Local customizations: vault injection (Layer 3) preserved after upstream update

This commit is contained in:
AJ 2026-04-22 17:23:54 -04:00
parent 4fade39c90
commit de1a3922ed
5 changed files with 495 additions and 2 deletions

117
agent/vault_injection.py Normal file
View file

@ -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)

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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