121 lines
3.9 KiB
Python
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()
|