From 03533b3b044dc8d7f7639c8db5362422e9167fc8 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 8 Jun 2026 07:45:10 +0200 Subject: [PATCH 1/6] feat(hermes): add tokensave memory provider --- src/agents/hermes.rs | 209 ++++++++++++++++++++- src/cli.rs | 6 + src/main.rs | 154 +++++++++++++--- tests/agent_test.rs | 421 +++++++++++++++++++++++++++++++++++++++---- 4 files changed, 724 insertions(+), 66 deletions(-) diff --git a/src/agents/hermes.rs b/src/agents/hermes.rs index 64aac18114..70aaa2c270 100644 --- a/src/agents/hermes.rs +++ b/src/agents/hermes.rs @@ -52,7 +52,8 @@ impl AgentIntegration for HermesIntegration { install_plugin(&plugin_dir, &ctx.tokensave_bin)?; if profile.is_none() { eprintln!( - " Hermes project plugins require HERMES_ENABLE_PROJECT_PLUGINS=true when launching Hermes." + " Launch Hermes with HERMES_HOME={} so it reads this project-local plugin and memory provider config.", + project_path.join(".hermes").display() ); } Ok(()) @@ -228,7 +229,9 @@ fn disable_plugin(config_path: &Path) -> Result<()> { fn enable_plugin_config(existing: &str) -> std::result::Result { if existing.trim().is_empty() { - return Ok("plugins:\n enabled:\n - tokensave\n".to_string()); + return Ok( + "memory:\n provider: tokensave\nplugins:\n enabled:\n - tokensave\n".to_string(), + ); } let mut lines: Vec = existing.lines().map(str::to_string).collect(); @@ -242,7 +245,7 @@ fn enable_plugin_config(existing: &str) -> std::result::Result { out.push_str("\n\n"); } out.push_str("plugins:\n enabled:\n - tokensave\n"); - return Ok(out); + return enable_memory_provider_config(&out); } let (plugins_start, plugins_end) = find_top_level_section(existing, "plugins") @@ -266,7 +269,7 @@ fn enable_plugin_config(existing: &str) -> std::result::Result { lines.insert(plugins_start + 2, " - tokensave".to_string()); } - Ok(join_lines(lines, had_trailing_newline)) + enable_memory_provider_config(&join_lines(lines, had_trailing_newline)) } fn disable_plugin_config(existing: &str) -> std::result::Result { @@ -284,6 +287,64 @@ fn disable_plugin_config(existing: &str) -> std::result::Result if let Some((enabled_start, enabled_end)) = enabled { lines = remove_list_item(lines, enabled_start, enabled_end, "tokensave"); } + disable_memory_provider_config(&join_lines(lines, had_trailing_newline)) +} + +fn enable_memory_provider_config(existing: &str) -> std::result::Result { + if existing.trim().is_empty() { + return Ok("memory:\n provider: tokensave\n".to_string()); + } + + validate_top_level_memory_shape(existing)?; + let mut lines: Vec = existing.lines().map(str::to_string).collect(); + let had_trailing_newline = existing.ends_with('\n'); + + let Some((memory_start, memory_end)) = find_top_level_section(existing, "memory") else { + let mut out = existing.trim_end().to_string(); + if !out.is_empty() { + out.push_str("\n\n"); + } + out.push_str("memory:\n provider: tokensave\n"); + return Ok(out); + }; + + let provider_line = find_memory_provider_line(&lines, memory_start, memory_end) + .ok_or_else(|| "unsupported Hermes memory config".to_string())?; + if let Some(provider_line) = provider_line { + if lines[provider_line].trim() != "provider: tokensave" { + lines[provider_line] = " provider: tokensave".to_string(); + } + } else { + lines.insert(memory_start + 1, " provider: tokensave".to_string()); + } + + Ok(join_lines(lines, had_trailing_newline)) +} + +fn disable_memory_provider_config(existing: &str) -> std::result::Result { + if existing.trim().is_empty() { + return Ok(existing.to_string()); + } + + validate_top_level_memory_shape(existing)?; + let mut lines: Vec = existing.lines().map(str::to_string).collect(); + let had_trailing_newline = existing.ends_with('\n'); + let Some((memory_start, memory_end)) = find_top_level_section(existing, "memory") else { + return Ok(existing.to_string()); + }; + let provider_line = find_memory_provider_line(&lines, memory_start, memory_end) + .ok_or_else(|| "unsupported Hermes memory config".to_string())?; + let mut removed_provider = false; + if let Some(provider_line) = provider_line { + if lines[provider_line].trim() == "provider: tokensave" { + lines.remove(provider_line); + removed_provider = true; + } + } + if removed_provider { + remove_empty_top_level_section(&mut lines, "memory"); + } + Ok(join_lines(lines, had_trailing_newline)) } @@ -305,6 +366,24 @@ fn validate_top_level_plugins_shape(existing: &str) -> std::result::Result<(), S } } +fn validate_top_level_memory_shape(existing: &str) -> std::result::Result<(), String> { + let memory_lines = existing + .lines() + .filter(|line| { + let trimmed = line.trim(); + line_indent(line) == 0 && !trimmed.starts_with('#') && trimmed.starts_with("memory:") + }) + .collect::>(); + match memory_lines.as_slice() { + [] => Ok(()), + [line] if line.trim() == "memory:" => Ok(()), + _ => Err( + "unsupported Hermes memory config; expected a block-style `memory:` mapping" + .to_string(), + ), + } +} + fn find_top_level_section(config: &str, key: &str) -> Option<(usize, usize)> { let lines: Vec<&str> = config.lines().collect(); find_top_level_section_in(&lines, key) @@ -388,6 +467,40 @@ fn find_child_section_in( Some(Some((start, end))) } +fn find_memory_provider_line( + lines: &[String], + memory_start: usize, + memory_end: usize, +) -> Option> { + for (idx, line) in lines + .iter() + .enumerate() + .take(memory_end) + .skip(memory_start + 1) + { + if line.trim_start().starts_with('\t') { + return None; + } + if line_indent(line) == 2 && line.trim_start().starts_with("provider:") { + return Some(Some(idx)); + } + } + Some(None) +} + +fn remove_empty_top_level_section(lines: &mut Vec, key: &str) { + let Some((start, end)) = find_top_level_section_from_strings(lines, key) else { + return; + }; + let has_content = lines.iter().take(end).skip(start + 1).any(|line| { + let trimmed = line.trim(); + !trimmed.is_empty() + }); + if !has_content { + lines.drain(start..end); + } +} + fn list_contains_item_strings(lines: &[String], start: usize, end: usize, item: &str) -> bool { lines .iter() @@ -618,10 +731,23 @@ def make_handler(name: str): fn plugin_init() -> String { r#""""tokensave Hermes plugin registration.""" +import json +import shutil from pathlib import Path from . import schemas, tools +try: + from agent.memory_provider import MemoryProvider +except Exception: + class MemoryProvider: + pass + +MEMORY_TOOL_MAP = { + "fact_store": "tokensave_fact_store", + "fact_feedback": "tokensave_fact_feedback", +} + def _pre_llm_call(*args, **kwargs): return ( "Prefer tokensave tools for codebase exploration, symbol lookup, call graphs, " @@ -631,6 +757,73 @@ def _pre_llm_call(*args, **kwargs): def _tokensave_status(raw_args: str = ""): return tools.call_tokensave_tool("tokensave_status", {}) +def _memory_schema(tokensave_name: str, hermes_name: str) -> dict: + for schema in schemas.TOOL_SCHEMAS: + if schema.get("name") == tokensave_name: + return { + "name": hermes_name, + "description": schema.get("description", ""), + "parameters": schema.get("parameters", {}), + } + return { + "name": hermes_name, + "description": f"Tokensave memory tool {hermes_name}.", + "parameters": {"type": "object", "properties": {}}, + } + +def _decode_tool_args(arguments): + if arguments is None: + return {} + if isinstance(arguments, dict): + return arguments + if isinstance(arguments, str): + if not arguments.strip(): + return {} + try: + return json.loads(arguments) + except json.JSONDecodeError: + return {"arguments": arguments} + return {"arguments": arguments} + +def _normalize_memory_tool_call(name, arguments): + if isinstance(name, dict): + function = name.get("function") or {} + tool_name = name.get("name") or function.get("name") + tool_args = name.get("arguments", function.get("arguments", arguments)) + return tool_name, _decode_tool_args(tool_args) + return name, _decode_tool_args(arguments) + +class TokensaveMemoryProvider(MemoryProvider): + provider_id = "tokensave" + + def __init__(self): + self.hermes_home = None + self.session_id = None + + @property + def name(self) -> str: + return "tokensave" + + def is_available(self) -> bool: + return shutil.which(tools.TOKENSAVE_BIN) is not None + + def initialize(self, session_id=None, **kwargs): + self.hermes_home = kwargs.get("hermes_home") + self.session_id = session_id + + def get_tool_schemas(self): + return [ + _memory_schema("tokensave_fact_store", "fact_store"), + _memory_schema("tokensave_fact_feedback", "fact_feedback"), + ] + + def handle_tool_call(self, name, arguments=None, **kwargs) -> str: + tool_name, tool_args = _normalize_memory_tool_call(name, arguments) + tokensave_name = MEMORY_TOOL_MAP.get(tool_name) + if tokensave_name is None: + return tools.error_payload(f"unknown memory tool: {tool_name}") + return tools.call_tokensave_tool(tokensave_name, tool_args, **kwargs) + def register(ctx): for schema in schemas.TOOL_SCHEMAS: name = schema["name"] @@ -650,10 +843,14 @@ def register(ctx): description="Show tokensave project status.", ) + if callable(getattr(ctx, "register_memory_provider", None)): + ctx.register_memory_provider(TokensaveMemoryProvider()) + skills_dir = Path(__file__).parent / "skills" skill_path = skills_dir / "tokensave" / "SKILL.md" - if skill_path.exists(): - ctx.register_skill("tokensave:tokensave", skill_path) + register_skill = getattr(ctx, "register_skill", None) + if skill_path.exists() and callable(register_skill): + register_skill("tokensave:tokensave", skill_path) "# .to_string() } diff --git a/src/cli.rs b/src/cli.rs index 5dde84e649..cb9c27160e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -91,6 +91,9 @@ pub enum Commands { /// Hermes profile to install into (only used with --agent hermes) #[arg(long)] profile: Option, + /// Install into the default profile and every Hermes profile directory + #[arg(long, conflicts_with = "profile")] + all_profiles: bool, }, /// Refresh settings for all already-installed agents Reinstall, @@ -103,6 +106,9 @@ pub enum Commands { /// Hermes profile to uninstall from (only used with --agent hermes) #[arg(long)] profile: Option, + /// Uninstall from the default profile and every Hermes profile directory + #[arg(long, conflicts_with = "profile")] + all_profiles: bool, }, /// Extraction worker (spawned by tokensave itself; not for direct use). #[command(name = "extract-worker", hide = true)] diff --git a/src/main.rs b/src/main.rs index e3a744cf74..0ee787fa03 100644 --- a/src/main.rs +++ b/src/main.rs @@ -97,6 +97,89 @@ impl Drop for Spinner { } } +fn hermes_profile_targets( + home: &std::path::Path, +) -> tokensave::errors::Result>> { + let mut targets = vec![None]; + let profiles_dir = home.join(".hermes/profiles"); + let entries = match std::fs::read_dir(&profiles_dir) { + Ok(entries) => entries, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(targets), + Err(e) => { + return Err(tokensave::errors::TokenSaveError::Config { + message: format!( + "failed to read Hermes profiles directory {}: {e}", + profiles_dir.display() + ), + }); + } + }; + + let mut profile_names = Vec::new(); + for entry in entries { + let entry = entry.map_err(|e| tokensave::errors::TokenSaveError::Config { + message: format!( + "failed to read Hermes profiles directory {}: {e}", + profiles_dir.display() + ), + })?; + let file_type = + entry + .file_type() + .map_err(|e| tokensave::errors::TokenSaveError::Config { + message: format!( + "failed to inspect Hermes profile {}: {e}", + entry.path().display() + ), + })?; + if !file_type.is_dir() { + continue; + } + let name = entry.file_name().into_string().map_err(|_| { + tokensave::errors::TokenSaveError::Config { + message: format!( + "Hermes profile path is not valid UTF-8: {}", + entry.path().display() + ), + } + })?; + profile_names.push(name); + } + profile_names.sort(); + targets.extend(profile_names.into_iter().map(Some)); + Ok(targets) +} + +fn validate_hermes_profile_flags( + agent: Option<&str>, + profile: &Option, + all_profiles: bool, +) -> tokensave::errors::Result<()> { + if profile.is_some() && agent != Some("hermes") { + return Err(tokensave::errors::TokenSaveError::Config { + message: "`--profile` is only supported with `--agent hermes`".to_string(), + }); + } + if all_profiles && agent != Some("hermes") { + return Err(tokensave::errors::TokenSaveError::Config { + message: "`--all-profiles` is only supported with `--agent hermes`".to_string(), + }); + } + Ok(()) +} + +fn hermes_selected_profile_targets( + home: &std::path::Path, + profile: &Option, + all_profiles: bool, +) -> tokensave::errors::Result>> { + if all_profiles { + hermes_profile_targets(home) + } else { + Ok(vec![profile.clone()]) + } +} + #[tokio::main] async fn main() { let cli = Cli::parse(); @@ -546,12 +629,9 @@ async fn run(cli: Cli) -> tokensave::errors::Result<()> { agent, local, profile, + all_profiles, } => { - if profile.is_some() && agent.as_deref() != Some("hermes") { - return Err(tokensave::errors::TokenSaveError::Config { - message: "`--profile` is only supported with `--agent hermes`".to_string(), - }); - } + validate_hermes_profile_flags(agent.as_deref(), &profile, all_profiles)?; let home = tokensave::agents::home_dir().ok_or_else(|| { tokensave::errors::TokenSaveError::Config { message: "could not determine home directory".to_string(), @@ -581,7 +661,17 @@ async fn run(cli: Cli) -> tokensave::errors::Result<()> { if let Some(id) = agent { let ag = tokensave::agents::get_integration(&id)?; - ag.install_local(&ctx, &project_path)?; + for target_profile in + hermes_selected_profile_targets(&home, &profile, all_profiles)? + { + let ctx = tokensave::agents::InstallContext { + home: home.clone(), + tokensave_bin: tokensave_bin.clone(), + tool_permissions: tokensave::agents::expected_tool_perms(), + profile: target_profile, + }; + ag.install_local(&ctx, &project_path)?; + } installed_names.push(ag.name().to_string()); } else { let (to_install, _) = @@ -620,13 +710,17 @@ async fn run(cli: Cli) -> tokensave::errors::Result<()> { if let Some(id) = agent { let ag = tokensave::agents::get_integration(&id)?; let name = ag.name().to_string(); - let ctx = tokensave::agents::InstallContext { - home: home.clone(), - tokensave_bin: tokensave_bin.clone(), - tool_permissions: tokensave::agents::expected_tool_perms(), - profile: profile.clone(), - }; - ag.install(&ctx)?; + for target_profile in + hermes_selected_profile_targets(&home, &profile, all_profiles)? + { + let ctx = tokensave::agents::InstallContext { + home: home.clone(), + tokensave_bin: tokensave_bin.clone(), + tool_permissions: tokensave::agents::expected_tool_perms(), + profile: target_profile, + }; + ag.install(&ctx)?; + } if !user_cfg.installed_agents.contains(&id) { user_cfg.installed_agents.push(id); installed_names.push(name); @@ -722,12 +816,12 @@ async fn run(cli: Cli) -> tokensave::errors::Result<()> { user_cfg.save(); } } - Commands::Uninstall { agent, profile } => { - if profile.is_some() && agent.as_deref() != Some("hermes") { - return Err(tokensave::errors::TokenSaveError::Config { - message: "`--profile` is only supported with `--agent hermes`".to_string(), - }); - } + Commands::Uninstall { + agent, + profile, + all_profiles, + } => { + validate_hermes_profile_flags(agent.as_deref(), &profile, all_profiles)?; let home = tokensave::agents::home_dir().ok_or_else(|| { tokensave::errors::TokenSaveError::Config { message: "could not determine home directory".to_string(), @@ -738,13 +832,17 @@ async fn run(cli: Cli) -> tokensave::errors::Result<()> { if let Some(id) = agent { let ag = tokensave::agents::get_integration(&id)?; - let ctx = tokensave::agents::InstallContext { - home, - tokensave_bin: String::new(), - tool_permissions: tokensave::agents::expected_tool_perms(), - profile: profile.clone(), - }; - ag.uninstall(&ctx)?; + for target_profile in + hermes_selected_profile_targets(&home, &profile, all_profiles)? + { + let ctx = tokensave::agents::InstallContext { + home: home.clone(), + tokensave_bin: String::new(), + tool_permissions: tokensave::agents::expected_tool_perms(), + profile: target_profile, + }; + ag.uninstall(&ctx)?; + } user_cfg.installed_agents.retain(|a| a != &id); user_cfg.save(); } else { @@ -1290,11 +1388,13 @@ mod startup_tests { agent: Some("kiro".to_string()), local: false, profile: None, + all_profiles: false, })); assert!(should_skip_startup_maintenance(&Commands::Reinstall)); assert!(should_skip_startup_maintenance(&Commands::Uninstall { agent: Some("kiro".to_string()), profile: None, + all_profiles: false, })); } @@ -1322,6 +1422,7 @@ mod startup_tests { agent: Some("cursor".to_string()), local: false, profile: None, + all_profiles: false, })); assert!(should_skip_agent_install_maintenance(&Commands::Reinstall)); assert!(should_skip_agent_install_maintenance(&Commands::Tool { @@ -1335,6 +1436,7 @@ mod startup_tests { &Commands::Uninstall { agent: Some("cursor".to_string()), profile: None, + all_profiles: false, } )); assert!(should_skip_agent_install_maintenance(&Commands::Doctor { diff --git a/tests/agent_test.rs b/tests/agent_test.rs index 0d94fc19ee..c8ba0fa988 100644 --- a/tests/agent_test.rs +++ b/tests/agent_test.rs @@ -127,23 +127,29 @@ fn make_install_ctx(home: &Path) -> InstallContext { } } -fn run_local_install(agent: &str, project: &Path, home: &Path) -> std::process::Output { - Command::new(env!("CARGO_BIN_EXE_tokensave")) - .arg("install") - .arg("--local") - .arg("--agent") - .arg(agent) +fn tokensave_command(project: &Path, home: &Path) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_tokensave")); + command .current_dir(project) .env("HOME", home) .env("USERPROFILE", home) .env("XDG_CONFIG_HOME", home.join(".config")) .env("KIRO_HOME", home.join(".kiro")) - .env("VIBE_HOME", home.join(".vibe")) + .env("VIBE_HOME", home.join(".vibe")); + command +} + +fn run_local_install(agent: &str, project: &Path, home: &Path) -> std::process::Output { + tokensave_command(project, home) + .arg("install") + .arg("--local") + .arg("--agent") + .arg(agent) .output() .unwrap_or_else(|e| panic!("failed to run local install for {agent}: {e}")) } -fn assert_local_install_success(agent: &str, project: &Path, home: &Path) { +fn assert_local_install_success(agent: &str, project: &Path, home: &Path) -> std::process::Output { let output = run_local_install(agent, project, home); assert!( output.status.success(), @@ -151,6 +157,7 @@ fn assert_local_install_success(agent: &str, project: &Path, home: &Path) { String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); + output } fn read_json(path: &Path) -> serde_json::Value { @@ -195,6 +202,36 @@ fn assert_command_is_tokensave(json: &serde_json::Value, command_path: &[&str]) ); } +fn assert_hermes_config_enables_tokensave_memory(config_path: &Path) -> String { + let config = std::fs::read_to_string(config_path).unwrap_or_else(|e| { + panic!( + "failed to read Hermes config {}: {e}", + config_path.display() + ) + }); + assert!( + config.contains("memory:"), + "missing memory block:\n{config}" + ); + assert!( + config.contains(" provider: tokensave"), + "missing tokensave memory provider:\n{config}" + ); + assert!( + config.contains("plugins:"), + "missing plugins block:\n{config}" + ); + assert!( + config.contains("enabled:"), + "missing enabled block:\n{config}" + ); + assert!( + config.contains("- tokensave"), + "missing tokensave plugin enablement:\n{config}" + ); + config +} + #[test] fn test_local_install_cursor_writes_project_config_only() { let home = TempDir::new().unwrap(); @@ -391,7 +428,7 @@ fn test_hermes_local_install_writes_profile_plugin() { let home = TempDir::new().unwrap(); let project = TempDir::new().unwrap(); - assert_local_install_success("hermes", project.path(), home.path()); + let output = assert_local_install_success("hermes", project.path(), home.path()); let plugin_dir = project.path().join(".hermes/plugins/tokensave"); let manifest = std::fs::read_to_string(plugin_dir.join("plugin.yaml")).unwrap(); @@ -406,10 +443,13 @@ fn test_hermes_local_install_writes_profile_plugin() { let init_py = std::fs::read_to_string(plugin_dir.join("__init__.py")).unwrap(); assert!(init_py.contains("def register(ctx):")); + assert!(init_py.contains("class TokensaveMemoryProvider")); + assert!(init_py.contains("ctx.register_memory_provider(")); assert!(init_py.contains("ctx.register_tool(")); assert!(init_py.contains("ctx.register_hook(\"pre_llm_call\"")); assert!(init_py.contains("getattr(ctx, \"register_command\", None)")); - assert!(init_py.contains("ctx.register_skill(\"tokensave:tokensave\"")); + assert!(init_py.contains("getattr(ctx, \"register_skill\", None)")); + assert!(init_py.contains("register_skill(\"tokensave:tokensave\"")); let schemas_py = std::fs::read_to_string(plugin_dir.join("schemas.py")).unwrap(); assert!(schemas_py.contains("TOOL_SCHEMAS")); @@ -438,14 +478,19 @@ fn test_hermes_local_install_writes_profile_plugin() { let skill = std::fs::read_to_string(plugin_dir.join("skills/tokensave/SKILL.md")).unwrap(); assert!(skill.contains("Use tokensave")); - let config = std::fs::read_to_string(project.path().join(".hermes/config.yaml")).unwrap(); - assert!(config.contains("plugins:")); - assert!(config.contains("enabled:")); - assert!(config.contains("- tokensave")); + assert_hermes_config_enables_tokensave_memory(&project.path().join(".hermes/config.yaml")); assert!( !home.path().join(".hermes/config.yaml").exists(), "plain local install must not mutate the user profile config" ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains(&format!( + "HERMES_HOME={}", + project.path().join(".hermes").display() + )), + "plain local install guidance must tell users to launch Hermes with the project-local HERMES_HOME\nstderr:\n{stderr}" + ); } #[test] @@ -493,6 +538,7 @@ class Result: def fake_run(argv, **kwargs): assert argv[0] == expected_bin assert argv[1:] == ["tool", "tokensave_context", "--json", "--args", "{\"query\": \"x\"}"] + assert "cwd" not in kwargs assert kwargs["timeout"] == 600 assert kwargs["shell"] is False return Result() @@ -522,6 +568,205 @@ assert payload["stderr"].endswith("...") ); } +#[test] +fn test_hermes_generated_python_registers_memory_provider() { + let home = TempDir::new().unwrap(); + HermesIntegration + .install(&make_install_ctx_with_real_bin(home.path())) + .unwrap(); + + let plugin_dir = home.path().join(".hermes/plugins/tokensave"); + assert_python_compiles(&[ + &plugin_dir.join("tools.py"), + &plugin_dir.join("schemas.py"), + &plugin_dir.join("__init__.py"), + ]); + + let script = plugin_dir.join("check_memory_provider.py"); + std::fs::write( + &script, + r#" +import importlib +import importlib.machinery +import importlib.util +import abc +import json +import pathlib +import sys +import types + +plugin_dir = pathlib.Path(sys.argv[1]) + +class MemoryProvider(abc.ABC): + @property + @abc.abstractmethod + def name(self): + pass + + @abc.abstractmethod + def is_available(self): + pass + + @abc.abstractmethod + def initialize(self, session_id, **kwargs): + pass + + @abc.abstractmethod + def get_tool_schemas(self): + pass + +agent_module = types.ModuleType("agent") +memory_provider_module = types.ModuleType("agent.memory_provider") +memory_provider_module.MemoryProvider = MemoryProvider +sys.modules["agent"] = agent_module +sys.modules["agent.memory_provider"] = memory_provider_module + +parent_name = "_hermes_user_memory" +parent_spec = importlib.machinery.ModuleSpec(parent_name, None, is_package=True) +parent_spec.submodule_search_locations = [] +parent_module = importlib.util.module_from_spec(parent_spec) +sys.modules[parent_name] = parent_module + +module_name = f"{parent_name}.tokensave" +spec = importlib.util.spec_from_file_location( + module_name, + plugin_dir / "__init__.py", + submodule_search_locations=[str(plugin_dir)], +) +plugin = importlib.util.module_from_spec(spec) +sys.modules[module_name] = plugin +spec.loader.exec_module(plugin) + +class FullCtx: + def __init__(self): + self.tools = [] + self.hooks = [] + self.commands = [] + self.skills = [] + self.memory_providers = [] + + def register_tool(self, **kwargs): + self.tools.append(kwargs) + + def register_hook(self, name, handler): + self.hooks.append((name, handler)) + + def register_command(self, name, handler, **kwargs): + self.commands.append((name, handler, kwargs)) + + def register_skill(self, name, path): + self.skills.append((name, path)) + + def register_memory_provider(self, provider): + self.memory_providers.append(provider) + +ctx = FullCtx() +plugin.register(ctx) +assert any(tool["name"] == "tokensave_context" for tool in ctx.tools) +assert ctx.hooks and ctx.hooks[0][0] == "pre_llm_call" +assert ctx.commands and ctx.commands[0][0] == "/tokensave_status" +assert ctx.skills and ctx.skills[0][0] == "tokensave:tokensave" +assert len(ctx.memory_providers) == 1 + +provider = ctx.memory_providers[0] +assert isinstance(provider, MemoryProvider) +assert provider.name == "tokensave" +assert provider.provider_id == "tokensave" +assert provider.is_available() is True +original_bin = plugin.tools.TOKENSAVE_BIN +plugin.tools.TOKENSAVE_BIN = "/definitely/missing/tokensave" +assert provider.is_available() is False +plugin.tools.TOKENSAVE_BIN = original_bin +provider.initialize("session-123", hermes_home="/tmp/hermes-profile") +assert provider.hermes_home == "/tmp/hermes-profile" +assert provider.session_id == "session-123" +provider.initialize("session-only") +assert provider.hermes_home is None +assert provider.session_id == "session-only" + +schemas = provider.get_tool_schemas() +schema_names = [schema.get("name") for schema in schemas] +assert schema_names == ["fact_store", "fact_feedback"] +assert all("function" not in schema for schema in schemas) +fact_store_schema = schemas[0] +fact_feedback_schema = schemas[1] +assert fact_store_schema["parameters"]["required"] == ["action"] +assert fact_feedback_schema["parameters"]["required"] == ["fact_id"] + +calls = [] + +def fake_call(name, args, **kwargs): + calls.append((name, args, kwargs)) + return json.dumps({"name": name, "args": args}) + +plugin.tools.call_tokensave_tool = fake_call +store_result = provider.handle_tool_call("fact_store", {"action": "list"}, request_id="r1") +feedback_result = provider.handle_tool_call("fact_feedback", {"fact_id": 7, "helpful": True}) +assert isinstance(store_result, str) +assert isinstance(feedback_result, str) +assert json.loads(store_result)["name"] == "tokensave_fact_store" +assert json.loads(feedback_result)["name"] == "tokensave_fact_feedback" +assert calls[0][0] == "tokensave_fact_store" +assert calls[0][1] == {"action": "list"} +assert calls[0][2]["request_id"] == "r1" +assert calls[1][0] == "tokensave_fact_feedback" + +class LegacyCtx: + def __init__(self): + self.tools = [] + self.hooks = [] + + def register_tool(self, **kwargs): + self.tools.append(kwargs) + + def register_hook(self, name, handler): + self.hooks.append((name, handler)) + + def register_skill(self, name, path): + pass + +legacy = LegacyCtx() +plugin.register(legacy) +assert any(tool["name"] == "tokensave_context" for tool in legacy.tools) +assert legacy.hooks and legacy.hooks[0][0] == "pre_llm_call" + +class ProviderCollector: + def __init__(self): + self.provider = None + + def register_memory_provider(self, provider): + self.provider = provider + + def register_tool(self, *args, **kwargs): + pass + + def register_hook(self, *args, **kwargs): + pass + + def register_cli_command(self, *args, **kwargs): + pass + +collector = ProviderCollector() +plugin.register(collector) +assert collector.provider is not None +assert collector.provider.name == "tokensave" +"#, + ) + .unwrap(); + + let output = Command::new("python3") + .arg(&script) + .arg(plugin_dir) + .output() + .expect("python3 should run generated Hermes memory provider check"); + assert!( + output.status.success(), + "generated plugin should register a Hermes memory provider\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + #[test] fn test_hermes_global_install_and_uninstall_plugin() { let home = TempDir::new().unwrap(); @@ -545,6 +790,10 @@ fn test_hermes_global_install_and_uninstall_plugin() { !config.contains("- tokensave"), "uninstall should remove tokensave from plugins.enabled" ); + assert!( + !config.contains("memory:\n"), + "uninstall should remove the empty tokensave-created memory block" + ); } #[test] @@ -569,6 +818,11 @@ fn test_hermes_profile_install_targets_named_profile() { .join(".hermes/profiles/work_profile/config.yaml"), ) .expect("profile config should be written"); + assert_hermes_config_enables_tokensave_memory( + &home + .path() + .join(".hermes/profiles/work_profile/config.yaml"), + ); assert!(config.contains("- tokensave")); HermesIntegration.uninstall(&ctx).unwrap(); @@ -576,21 +830,129 @@ fn test_hermes_profile_install_targets_named_profile() { assert!(home.path().join(".hermes/profiles/work_profile").exists()); } +#[test] +fn test_hermes_install_all_profiles_configures_default_and_named_profiles() { + let home = TempDir::new().unwrap(); + let project = TempDir::new().unwrap(); + let default_profile = home.path().join(".hermes"); + let work_profile = home.path().join(".hermes/profiles/work"); + let personal_profile = home.path().join(".hermes/profiles/personal"); + std::fs::create_dir_all(&work_profile).unwrap(); + std::fs::create_dir_all(&personal_profile).unwrap(); + std::fs::write( + default_profile.join("config.yaml"), + "theme: dark\nplugins:\n enabled:\n - other\n", + ) + .unwrap(); + std::fs::write( + work_profile.join("config.yaml"), + "theme: light\nmemory:\n retention: session\nplugins:\n disabled:\n - tokensave\n - other-disabled\n", + ) + .unwrap(); + + let output = tokensave_command(project.path(), home.path()) + .arg("install") + .arg("--agent") + .arg("hermes") + .arg("--all-profiles") + .output() + .expect("run hermes all-profiles install"); + assert!( + output.status.success(), + "hermes all-profiles install should succeed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + for profile in [&default_profile, &work_profile, &personal_profile] { + assert!( + profile.join("plugins/tokensave/plugin.yaml").exists(), + "tokensave plugin should be installed in {}", + profile.display() + ); + assert_hermes_config_enables_tokensave_memory(&profile.join("config.yaml")); + } + let default_config = std::fs::read_to_string(default_profile.join("config.yaml")).unwrap(); + assert!(default_config.contains(" - other")); + let work_config = std::fs::read_to_string(work_profile.join("config.yaml")).unwrap(); + assert!(work_config.contains(" retention: session")); + assert!(!work_config.contains(" disabled:\n - tokensave")); + assert!( + !project + .path() + .join(".hermes/plugins/tokensave/plugin.yaml") + .exists(), + "profile-level all-profiles install must not write a project-local plugin" + ); +} + +#[test] +fn test_hermes_uninstall_all_profiles_cleans_only_tokensave_from_each_profile() { + let home = TempDir::new().unwrap(); + let project = TempDir::new().unwrap(); + let default_profile = home.path().join(".hermes"); + let work_profile = home.path().join(".hermes/profiles/work"); + let personal_profile = home.path().join(".hermes/profiles/personal"); + + for profile in [&default_profile, &work_profile, &personal_profile] { + let plugin_dir = profile.join("plugins/tokensave"); + let other_plugin_dir = profile.join("plugins/other"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + std::fs::create_dir_all(&other_plugin_dir).unwrap(); + std::fs::write(plugin_dir.join("plugin.yaml"), "name: tokensave\n").unwrap(); + std::fs::write(other_plugin_dir.join("plugin.yaml"), "name: other\n").unwrap(); + std::fs::write( + profile.join("config.yaml"), + "theme: dark\nmemory:\n provider: tokensave\nplugins:\n enabled:\n - tokensave\n - other\n", + ) + .unwrap(); + } + + let output = tokensave_command(project.path(), home.path()) + .arg("uninstall") + .arg("--agent") + .arg("hermes") + .arg("--all-profiles") + .output() + .expect("run hermes all-profiles uninstall"); + assert!( + output.status.success(), + "hermes all-profiles uninstall should succeed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + for profile in [&default_profile, &work_profile, &personal_profile] { + assert!( + !profile.join("plugins/tokensave/plugin.yaml").exists(), + "tokensave plugin should be removed from {}", + profile.display() + ); + assert!( + profile.join("plugins/other/plugin.yaml").exists(), + "uninstall must preserve unrelated plugins in {}", + profile.display() + ); + let config = std::fs::read_to_string(profile.join("config.yaml")).unwrap(); + assert!(config.contains("theme: dark")); + assert!(config.contains(" - other")); + assert!(!config.contains(" - tokensave")); + assert!(!config.contains("provider: tokensave")); + } +} + #[test] fn test_hermes_local_install_with_profile_targets_named_profile() { let home = TempDir::new().unwrap(); let project = TempDir::new().unwrap(); - let output = Command::new(env!("CARGO_BIN_EXE_tokensave")) + let output = tokensave_command(project.path(), home.path()) .arg("install") .arg("--local") .arg("--agent") .arg("hermes") .arg("--profile") .arg("project") - .current_dir(project.path()) - .env("HOME", home.path()) - .env("USERPROFILE", home.path()) .output() .expect("run hermes local install with profile"); assert!( @@ -603,6 +965,9 @@ fn test_hermes_local_install_with_profile_targets_named_profile() { .path() .join(".hermes/profiles/project/plugins/tokensave/plugin.yaml") .exists()); + assert_hermes_config_enables_tokensave_memory( + &home.path().join(".hermes/profiles/project/config.yaml"), + ); assert!( !project .path() @@ -632,15 +997,12 @@ fn test_profile_flag_is_only_valid_for_hermes_install() { let home = TempDir::new().unwrap(); let project = TempDir::new().unwrap(); - let output = Command::new(env!("CARGO_BIN_EXE_tokensave")) + let output = tokensave_command(project.path(), home.path()) .arg("install") .arg("--agent") .arg("cursor") .arg("--profile") .arg("work") - .current_dir(project.path()) - .env("HOME", home.path()) - .env("USERPROFILE", home.path()) .output() .expect("run install with invalid --profile agent"); @@ -654,15 +1016,12 @@ fn test_profile_flag_is_valid_for_hermes_uninstall_only() { let home = TempDir::new().unwrap(); let project = TempDir::new().unwrap(); - let install = Command::new(env!("CARGO_BIN_EXE_tokensave")) + let install = tokensave_command(project.path(), home.path()) .arg("install") .arg("--agent") .arg("hermes") .arg("--profile") .arg("work") - .current_dir(project.path()) - .env("HOME", home.path()) - .env("USERPROFILE", home.path()) .output() .expect("run hermes profile install"); assert!( @@ -673,15 +1032,12 @@ fn test_profile_flag_is_valid_for_hermes_uninstall_only() { let plugin_dir = home.path().join(".hermes/profiles/work/plugins/tokensave"); assert!(plugin_dir.exists()); - let uninstall = Command::new(env!("CARGO_BIN_EXE_tokensave")) + let uninstall = tokensave_command(project.path(), home.path()) .arg("uninstall") .arg("--agent") .arg("hermes") .arg("--profile") .arg("work") - .current_dir(project.path()) - .env("HOME", home.path()) - .env("USERPROFILE", home.path()) .output() .expect("run hermes profile uninstall"); assert!( @@ -691,15 +1047,12 @@ fn test_profile_flag_is_valid_for_hermes_uninstall_only() { ); assert!(!plugin_dir.exists()); - let invalid = Command::new(env!("CARGO_BIN_EXE_tokensave")) + let invalid = tokensave_command(project.path(), home.path()) .arg("uninstall") .arg("--agent") .arg("cursor") .arg("--profile") .arg("work") - .current_dir(project.path()) - .env("HOME", home.path()) - .env("USERPROFILE", home.path()) .output() .expect("run non-Hermes uninstall with profile"); assert!(!invalid.status.success()); From 72e62bbe62fa1520a5757441207e74d2b05f99dd Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 8 Jun 2026 07:55:18 +0200 Subject: [PATCH 2/6] fix(hermes): stabilize memory provider checks --- src/agents/hermes.rs | 8 +++++++- tests/agent_test.rs | 8 ++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/agents/hermes.rs b/src/agents/hermes.rs index 70aaa2c270..2a34f3e75e 100644 --- a/src/agents/hermes.rs +++ b/src/agents/hermes.rs @@ -732,6 +732,7 @@ def make_handler(name: str): fn plugin_init() -> String { r#""""tokensave Hermes plugin registration.""" import json +import os import shutil from pathlib import Path @@ -793,6 +794,11 @@ def _normalize_memory_tool_call(name, arguments): return tool_name, _decode_tool_args(tool_args) return name, _decode_tool_args(arguments) +def _tokensave_binary_available() -> bool: + if os.path.dirname(tools.TOKENSAVE_BIN): + return Path(tools.TOKENSAVE_BIN).is_file() and os.access(tools.TOKENSAVE_BIN, os.X_OK) + return shutil.which(tools.TOKENSAVE_BIN) is not None + class TokensaveMemoryProvider(MemoryProvider): provider_id = "tokensave" @@ -805,7 +811,7 @@ class TokensaveMemoryProvider(MemoryProvider): return "tokensave" def is_available(self) -> bool: - return shutil.which(tools.TOKENSAVE_BIN) is not None + return _tokensave_binary_available() def initialize(self, session_id=None, **kwargs): self.hermes_home = kwargs.get("hermes_home") diff --git a/tests/agent_test.rs b/tests/agent_test.rs index c8ba0fa988..71987d935f 100644 --- a/tests/agent_test.rs +++ b/tests/agent_test.rs @@ -484,11 +484,11 @@ fn test_hermes_local_install_writes_profile_plugin() { "plain local install must not mutate the user profile config" ); let stderr = String::from_utf8_lossy(&output.stderr); + let expected_hermes_home = std::fs::canonicalize(project.path()) + .unwrap_or_else(|_| project.path().to_path_buf()) + .join(".hermes"); assert!( - stderr.contains(&format!( - "HERMES_HOME={}", - project.path().join(".hermes").display() - )), + stderr.contains(&format!("HERMES_HOME={}", expected_hermes_home.display())), "plain local install guidance must tell users to launch Hermes with the project-local HERMES_HOME\nstderr:\n{stderr}" ); } From 20c4c7b76c6e463591c5b62c85eb871fbedccd9f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 8 Jun 2026 08:02:38 +0200 Subject: [PATCH 3/6] fix(hermes): allow local path aliases in test --- tests/agent_test.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/agent_test.rs b/tests/agent_test.rs index 71987d935f..3a7ccd076e 100644 --- a/tests/agent_test.rs +++ b/tests/agent_test.rs @@ -484,11 +484,16 @@ fn test_hermes_local_install_writes_profile_plugin() { "plain local install must not mutate the user profile config" ); let stderr = String::from_utf8_lossy(&output.stderr); - let expected_hermes_home = std::fs::canonicalize(project.path()) - .unwrap_or_else(|_| project.path().to_path_buf()) - .join(".hermes"); + let expected_hermes_homes = [ + project.path().join(".hermes"), + std::fs::canonicalize(project.path()) + .unwrap_or_else(|_| project.path().to_path_buf()) + .join(".hermes"), + ]; assert!( - stderr.contains(&format!("HERMES_HOME={}", expected_hermes_home.display())), + expected_hermes_homes + .iter() + .any(|path| stderr.contains(&format!("HERMES_HOME={}", path.display()))), "plain local install guidance must tell users to launch Hermes with the project-local HERMES_HOME\nstderr:\n{stderr}" ); } From 2687ef51b2cdb594876b8baaa46e839715510bd8 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 8 Jun 2026 08:19:37 +0200 Subject: [PATCH 4/6] fix(hermes): preserve existing memory providers --- src/agents/hermes.rs | 4 +++- tests/agent_test.rs | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/agents/hermes.rs b/src/agents/hermes.rs index 2a34f3e75e..ce0705e39b 100644 --- a/src/agents/hermes.rs +++ b/src/agents/hermes.rs @@ -312,7 +312,9 @@ fn enable_memory_provider_config(existing: &str) -> std::result::Result Date: Mon, 8 Jun 2026 08:35:35 +0200 Subject: [PATCH 5/6] fix(hermes): recognize profile healthchecks Teach Hermes doctor checks to find tokensave installs in named profiles and project-local Hermes homes, and cover generated memory-provider discovery against Hermes-style loading. --- src/agents/hermes.rs | 53 +++++++++++-- tests/agent_test.rs | 174 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 222 insertions(+), 5 deletions(-) diff --git a/src/agents/hermes.rs b/src/agents/hermes.rs index ce0705e39b..04c428f25c 100644 --- a/src/agents/hermes.rs +++ b/src/agents/hermes.rs @@ -70,7 +70,7 @@ impl AgentIntegration for HermesIntegration { fn healthcheck(&self, dc: &mut DoctorCounters, ctx: &HealthcheckContext) { eprintln!("\n\x1b[1mHermes integration\x1b[0m"); - doctor_check_plugin(dc, &ctx.home); + doctor_check_plugin(dc, &ctx.home, &ctx.project_path); } fn is_detected(&self, home: &Path) -> bool { @@ -122,19 +122,62 @@ fn normalize_profile(profile: Option<&str>) -> Result> { Ok(Some(normalized)) } -fn doctor_check_plugin(dc: &mut DoctorCounters, home: &Path) { - let plugin = hermes_plugin_dir(home, None).join("plugin.yaml"); - if plugin.exists() { +fn doctor_check_plugin(dc: &mut DoctorCounters, home: &Path, project_path: &Path) { + let candidates = hermes_healthcheck_plugin_paths(home, project_path); + let plugin = candidates.iter().find(|plugin| plugin.exists()); + if let Some(plugin) = plugin { dc.pass(&format!( "Hermes tokensave plugin found at {}", plugin.display() )); - } else { + } else if let Some(plugin) = candidates.first() { dc.warn(&format!( "{} not found — run `tokensave install --agent hermes` if you use Hermes", plugin.display() )); + } else { + dc.warn("Hermes tokensave plugin not found — run `tokensave install --agent hermes` if you use Hermes"); + } +} + +fn hermes_healthcheck_plugin_paths(home: &Path, project_path: &Path) -> Vec { + let mut roots = Vec::new(); + roots.push(hermes_home(home)); + + if let Some(env_home) = std::env::var_os("HERMES_HOME") { + if !env_home.is_empty() { + roots.push(PathBuf::from(env_home)); + } + } + + roots.extend(hermes_profile_dirs(home)); + roots.push(project_path.join(".hermes")); + + let mut seen = std::collections::BTreeSet::new(); + let mut plugins = Vec::new(); + for root in roots { + let plugin = root.join("plugins/tokensave/plugin.yaml"); + if seen.insert(plugin.clone()) { + plugins.push(plugin); + } } + plugins +} + +fn hermes_profile_dirs(home: &Path) -> Vec { + let profiles_dir = hermes_home(home).join("profiles"); + let Ok(entries) = std::fs::read_dir(&profiles_dir) else { + return Vec::new(); + }; + let mut profiles = entries + .filter_map(|entry| { + let entry = entry.ok()?; + let file_type = entry.file_type().ok()?; + file_type.is_dir().then(|| entry.path()) + }) + .collect::>(); + profiles.sort(); + profiles } fn install_plugin(plugin_dir: &Path, tokensave_bin: &str) -> Result<()> { diff --git a/tests/agent_test.rs b/tests/agent_test.rs index 00faa5d40f..f6d5f02366 100644 --- a/tests/agent_test.rs +++ b/tests/agent_test.rs @@ -772,6 +772,136 @@ assert collector.provider.name == "tokensave" ); } +#[test] +fn test_hermes_generated_memory_provider_is_discovered_from_active_home() { + let home = TempDir::new().unwrap(); + HermesIntegration + .install(&make_install_ctx_with_real_bin(home.path())) + .unwrap(); + + let hermes_home = home.path().join(".hermes"); + let plugin_dir = hermes_home.join("plugins/tokensave"); + let script = plugin_dir.join("check_hermes_discovery.py"); + std::fs::write( + &script, + r#" +import abc +import importlib.machinery +import importlib.util +import pathlib +import sys +import types + +hermes_home = pathlib.Path(sys.argv[1]) + +class MemoryProvider(abc.ABC): + @property + @abc.abstractmethod + def name(self): + pass + + @abc.abstractmethod + def is_available(self): + pass + + @abc.abstractmethod + def initialize(self, session_id, **kwargs): + pass + + @abc.abstractmethod + def get_tool_schemas(self): + pass + + def get_config_schema(self): + return [] + + def save_config(self, values, hermes_home): + pass + +agent_module = types.ModuleType("agent") +memory_provider_module = types.ModuleType("agent.memory_provider") +memory_provider_module.MemoryProvider = MemoryProvider +sys.modules["agent"] = agent_module +sys.modules["agent.memory_provider"] = memory_provider_module + +def is_memory_provider_dir(path): + init_file = path / "__init__.py" + if not init_file.exists(): + return False + source = init_file.read_text(errors="replace")[:8192] + return "register_memory_provider" in source or "MemoryProvider" in source + +def iter_user_provider_dirs(): + plugins_dir = hermes_home / "plugins" + for child in sorted(plugins_dir.iterdir()): + if child.is_dir() and not child.name.startswith(("_", ".")) and is_memory_provider_dir(child): + yield child.name, child + +def load_provider(provider_dir): + parent_name = "_hermes_user_memory" + if parent_name not in sys.modules: + parent_spec = importlib.machinery.ModuleSpec(parent_name, None, is_package=True) + parent_spec.submodule_search_locations = [] + sys.modules[parent_name] = importlib.util.module_from_spec(parent_spec) + + module_name = f"{parent_name}.{provider_dir.name}" + spec = importlib.util.spec_from_file_location( + module_name, + provider_dir / "__init__.py", + submodule_search_locations=[str(provider_dir)], + ) + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + + class ProviderCollector: + def __init__(self): + self.provider = None + def register_memory_provider(self, provider): + self.provider = provider + def register_tool(self, *args, **kwargs): + pass + def register_hook(self, *args, **kwargs): + pass + def register_cli_command(self, *args, **kwargs): + pass + + collector = ProviderCollector() + module.register(collector) + return collector.provider + +config = (hermes_home / "config.yaml").read_text() +assert "memory:" in config +assert "provider: tokensave" in config + +providers = dict(iter_user_provider_dirs()) +assert "tokensave" in providers +provider = load_provider(providers["tokensave"]) +assert provider is not None +assert isinstance(provider, MemoryProvider) +assert provider.name == "tokensave" +assert provider.is_available() is True +assert provider.get_config_schema() == [] +provider.initialize("doctor-session", hermes_home=str(hermes_home), platform="cli") +assert provider.hermes_home == str(hermes_home) +assert [schema["name"] for schema in provider.get_tool_schemas()] == ["fact_store", "fact_feedback"] +"#, + ) + .unwrap(); + + let output = Command::new("python3") + .arg(&script) + .arg(hermes_home) + .output() + .expect("python3 should run Hermes memory provider discovery check"); + assert!( + output.status.success(), + "Hermes-style memory provider discovery should find the generated tokensave provider\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + #[test] fn test_hermes_global_install_and_uninstall_plugin() { let home = TempDir::new().unwrap(); @@ -2801,6 +2931,50 @@ fn test_healthcheck_cursor_local_install_checks_project_config() { ); } +#[test] +fn test_healthcheck_hermes_profile_install_checks_named_profiles() { + let home = TempDir::new().unwrap(); + let project = TempDir::new().unwrap(); + let ctx = InstallContext { + home: home.path().to_path_buf(), + tokensave_bin: "/usr/local/bin/tokensave".to_string(), + tool_permissions: expected_tool_perms(), + profile: Some("work".to_string()), + }; + HermesIntegration.install(&ctx).unwrap(); + + let mut dc = DoctorCounters::new(); + let hctx = HealthcheckContext { + home: home.path().to_path_buf(), + project_path: project.path().to_path_buf(), + }; + HermesIntegration.healthcheck(&mut dc, &hctx); + assert_eq!(dc.issues, 0, "Hermes profile install should have no issues"); + assert_eq!( + dc.warnings, 0, + "Hermes healthcheck should recognize named profile installs" + ); +} + +#[test] +fn test_healthcheck_hermes_local_install_checks_project_hermes_home() { + let home = TempDir::new().unwrap(); + let project = TempDir::new().unwrap(); + assert_local_install_success("hermes", project.path(), home.path()); + + let mut dc = DoctorCounters::new(); + let hctx = HealthcheckContext { + home: home.path().to_path_buf(), + project_path: project.path().to_path_buf(), + }; + HermesIntegration.healthcheck(&mut dc, &hctx); + assert_eq!(dc.issues, 0, "Hermes local install should have no issues"); + assert_eq!( + dc.warnings, 0, + "Hermes healthcheck should recognize project-local HERMES_HOME installs" + ); +} + #[test] fn test_healthcheck_opencode_clean_install() { let dir = TempDir::new().unwrap(); From b00ac605284155775227d04bd69ea3795865bece Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 8 Jun 2026 20:36:04 +0200 Subject: [PATCH 6/6] fix(hermes): expose memory provider actions --- src/agents/hermes.rs | 75 ++++++++++++++++++++++++++++++++++++-------- tests/agent_test.rs | 34 +++++++++++++++++--- 2 files changed, 92 insertions(+), 17 deletions(-) diff --git a/src/agents/hermes.rs b/src/agents/hermes.rs index 04c428f25c..d8ca3f4988 100644 --- a/src/agents/hermes.rs +++ b/src/agents/hermes.rs @@ -789,10 +789,38 @@ except Exception: class MemoryProvider: pass -MEMORY_TOOL_MAP = { - "fact_store": "tokensave_fact_store", - "fact_feedback": "tokensave_fact_feedback", -} +MEMORY_FACT_ACTIONS = { + "fact_add": "add", + "fact_search": "search", + "fact_probe": "probe", + "fact_related": "related", + "fact_reason": "reason", + "fact_contradict": "contradict", + "fact_update": "update", + "fact_remove": "remove", + "fact_list": "list", +} + +MEMORY_ACTION_DESCRIPTIONS = { + "fact_add": "Add a holographic memory fact.", + "fact_search": "Search holographic memory facts by query.", + "fact_probe": "Find facts connected to one entity.", + "fact_related": "List entities related to one entity.", + "fact_reason": "Reason over facts that connect multiple entities.", + "fact_contradict": "Scan memory facts for likely contradictions.", + "fact_update": "Update an existing holographic memory fact.", + "fact_remove": "Remove a holographic memory fact.", + "fact_list": "List holographic memory facts.", +} + +MEMORY_TOOL_MAP = {"fact_store": {"tokensave_name": "tokensave_fact_store"}} +for _hermes_name, _action in MEMORY_FACT_ACTIONS.items(): + MEMORY_TOOL_MAP[_hermes_name] = { + "tokensave_name": "tokensave_fact_store", + "fixed_args": {"action": _action}, + } +MEMORY_TOOL_MAP["fact_feedback"] = {"tokensave_name": "tokensave_fact_feedback"} +MEMORY_TOOL_MAP["memory_status"] = {"tokensave_name": "tokensave_memory_status"} def _pre_llm_call(*args, **kwargs): return ( @@ -803,13 +831,27 @@ def _pre_llm_call(*args, **kwargs): def _tokensave_status(raw_args: str = ""): return tools.call_tokensave_tool("tokensave_status", {}) -def _memory_schema(tokensave_name: str, hermes_name: str) -> dict: +def _memory_schema(tokensave_name: str, hermes_name: str, action: str = None) -> dict: for schema in schemas.TOOL_SCHEMAS: if schema.get("name") == tokensave_name: + parameters = json.loads(json.dumps(schema.get("parameters", {}))) + if action is not None: + properties = parameters.get("properties") + if isinstance(properties, dict): + properties.pop("action", None) + required = parameters.get("required") + if isinstance(required, list): + required = [field for field in required if field != "action"] + if required: + parameters["required"] = required + else: + parameters.pop("required", None) return { "name": hermes_name, - "description": schema.get("description", ""), - "parameters": schema.get("parameters", {}), + "description": MEMORY_ACTION_DESCRIPTIONS.get( + hermes_name, schema.get("description", "") + ), + "parameters": parameters, } return { "name": hermes_name, @@ -863,16 +905,23 @@ class TokensaveMemoryProvider(MemoryProvider): self.session_id = session_id def get_tool_schemas(self): - return [ - _memory_schema("tokensave_fact_store", "fact_store"), - _memory_schema("tokensave_fact_feedback", "fact_feedback"), - ] + memory_schemas = [_memory_schema("tokensave_fact_store", "fact_store")] + for hermes_name, action in MEMORY_FACT_ACTIONS.items(): + memory_schemas.append(_memory_schema("tokensave_fact_store", hermes_name, action)) + memory_schemas.append(_memory_schema("tokensave_fact_feedback", "fact_feedback")) + memory_schemas.append(_memory_schema("tokensave_memory_status", "memory_status")) + return memory_schemas def handle_tool_call(self, name, arguments=None, **kwargs) -> str: tool_name, tool_args = _normalize_memory_tool_call(name, arguments) - tokensave_name = MEMORY_TOOL_MAP.get(tool_name) - if tokensave_name is None: + mapping = MEMORY_TOOL_MAP.get(tool_name) + if mapping is None: return tools.error_payload(f"unknown memory tool: {tool_name}") + tokensave_name = mapping["tokensave_name"] + fixed_args = mapping.get("fixed_args") + if fixed_args: + tool_args = dict(tool_args) + tool_args.update(fixed_args) return tools.call_tokensave_tool(tokensave_name, tool_args, **kwargs) def register(ctx): diff --git a/tests/agent_test.rs b/tests/agent_test.rs index f6d5f02366..b57e2516d3 100644 --- a/tests/agent_test.rs +++ b/tests/agent_test.rs @@ -691,11 +691,26 @@ assert provider.session_id == "session-only" schemas = provider.get_tool_schemas() schema_names = [schema.get("name") for schema in schemas] -assert schema_names == ["fact_store", "fact_feedback"] +assert schema_names == [ + "fact_store", + "fact_add", + "fact_search", + "fact_probe", + "fact_related", + "fact_reason", + "fact_contradict", + "fact_update", + "fact_remove", + "fact_list", + "fact_feedback", + "memory_status", +] assert all("function" not in schema for schema in schemas) -fact_store_schema = schemas[0] -fact_feedback_schema = schemas[1] +schema_by_name = {schema["name"]: schema for schema in schemas} +fact_store_schema = schema_by_name["fact_store"] +fact_feedback_schema = schema_by_name["fact_feedback"] assert fact_store_schema["parameters"]["required"] == ["action"] +assert "action" not in schema_by_name["fact_search"]["parameters"].get("required", []) assert fact_feedback_schema["parameters"]["required"] == ["fact_id"] calls = [] @@ -707,14 +722,24 @@ def fake_call(name, args, **kwargs): plugin.tools.call_tokensave_tool = fake_call store_result = provider.handle_tool_call("fact_store", {"action": "list"}, request_id="r1") feedback_result = provider.handle_tool_call("fact_feedback", {"fact_id": 7, "helpful": True}) +search_result = provider.handle_tool_call("fact_search", {"query": "Project Phoenix"}) +status_result = provider.handle_tool_call("memory_status", None) assert isinstance(store_result, str) assert isinstance(feedback_result, str) +assert isinstance(search_result, str) +assert isinstance(status_result, str) assert json.loads(store_result)["name"] == "tokensave_fact_store" assert json.loads(feedback_result)["name"] == "tokensave_fact_feedback" +assert json.loads(search_result)["name"] == "tokensave_fact_store" +assert json.loads(status_result)["name"] == "tokensave_memory_status" assert calls[0][0] == "tokensave_fact_store" assert calls[0][1] == {"action": "list"} assert calls[0][2]["request_id"] == "r1" assert calls[1][0] == "tokensave_fact_feedback" +assert calls[2][0] == "tokensave_fact_store" +assert calls[2][1] == {"query": "Project Phoenix", "action": "search"} +assert calls[3][0] == "tokensave_memory_status" +assert calls[3][1] == {} class LegacyCtx: def __init__(self): @@ -884,7 +909,8 @@ assert provider.is_available() is True assert provider.get_config_schema() == [] provider.initialize("doctor-session", hermes_home=str(hermes_home), platform="cli") assert provider.hermes_home == str(hermes_home) -assert [schema["name"] for schema in provider.get_tool_schemas()] == ["fact_store", "fact_feedback"] +assert "fact_search" in [schema["name"] for schema in provider.get_tool_schemas()] +assert "memory_status" in [schema["name"] for schema in provider.get_tool_schemas()] "#, ) .unwrap();