diff --git a/agent/vault_injection.py b/agent/vault_injection.py new file mode 100644 index 0000000000..c46f8219d4 --- /dev/null +++ b/agent/vault_injection.py @@ -0,0 +1,117 @@ +"""Vault injection — auto-load Obsidian vault files into the system prompt. + +Reads working-context.md and user-profile.md from a configured vault path +at session start and injects them into the system prompt alongside Layer 1 +memory (MEMORY.md / USER.md). This is a structural fix for vault neglect: +the agent no longer needs to remember to read these files — they're injected +automatically, the same way Layer 1 memory is. + +The vault is Layer 3 in the memory architecture. Files injected here are +read-only in the system prompt (frozen at session start). Mid-session +writes to vault files require the read_file/write_file tools or the +memory-vault skill. + +Config (in config.yaml under 'vault'): + enabled: true # enable vault injection + path: /path/to/vault # absolute path to the Obsidian vault root + +Files read (relative to vault path): + Agent-Hermes/working-context.md — what the agent is actively doing + Agent-Shared/user-profile.md — who the user is (durable facts) + +If either file doesn't exist or is empty, it's silently skipped. +If the vault path doesn't exist or isn't configured, vault injection is +silently disabled. +""" + +import logging +import os +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + +# Character limits for vault injection blocks (to prevent prompt bloat) +WORKING_CONTEXT_CHAR_LIMIT = 4000 +USER_PROFILE_CHAR_LIMIT = 4000 + +SEPARATOR = "\u2550" * 46 # ═ same as memory_tool uses + + +def _read_vault_file(path: Path, char_limit: int) -> Optional[str]: + """Read a vault file and return its content, or None if missing/empty. + + Truncates with a notice if the file exceeds char_limit. + """ + if not path.exists(): + return None + try: + content = path.read_text(encoding="utf-8").strip() + except (OSError, IOError) as e: + logger.debug("Could not read vault file %s: %s", path, e) + return None + + if not content: + return None + + # Strip YAML frontmatter (same as prompt_builder does for context files) + content = _strip_yaml_frontmatter(content) + + if not content: + return None + + if len(content) > char_limit: + truncated = content[:char_limit] + # Find last newline to avoid cutting mid-line + last_nl = truncated.rfind("\n") + if last_nl > char_limit // 2: + truncated = truncated[:last_nl] + content = truncated + f"\n[... truncated at {char_limit} chars ...]" + + return content + + +def _strip_yaml_frontmatter(content: str) -> str: + """Remove optional YAML frontmatter (--- delimited) from content.""" + if content.startswith("---"): + end = content.find("\n---", 3) + if end != -1: + body = content[end + 4:].lstrip("\n") + return body if body else content + return content + + +def build_vault_system_prompt(vault_path: str) -> str: + """Build the vault injection block for the system prompt. + + Reads working-context.md and user-profile.md from the vault and formats + them with headers matching the style of Layer 1 memory blocks. + + Returns an empty string if vault is disabled, path is missing, or + all files are empty. + """ + if not vault_path: + return "" + + vault_root = Path(vault_path) + if not vault_root.is_dir(): + logger.debug("Vault path does not exist or is not a directory: %s", vault_path) + return "" + + parts = [] + + # Read working-context.md (agent's current state) + wc_path = vault_root / "Agent-Hermes" / "working-context.md" + wc_content = _read_vault_file(wc_path, WORKING_CONTEXT_CHAR_LIMIT) + if wc_content: + header = "VAULT: WORKING CONTEXT (what you're doing right now)" + parts.append(f"{SEPARATOR}\n{header}\n{SEPARATOR}\n{wc_content}") + + # Read user-profile.md (shared user profile) + up_path = vault_root / "Agent-Shared" / "user-profile.md" + up_content = _read_vault_file(up_path, USER_PROFILE_CHAR_LIMIT) + if up_content: + header = "VAULT: USER PROFILE (durable facts from Obsidian vault)" + parts.append(f"{SEPARATOR}\n{header}\n{SEPARATOR}\n{up_content}") + + return "\n\n".join(parts) \ No newline at end of file diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 7678287a0e..71727913d7 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -754,6 +754,16 @@ DEFAULT_CONFIG = { "provider": "", }, + # Obsidian vault auto-injection — Layer 3 persistent memory. + # When enabled, working-context.md and user-profile.md are read from + # the vault path at session start and injected into the system prompt + # alongside Layer 1 memory. This is a structural fix for vault neglect: + # the agent no longer needs to remember to read these files manually. + "vault": { + "enabled": False, # set true to activate + "path": "", # absolute path to the Obsidian vault root + }, + # Subagent delegation — override the provider:model used by delegate_task # so child agents can run on a different (cheaper/faster) provider and model. # Uses the same runtime provider resolution as CLI/gateway startup, so all diff --git a/run_agent.py b/run_agent.py index 6770f568c0..c1b2d60658 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1566,7 +1566,8 @@ class AIAgent: try: from hermes_cli.config import load_config as _load_agent_config _agent_cfg = _load_agent_config() - except Exception: + except Exception as e: + logger.warning("Agent init: load_config() failed: %s: %s — using empty config", type(e).__name__, e) _agent_cfg = {} # Cache only the derived auxiliary compression context override that is # needed later by the startup feasibility check. Avoid exposing a @@ -1597,8 +1598,20 @@ class AIAgent: self._memory_store.load_from_disk() except Exception: pass # Memory is optional -- don't break agent init - + # Obsidian vault auto-injection (Layer 3) — structural fix for + # vault neglect. Reads working-context.md and user-profile.md + # from the configured vault path and injects them into the system + # prompt at session start, just like Layer 1 memory. + self._vault_enabled = False + self._vault_path = "" + try: + vault_config = _agent_cfg.get("vault", {}) + self._vault_enabled = vault_config.get("enabled", False) + self._vault_path = vault_config.get("path", "") + logging.getLogger("agent.vault").info("Vault config: enabled=%s path=%s", self._vault_enabled, self._vault_path) + except Exception as e: + logging.getLogger("agent.vault").warning("Vault config read failed: %s: %s", type(e).__name__, e) # Memory provider plugin (external — one at a time, alongside built-in) # Reads memory.provider from config to select which plugin to activate. @@ -4448,6 +4461,24 @@ class AIAgent: if user_block: prompt_parts.append(user_block) + # Vault auto-injection (Layer 3) — reads working-context.md and + # user-profile.md from the Obsidian vault and injects them into + # the system prompt. Structural fix for vault neglect. + _vault_log = logging.getLogger("agent.vault") + if self._vault_enabled and self._vault_path: + try: + from agent.vault_injection import build_vault_system_prompt + _vault_block = build_vault_system_prompt(self._vault_path) + if _vault_block: + prompt_parts.append(_vault_block) + _vault_log.info("Injection succeeded: %d chars from %s", len(_vault_block), self._vault_path) + else: + _vault_log.warning("Injection returned empty for path %s", self._vault_path) + except Exception as e: + _vault_log.warning("Injection failed: %s: %s", type(e).__name__, e) + else: + _vault_log.info("Injection skipped: enabled=%s path=%s", self._vault_enabled, self._vault_path) + # External memory provider system prompt block (additive to built-in) if self._memory_manager: try: diff --git a/tests/agent/test_vault_injection.py b/tests/agent/test_vault_injection.py new file mode 100644 index 0000000000..0eae544e41 --- /dev/null +++ b/tests/agent/test_vault_injection.py @@ -0,0 +1,174 @@ +"""Tests for agent/vault_injection.py — Obsidian vault auto-injection into system prompt.""" + +import pytest +import os +from pathlib import Path + +from agent.vault_injection import ( + build_vault_system_prompt, + _read_vault_file, + _strip_yaml_frontmatter, + WORKING_CONTEXT_CHAR_LIMIT, + USER_PROFILE_CHAR_LIMIT, +) + + +# --------------------------------------------------------------------------- +# _strip_yaml_frontmatter +# --------------------------------------------------------------------------- + +class TestStripYamlFrontmatter: + def test_strips_simple_frontmatter(self): + content = "---\ndate: 2026-04-22\n---\nHello world" + assert _strip_yaml_frontmatter(content) == "Hello world" + + def test_no_frontmatter(self): + content = "Just some text" + assert _strip_yaml_frontmatter(content) == "Just some text" + + def test_frontmatter_with_blank_lines(self): + content = "---\ndate: 2026-04-22\nprojects: [X]\n---\n\nActual content here" + result = _strip_yaml_frontmatter(content) + assert result == "Actual content here" + + def test_unclosed_frontmatter_returns_original(self): + content = "---\ndate: 2026-04-22\nNo closing dashes" + assert _strip_yaml_frontmatter(content) == content + + +# --------------------------------------------------------------------------- +# _read_vault_file +# --------------------------------------------------------------------------- + +class TestReadVaultFile: + def test_reads_existing_file(self, tmp_path): + f = tmp_path / "test.md" + f.write_text("some content", encoding="utf-8") + result = _read_vault_file(f, 4000) + assert result == "some content" + + def test_returns_none_for_missing_file(self, tmp_path): + f = tmp_path / "nonexistent.md" + result = _read_vault_file(f, 4000) + assert result is None + + def test_returns_none_for_empty_file(self, tmp_path): + f = tmp_path / "empty.md" + f.write_text("", encoding="utf-8") + result = _read_vault_file(f, 4000) + assert result is None + + def test_returns_none_for_whitespace_only(self, tmp_path): + f = tmp_path / "ws.md" + f.write_text(" \n\n ", encoding="utf-8") + result = _read_vault_file(f, 4000) + assert result is None + + def test_strips_frontmatter(self, tmp_path): + f = tmp_path / "frontmatter.md" + f.write_text("---\ndate: 2026-04-22\n---\nReal content", encoding="utf-8") + result = _read_vault_file(f, 4000) + assert result == "Real content" + + def test_truncates_long_file(self, tmp_path): + f = tmp_path / "long.md" + long_content = "x" * 5000 + f.write_text(long_content, encoding="utf-8") + result = _read_vault_file(f, 100) + assert len(result) < 200 # truncation + notice + assert "truncated" in result + + def test_truncation_at_newline(self, tmp_path): + f = tmp_path / "multiline.md" + lines = ["line " + str(i) for i in range(100)] + content = "\n".join(lines) + f.write_text(content, encoding="utf-8") + # Small limit, should truncate at a newline boundary + result = _read_vault_file(f, 50) + assert "truncated" in result + # Should not cut mid-line + for line in result.split("\n"): + if line and "truncated" not in line: + assert line.startswith("line") + + +# --------------------------------------------------------------------------- +# build_vault_system_prompt +# --------------------------------------------------------------------------- + +class TestBuildVaultSystemPrompt: + def test_empty_path_returns_empty(self): + assert build_vault_system_prompt("") == "" + + def test_nonexistent_path_returns_empty(self, tmp_path): + assert build_vault_system_prompt(str(tmp_path / "nope")) == "" + + def test_empty_vault_dir_returns_empty(self, tmp_path): + assert build_vault_system_prompt(str(tmp_path)) == "" + + def test_injects_working_context(self, tmp_path): + vault = tmp_path / "vault" + agent_dir = vault / "Agent-Hermes" + agent_dir.mkdir(parents=True) + wc = agent_dir / "working-context.md" + wc.write_text("---\ndate: 2026-04-22\n---\n## Current Status\n- Status: Active", encoding="utf-8") + + result = build_vault_system_prompt(str(vault)) + assert "VAULT: WORKING CONTEXT" in result + assert "Status: Active" in result + + def test_injects_user_profile(self, tmp_path): + vault = tmp_path / "vault" + shared_dir = vault / "Agent-Shared" + shared_dir.mkdir(parents=True) + up = shared_dir / "user-profile.md" + up.write_text("# User Profile\n\nName: AJ", encoding="utf-8") + + result = build_vault_system_prompt(str(vault)) + assert "VAULT: USER PROFILE" in result + assert "Name: AJ" in result + + def test_injects_both_files(self, tmp_path): + vault = tmp_path / "vault" + agent_dir = vault / "Agent-Hermes" + shared_dir = vault / "Agent-Shared" + agent_dir.mkdir(parents=True) + shared_dir.mkdir(parents=True) + + (agent_dir / "working-context.md").write_text( + "---\ndate: 2026-04-22\n---\nWorking on X", encoding="utf-8" + ) + (shared_dir / "user-profile.md").write_text( + "# User Profile\n\nName: AJ", encoding="utf-8" + ) + + result = build_vault_system_prompt(str(vault)) + assert "VAULT: WORKING CONTEXT" in result + assert "VAULT: USER PROFILE" in result + assert "Working on X" in result + assert "Name: AJ" in result + + def test_skips_empty_working_context(self, tmp_path): + vault = tmp_path / "vault" + agent_dir = vault / "Agent-Hermes" + shared_dir = vault / "Agent-Shared" + agent_dir.mkdir(parents=True) + shared_dir.mkdir(parents=True) + + (agent_dir / "working-context.md").write_text("", encoding="utf-8") + (shared_dir / "user-profile.md").write_text("Name: AJ", encoding="utf-8") + + result = build_vault_system_prompt(str(vault)) + assert "VAULT: WORKING CONTEXT" not in result + assert "VAULT: USER PROFILE" in result + + def test_format_matches_memory_block_style(self, tmp_path): + vault = tmp_path / "vault" + agent_dir = vault / "Agent-Hermes" + agent_dir.mkdir(parents=True) + (agent_dir / "working-context.md").write_text("Active task", encoding="utf-8") + + result = build_vault_system_prompt(str(vault)) + # Should use the same separator as memory_tool (═══) + assert "\u2550" in result # ═ character + assert "VAULT: WORKING CONTEXT" in result \ No newline at end of file diff --git a/tests/run_agent/test_vault_injection.py b/tests/run_agent/test_vault_injection.py new file mode 100644 index 0000000000..76529c62ce --- /dev/null +++ b/tests/run_agent/test_vault_injection.py @@ -0,0 +1,161 @@ +"""Tests for vault auto-injection integration with _build_system_prompt. + +Verifies that vault content appears in the system prompt when vault is +configured, and is absent otherwise. +""" + +import os +import pytest +from pathlib import Path +from unittest.mock import MagicMock, patch + + +def _make_minimal_agent(**overrides): + """Create a minimal AIAgent for testing, with vault attrs settable.""" + from run_agent import AIAgent + + with ( + patch("run_agent.get_tool_definitions", return_value=[]), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + ): + a = AIAgent( + api_key="test-k...7890", + base_url="https://openrouter.ai/api/v1", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + ) + a.client = MagicMock() + + # Apply overrides after creation + for k, v in overrides.items(): + setattr(a, k, v) + + return a + + +class TestVaultSystemPromptIntegration: + """Test that _build_system_prompt injects vault content when configured.""" + + def test_vault_not_injected_when_disabled(self, tmp_path): + """Vault content should not appear when vault_enabled=False.""" + vault_dir = tmp_path / "vault" + agent_dir = vault_dir / "Agent-Hermes" + agent_dir.mkdir(parents=True) + (agent_dir / "working-context.md").write_text("Active task X", encoding="utf-8") + + agent = _make_minimal_agent( + _vault_enabled=False, + _vault_path=str(vault_dir), + ) + + prompt = agent._build_system_prompt() + assert "VAULT: WORKING CONTEXT" not in prompt + assert "Active task X" not in prompt + + def test_vault_injected_when_enabled(self, tmp_path): + """Vault content should appear in system prompt when vault_enabled=True.""" + vault_dir = tmp_path / "vault" + agent_dir = vault_dir / "Agent-Hermes" + shared_dir = vault_dir / "Agent-Shared" + agent_dir.mkdir(parents=True) + shared_dir.mkdir(parents=True) + (agent_dir / "working-context.md").write_text( + "---\ndate: 2026-04-22\n---\n## Status\nActive: vault fix", + encoding="utf-8", + ) + (shared_dir / "user-profile.md").write_text( + "# User Profile\n\nName: Test User", + encoding="utf-8", + ) + + agent = _make_minimal_agent( + _vault_enabled=True, + _vault_path=str(vault_dir), + ) + + prompt = agent._build_system_prompt() + assert "VAULT: WORKING CONTEXT" in prompt + assert "Active: vault fix" in prompt + assert "VAULT: USER PROFILE" in prompt + assert "Name: Test User" in prompt + + def test_vault_after_memory_blocks(self, tmp_path): + """Vault injection should appear after Layer 1 memory blocks.""" + # Set up memory files + mem_dir = tmp_path / "memories" + mem_dir.mkdir(parents=True) + (mem_dir / "MEMORY.md").write_text("Layer 1 memory note", encoding="utf-8") + + # Set up vault files + vault_dir = tmp_path / "vault" + agent_dir = vault_dir / "Agent-Hermes" + agent_dir.mkdir(parents=True) + (agent_dir / "working-context.md").write_text("Vault content", encoding="utf-8") + + # Create agent with memory enabled + from run_agent import AIAgent + from tools.memory_tool import MemoryStore + + with ( + patch("run_agent.get_tool_definitions", return_value=[]), + patch("run_agent.check_toolset_requirements", return_value={}), + patch("run_agent.OpenAI"), + patch( + "hermes_cli.config.load_config", + return_value={ + "memory": { + "memory_enabled": True, + "user_profile_enabled": False, + "memory_char_limit": 2200, + "user_char_limit": 1375, + }, + }, + ), + ): + monkeypatch_env = {} + # Set HERMES_HOME so MemoryStore reads from tmp_path + os.environ["HERMES_HOME"] = str(tmp_path) + try: + a = AIAgent( + api_key="test-k...7890", + base_url="https://openrouter.ai/api/v1", + quiet_mode=True, + skip_context_files=True, + skip_memory=False, + ) + a.client = MagicMock() + finally: + del os.environ["HERMES_HOME"] + + a._vault_enabled = True + a._vault_path = str(vault_dir) + + prompt = a._build_system_prompt() + mem_pos = prompt.find("MEMORY (your personal notes)") + vault_pos = prompt.find("VAULT: WORKING CONTEXT") + assert mem_pos > 0, "Layer 1 memory block not found in prompt" + assert vault_pos > 0, "Vault block not found in prompt" + assert mem_pos < vault_pos, "Vault should appear after Layer 1 memory" + + def test_missing_vault_path_graceful(self, tmp_path): + """Agent works fine even if vault path doesn't exist.""" + agent = _make_minimal_agent( + _vault_enabled=True, + _vault_path="/nonexistent/vault/path", + ) + + # Should not crash + prompt = agent._build_system_prompt() + assert "VAULT:" not in prompt + + def test_no_vault_config_graceful(self): + """Agent works fine with no vault set (defaults).""" + agent = _make_minimal_agent( + _vault_enabled=False, + _vault_path="", + ) + + prompt = agent._build_system_prompt() + assert "VAULT:" not in prompt \ No newline at end of file