emma-hermes/agent/shared_knowledge.py

121 lines
3.9 KiB
Python

"""Shared knowledge / FAQ snapshot for system-prompt injection.
This layer is intentionally GLOBAL across principal profiles: it lives under the
Hermes root (``~/.hermes/shared_knowledge`` by default), not under an active
profile home. That lets different principals keep isolated private memories
while still seeing the same non-private FAQ / reusable knowledge base.
"""
from __future__ import annotations
from pathlib import Path
from typing import Iterable, Optional
from hermes_constants import get_default_hermes_root
from tools.threat_patterns import scan_for_threats
_ALLOWED_SUFFIXES = {".md", ".markdown", ".txt"}
_DEFAULT_CHAR_LIMIT = 12_000
def resolve_shared_knowledge_dir(path_override: Optional[str] = None) -> Path:
"""Return the global shared-knowledge directory.
``path_override`` may be absolute or relative. Relative paths resolve under
the Hermes root (not the active profile home) so every principal still sees
the same directory.
"""
root = get_default_hermes_root()
raw = str(path_override or "").strip()
if not raw:
return root / "shared_knowledge"
path = Path(raw).expanduser()
if not path.is_absolute():
path = root / path
return path
def iter_shared_knowledge_files(directory: Path) -> Iterable[Path]:
"""Yield supported shared-knowledge files deterministically."""
if not directory.exists() or not directory.is_dir():
return []
files = [
p for p in directory.rglob("*")
if p.is_file() and p.suffix.lower() in _ALLOWED_SUFFIXES
]
return sorted(files, key=lambda p: tuple(part.lower() for part in p.relative_to(directory).parts))
def _sanitize_shared_knowledge(content: str, rel_name: str) -> str:
findings = scan_for_threats(content, scope="context")
if findings:
return (
f"[BLOCKED: {rel_name} contained potential prompt injection "
f"({', '.join(findings)}). Content not loaded.]"
)
return content
def build_shared_knowledge_block(
*,
enabled: bool,
path_override: Optional[str] = None,
char_limit: Optional[int] = None,
) -> str:
"""Build the shared-knowledge system-prompt block, or ``""`` when absent."""
if not enabled:
return ""
directory = resolve_shared_knowledge_dir(path_override)
files = list(iter_shared_knowledge_files(directory))
if not files:
return ""
budget = int(char_limit or _DEFAULT_CHAR_LIMIT)
if budget <= 0:
budget = _DEFAULT_CHAR_LIMIT
sections = [
"## SHARED KNOWLEDGE",
(
"Global, non-private FAQ/knowledge shared across principals. Use this for "
"reusable facts; keep principal-specific preferences and private details in "
"profile memory instead."
),
f"Path: {directory}",
]
remaining = budget
added = 0
omitted = 0
for path in files:
rel_name = path.relative_to(directory).as_posix()
try:
raw = path.read_text(encoding="utf-8", errors="ignore").strip()
except OSError:
omitted += 1
continue
if not raw:
continue
sanitized = _sanitize_shared_knowledge(raw, rel_name)
chunk = f"### {rel_name}\n{sanitized}"
if len(chunk) <= remaining:
sections.append(chunk)
remaining -= len(chunk)
added += 1
continue
if remaining > 200:
trimmed = sanitized[: max(0, remaining - len(rel_name) - 32)].rstrip()
if trimmed:
sections.append(f"### {rel_name}\n{trimmed}\n[TRUNCATED]")
added += 1
omitted += 1
break
if not added:
return ""
if omitted > 0 or added < len(files):
sections.append(
f"[Shared knowledge truncated: loaded {added} file(s); {len(files) - added} omitted due to size/read limits.]"
)
return "\n\n".join(part for part in sections if part).strip()