mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
The disease: ~15 scattered raw yaml.safe_load(config.yaml) reads that
silently miss managed-scope overlay, ${ENV_VAR} expansion, profile-aware
pathing, and root-model normalization. Every new config feature needed an
N-site sweep (incident chain 9cbcc0c9c8 → 732293cf87 → b0e47a98f9 →
1928aa0443). This commit assigns every raw read to an owner and adds a
lint-guard test so the class cannot regrow.
New primitive (additive-only change to hermes_cli/config.py):
read_user_config_raw(path=None) — reads the user file EXACTLY as
written; docstring states it is ONLY legal for write-back round-trips
and raw-file diagnostics. Behavioral reads must use
load_config()/load_config_readonly().
BEHAVIOR FIXES (class-a sites migrated to a canonical loader — these
previously read values that could DIFFER from the effective config):
gateway/run.py _try_resolve_fallback_provider → _load_gateway_runtime_config
keys: fallback_providers/fallback_model (provider, model, base_url,
api_key). Drift fixed: a managed-pinned fallback chain was ignored;
an api_key of "${OPENROUTER_API_KEY}" reached the resolver unexpanded.
gateway/run.py GatewayRunner._load_provider_routing → same loader
key: provider_routing. Drift fixed: managed-pinned routing prefs and
${VAR} templates were ignored.
gateway/run.py GatewayRunner._load_fallback_model → same loader
keys: fallback chain. Same drift as above.
gateway/run.py GatewayRunner._refresh_fallback_model
keeps the raw primitive (its last-known-good-on-parse-failure contract
forbids the fail-open loader, which returns {} on a torn write) but now
applies managed overlay + env expansion inline. Drift fixed: chain
edits under managed scope / env templates were previously frozen out.
tui_gateway/server.py _load_cfg (72 behavioral call sites)
now = raw read + managed overlay (pre-existing) + NEW ${VAR} expansion,
split from a new _load_cfg_raw() write-back primitive. Drift fixed:
e.g. custom_prompt: "hello ${VAR}", agent.system_prompt, model,
api_key/base_url templates reached sessions unexpanded. DEFAULT_CONFIG
is deliberately NOT merged (callers treat missing keys as unset;
`_load_cfg() == {}` sentinels and _save_cfg round-trips depend on it).
tui_gateway/server.py _profile_configured_cwd
keys: terminal.cwd of a NON-launch profile. Drift fixed: managed
overlay + ${VAR} expansion now apply (load_config() would resolve the
wrong profile's home, so the raw primitive + inline pipeline is used).
plugins/platforms/telegram/adapter.py _reload_dm_topics_from_config
→ load_config_readonly(). keys: platforms.telegram.extra.dm_topics.
Drift fixed: managed overlay + profile-aware pathing + expansion.
plugins/memory/holographic _load_plugin_config → load_config_readonly().
keys: plugins.hermes-memory-store.*. Same drift class.
WRITE-BACK ROUND-TRIPS (class-b: stay raw BY DESIGN via read_user_config_raw;
merging defaults/overlay would pollute the saved user file):
gateway/slash_commands.py: model persist x2, _save_gateway_config_key,
memory/skills write_approval toggles
gateway/platforms/yuanbao.py auto-sethome
tui_gateway/server.py _write_config_key + all cfg→_save_cfg blocks
(reasoning show/hide/full/clamp, details_mode[.section], prompt)
→ new _load_cfg_raw()
plugins/memory/holographic save_config
RAW-FILE DIAGNOSTICS + presence-sensitive bridges (class-c: stay raw,
now via the shared primitive with an explanatory comment):
hermes_cli/doctor.py x5 (model validation, stale-root-keys, .env drift,
deprecation sweep, memory-provider probe — the latter two keep their
inline managed overlay where they had one)
gateway/run.py _bridge_max_turns_from_config and the module-level
TERMINAL_*/HERMES_* env bridge (bridging merged defaults would export
all of DEFAULT_CONFIG into the environment; both keep their inline
overlay + expansion)
hermes_cli/send_cmd.py env bridge (same presence-sensitivity)
hermes_cli/gateway.py multiplex-conflict probe (reads the DEFAULT root's
config, not the active profile's — load_config is the wrong owner)
hermes_cli/profiles.py / hermes_cli/web_server.py / tools/wake_word.py
multi-profile reads (load_config targets only the ACTIVE profile home)
cron/jobs.py _resolve_default_model_snapshot and cron/scheduler.py
run_job config read keep their existing inline overlay+expansion but
now share the primitive (their fail-open + last-value semantics and
the deliberate no-defaults merge are preserved exactly).
Failure-semantics audit: every migrated site preserves its exact previous
behavior on missing file ({} / early return) and parse failure (raise into
the caller's existing except, warn, last-known-good, or fail-open) —
read_user_config_raw intentionally mirrors bare open()+safe_load semantics
(raises on parse errors, {} only on FileNotFoundError/non-dict root).
Guard: tests/hermes_cli/test_config_read_guard.py scans the tree for
yaml.safe_load within 6 lines of a 'config.yaml' reference outside an
explicit ALLOWLIST (hermes_cli/config.py, gateway/config.py, gateway/run.py
fallback path, hermes_cli/managed_scope.py which reads the MANAGED file,
gateway/readiness.py parse-health probe) and fails on new offenders.
E2E: tests/hermes_cli/test_config_loader_e2e.py runs a subprocess with a
temp HERMES_HOME (config.yaml containing ${E2E_PROMPT_SUFFIX}) plus a
HERMES_MANAGED_DIR overlay pinning agent.reasoning_effort, asserting
tui _load_cfg resolves "hello world"/"high" while _load_cfg_raw +
_save_cfg round-trip the template and user value verbatim with no
managed/default leakage.
462 lines
20 KiB
Python
462 lines
20 KiB
Python
"""hermes-memory-store — holographic memory plugin using MemoryProvider interface.
|
|
|
|
Registers as a MemoryProvider plugin, giving the agent structured fact storage
|
|
with entity resolution, trust scoring, and HRR-based compositional retrieval.
|
|
|
|
Original plugin by dusterbloom (PR #2351), adapted to the MemoryProvider ABC.
|
|
|
|
Config in $HERMES_HOME/config.yaml (profile-scoped):
|
|
plugins:
|
|
hermes-memory-store:
|
|
db_path: $HERMES_HOME/memory_store.db # omit to use the default
|
|
auto_extract: false
|
|
default_trust: 0.5
|
|
min_trust_threshold: 0.3
|
|
temporal_decay_half_life: 0
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
from typing import Any, Dict, List
|
|
|
|
from agent.memory_provider import MemoryProvider
|
|
from tools.registry import tool_error
|
|
from utils import is_truthy_value
|
|
from .store import MemoryStore
|
|
from .retrieval import FactRetriever
|
|
from hermes_cli.config import cfg_get
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tool schemas (unchanged from original PR)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
FACT_STORE_SCHEMA = {
|
|
"name": "fact_store",
|
|
"description": (
|
|
"Deep structured memory with algebraic reasoning. "
|
|
"Use alongside the memory tool — memory for always-on context, "
|
|
"fact_store for deep recall and compositional queries.\n\n"
|
|
"ACTIONS (simple → powerful):\n"
|
|
"• add — Store a fact the user would expect you to remember.\n"
|
|
"• search — Keyword lookup ('editor config', 'deploy process').\n"
|
|
"• probe — Entity recall: ALL facts about a person/thing.\n"
|
|
"• related — What connects to an entity? Structural adjacency.\n"
|
|
"• reason — Compositional: facts connected to MULTIPLE entities simultaneously.\n"
|
|
"• contradict — Memory hygiene: find facts making conflicting claims.\n"
|
|
"• update/remove/list — CRUD operations.\n\n"
|
|
"IMPORTANT: Before answering questions about the user, ALWAYS probe or reason first."
|
|
),
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"action": {
|
|
"type": "string",
|
|
"enum": ["add", "search", "probe", "related", "reason", "contradict", "update", "remove", "list"],
|
|
},
|
|
"content": {"type": "string", "description": "Fact content (required for 'add')."},
|
|
"query": {"type": "string", "description": "Search query (required for 'search')."},
|
|
"entity": {"type": "string", "description": "Entity name for 'probe'/'related'."},
|
|
"entities": {"type": "array", "items": {"type": "string"}, "description": "Entity names for 'reason'."},
|
|
"fact_id": {"type": "integer", "description": "Fact ID for 'update'/'remove'."},
|
|
"category": {"type": "string", "enum": ["user_pref", "project", "tool", "general"]},
|
|
"tags": {"type": "string", "description": "Comma-separated tags."},
|
|
"trust_delta": {"type": "number", "description": "Trust adjustment for 'update'."},
|
|
"min_trust": {"type": "number", "description": "Minimum trust filter (default: 0.3)."},
|
|
"limit": {"type": "integer", "description": "Max results (default: 10)."},
|
|
},
|
|
"required": ["action"],
|
|
},
|
|
}
|
|
|
|
FACT_FEEDBACK_SCHEMA = {
|
|
"name": "fact_feedback",
|
|
"description": (
|
|
"Rate a fact after using it. Mark 'helpful' if accurate, 'unhelpful' if outdated. "
|
|
"This trains the memory — good facts rise, bad facts sink."
|
|
),
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"action": {"type": "string", "enum": ["helpful", "unhelpful"]},
|
|
"fact_id": {"type": "integer", "description": "The fact ID to rate."},
|
|
},
|
|
"required": ["action", "fact_id"],
|
|
},
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Config
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _load_plugin_config() -> dict:
|
|
try:
|
|
# Canonical loader: behavioral read now honors the managed-scope
|
|
# overlay + ${VAR} expansion (e.g. an api key template) too.
|
|
from hermes_cli.config import load_config_readonly
|
|
all_config = load_config_readonly()
|
|
return cfg_get(all_config, "plugins", "hermes-memory-store", default={}) or {}
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# MemoryProvider implementation
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class HolographicMemoryProvider(MemoryProvider):
|
|
"""Holographic memory with structured facts, entity resolution, and HRR retrieval."""
|
|
|
|
def __init__(self, config: dict | None = None):
|
|
self._config = config or _load_plugin_config()
|
|
self._store = None
|
|
self._retriever = None
|
|
self._min_trust = float(self._config.get("min_trust_threshold", 0.3))
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
return "holographic"
|
|
|
|
def is_available(self) -> bool:
|
|
return True # SQLite is always available, numpy is optional
|
|
|
|
def save_config(self, values, hermes_home):
|
|
"""Write config to config.yaml under plugins.hermes-memory-store."""
|
|
from pathlib import Path
|
|
config_path = Path(hermes_home) / "config.yaml"
|
|
try:
|
|
import yaml
|
|
# Write-back round-trip: raw read is correct (merged defaults
|
|
# must not be persisted back into the user's file).
|
|
from hermes_cli.config import read_user_config_raw
|
|
existing = read_user_config_raw(config_path)
|
|
existing.setdefault("plugins", {})
|
|
existing["plugins"]["hermes-memory-store"] = values
|
|
with open(config_path, "w", encoding="utf-8") as f:
|
|
yaml.dump(existing, f, default_flow_style=False)
|
|
except Exception:
|
|
pass
|
|
|
|
def get_config_schema(self):
|
|
from hermes_constants import display_hermes_home
|
|
_default_db = f"{display_hermes_home()}/memory_store.db"
|
|
return [
|
|
{"key": "db_path", "description": "SQLite database path", "default": _default_db},
|
|
{"key": "auto_extract", "description": "Auto-extract facts at session end", "default": "false", "choices": ["true", "false"]},
|
|
{"key": "default_trust", "description": "Default trust score for new facts", "default": "0.5"},
|
|
{"key": "hrr_dim", "description": "HRR vector dimensions", "default": "1024"},
|
|
]
|
|
|
|
def initialize(self, session_id: str, **kwargs) -> None:
|
|
from hermes_constants import get_hermes_home
|
|
_hermes_home = str(get_hermes_home())
|
|
_default_db = _hermes_home + "/memory_store.db"
|
|
db_path = self._config.get("db_path", _default_db)
|
|
# Expand $HERMES_HOME in user-supplied paths so config values like
|
|
# "$HERMES_HOME/memory_store.db" or "~/.hermes/memory_store.db" both
|
|
# resolve to the active profile's directory.
|
|
if isinstance(db_path, str):
|
|
db_path = db_path.replace("$HERMES_HOME", _hermes_home)
|
|
db_path = db_path.replace("${HERMES_HOME}", _hermes_home)
|
|
default_trust = float(self._config.get("default_trust", 0.5))
|
|
hrr_dim = int(self._config.get("hrr_dim", 1024))
|
|
hrr_weight = float(self._config.get("hrr_weight", 0.3))
|
|
temporal_decay = int(self._config.get("temporal_decay_half_life", 0))
|
|
|
|
self._store = MemoryStore(db_path=db_path, default_trust=default_trust, hrr_dim=hrr_dim)
|
|
self._retriever = FactRetriever(
|
|
store=self._store,
|
|
temporal_decay_half_life=temporal_decay,
|
|
hrr_weight=hrr_weight,
|
|
hrr_dim=hrr_dim,
|
|
)
|
|
self._session_id = session_id
|
|
|
|
def system_prompt_block(self) -> str:
|
|
if not self._store:
|
|
return ""
|
|
try:
|
|
total = self._store._conn.execute(
|
|
"SELECT COUNT(*) FROM facts"
|
|
).fetchone()[0]
|
|
except Exception:
|
|
total = 0
|
|
if total == 0:
|
|
return (
|
|
"# Holographic Memory\n"
|
|
"Active. Empty fact store — proactively add facts the user would expect you to remember.\n"
|
|
"Use fact_store(action='add') to store durable structured facts about people, projects, preferences, decisions.\n"
|
|
"Use fact_feedback to rate facts after using them (trains trust scores)."
|
|
)
|
|
return (
|
|
f"# Holographic Memory\n"
|
|
f"Active. {total} facts stored with entity resolution and trust scoring.\n"
|
|
f"Use fact_store to search, probe entities, reason across entities, or add facts.\n"
|
|
f"Use fact_feedback to rate facts after using them (trains trust scores)."
|
|
)
|
|
|
|
def prefetch(self, query: str, *, session_id: str = "") -> str:
|
|
if not self._retriever or not query:
|
|
return ""
|
|
try:
|
|
results = self._retriever.search(query, min_trust=self._min_trust, limit=5)
|
|
if not results:
|
|
return ""
|
|
lines = []
|
|
for r in results:
|
|
trust = r.get("trust_score", r.get("trust", 0))
|
|
lines.append(f"- [{trust:.1f}] {r.get('content', '')}")
|
|
return "## Holographic Memory\n" + "\n".join(lines)
|
|
except Exception as e:
|
|
logger.debug("Holographic prefetch failed: %s", e)
|
|
return ""
|
|
|
|
def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
|
|
# Holographic memory stores explicit facts via tools, not auto-sync.
|
|
# The on_session_end hook handles auto-extraction if configured.
|
|
pass
|
|
|
|
def get_tool_schemas(self) -> List[Dict[str, Any]]:
|
|
return [FACT_STORE_SCHEMA, FACT_FEEDBACK_SCHEMA]
|
|
|
|
def handle_tool_call(self, tool_name: str, args: Dict[str, Any], **kwargs) -> str:
|
|
if tool_name == "fact_store":
|
|
return self._handle_fact_store(args)
|
|
elif tool_name == "fact_feedback":
|
|
return self._handle_fact_feedback(args)
|
|
return tool_error(f"Unknown tool: {tool_name}")
|
|
|
|
def on_session_end(self, messages: List[Dict[str, Any]]) -> None:
|
|
# is_truthy_value: the config schema declares auto_extract as a string
|
|
# enum ("false"/"true"), and a plain truthiness check treats the string
|
|
# "false" as enabled (#57682).
|
|
if not is_truthy_value(self._config.get("auto_extract", False)):
|
|
return
|
|
if not self._store or not messages:
|
|
return
|
|
self._auto_extract_facts(messages)
|
|
|
|
def on_memory_write(self, action: str, target: str, content: str) -> None:
|
|
"""Mirror built-in memory writes as facts."""
|
|
if action == "add" and self._store and content:
|
|
try:
|
|
category = "user_pref" if target == "user" else "general"
|
|
self._store.add_fact(content, category=category)
|
|
except Exception as e:
|
|
logger.debug("Holographic memory_write mirror failed: %s", e)
|
|
|
|
def shutdown(self) -> None:
|
|
# Release the shared SQLite connection deterministically on the
|
|
# caller's thread. Dropping the reference alone leaves fd finalization
|
|
# to GC, which keeps the connection (and its write lock) alive on a
|
|
# long-running gateway and prolongs the "database is locked" contention
|
|
# this store's shared-connection refcounting is meant to eliminate.
|
|
# close() is idempotent and refcount-guarded, so siblings stay safe.
|
|
if self._store is not None:
|
|
try:
|
|
self._store.close()
|
|
except Exception as e:
|
|
logger.debug("Holographic shutdown close() failed: %s", e)
|
|
self._store = None
|
|
self._retriever = None
|
|
|
|
# -- Tool handlers -------------------------------------------------------
|
|
|
|
def _handle_fact_store(self, args: dict) -> str:
|
|
try:
|
|
action = args["action"]
|
|
store = self._store
|
|
retriever = self._retriever
|
|
|
|
if action == "add":
|
|
fact_id = store.add_fact(
|
|
args["content"],
|
|
category=args.get("category", "general"),
|
|
tags=args.get("tags", ""),
|
|
)
|
|
return json.dumps({"fact_id": fact_id, "status": "added"})
|
|
|
|
elif action == "search":
|
|
results = retriever.search(
|
|
args["query"],
|
|
category=args.get("category"),
|
|
min_trust=float(args.get("min_trust", self._min_trust)),
|
|
limit=int(args.get("limit", 10)),
|
|
)
|
|
return json.dumps({"results": results, "count": len(results)})
|
|
|
|
elif action == "probe":
|
|
results = retriever.probe(
|
|
args["entity"],
|
|
category=args.get("category"),
|
|
limit=int(args.get("limit", 10)),
|
|
)
|
|
return json.dumps({"results": results, "count": len(results)})
|
|
|
|
elif action == "related":
|
|
results = retriever.related(
|
|
args["entity"],
|
|
category=args.get("category"),
|
|
limit=int(args.get("limit", 10)),
|
|
)
|
|
return json.dumps({"results": results, "count": len(results)})
|
|
|
|
elif action == "reason":
|
|
entities = args.get("entities", [])
|
|
if not entities:
|
|
return tool_error("reason requires 'entities' list")
|
|
results = retriever.reason(
|
|
entities,
|
|
category=args.get("category"),
|
|
limit=int(args.get("limit", 10)),
|
|
)
|
|
return json.dumps({"results": results, "count": len(results)})
|
|
|
|
elif action == "contradict":
|
|
results = retriever.contradict(
|
|
category=args.get("category"),
|
|
limit=int(args.get("limit", 10)),
|
|
)
|
|
return json.dumps({"results": results, "count": len(results)})
|
|
|
|
elif action == "update":
|
|
updated = store.update_fact(
|
|
int(args["fact_id"]),
|
|
content=args.get("content"),
|
|
trust_delta=float(args["trust_delta"]) if "trust_delta" in args else None,
|
|
tags=args.get("tags"),
|
|
category=args.get("category"),
|
|
)
|
|
return json.dumps({"updated": updated})
|
|
|
|
elif action == "remove":
|
|
removed = store.remove_fact(int(args["fact_id"]))
|
|
return json.dumps({"removed": removed})
|
|
|
|
elif action == "list":
|
|
facts = store.list_facts(
|
|
category=args.get("category"),
|
|
min_trust=float(args.get("min_trust", 0.0)),
|
|
limit=int(args.get("limit", 10)),
|
|
)
|
|
return json.dumps({"facts": facts, "count": len(facts)})
|
|
|
|
else:
|
|
return tool_error(f"Unknown action: {action}")
|
|
|
|
except KeyError as exc:
|
|
return tool_error(f"Missing required argument: {exc}")
|
|
except Exception as exc:
|
|
return tool_error(str(exc))
|
|
|
|
def _handle_fact_feedback(self, args: dict) -> str:
|
|
try:
|
|
fact_id = int(args["fact_id"])
|
|
helpful = args["action"] == "helpful"
|
|
result = self._store.record_feedback(fact_id, helpful=helpful)
|
|
return json.dumps(result)
|
|
except KeyError as exc:
|
|
return tool_error(f"Missing required argument: {exc}")
|
|
except Exception as exc:
|
|
return tool_error(str(exc))
|
|
|
|
# -- Auto-extraction (on_session_end) ------------------------------------
|
|
|
|
def _auto_extract_facts(self, messages: list) -> None:
|
|
# Local import (pattern used in initialize()): the compressor module is
|
|
# heavier than this plugin and is only needed when auto_extract is on.
|
|
from agent.context_compressor import (
|
|
_MERGED_PRIOR_CONTEXT_HEADER,
|
|
_MERGED_SUMMARY_DELIMITER,
|
|
is_compaction_summary_message,
|
|
)
|
|
|
|
def _pre_delimiter_user_segment(msg: dict):
|
|
"""Return the genuine user text preceding a merged-into-tail
|
|
compaction summary, or None when the whole message is a summary.
|
|
|
|
Merge-into-tail messages (agent/context_compressor.py ~3163-3190)
|
|
wrap real prior tail content BEFORE ``_MERGED_SUMMARY_DELIMITER``,
|
|
prefixed with ``_MERGED_PRIOR_CONTEXT_HEADER``, then append the
|
|
generated handoff summary AFTER the delimiter. Dropping the whole
|
|
row (as ``is_compaction_summary_message`` alone would suggest)
|
|
discards that genuine pre-delimiter content too (#57690 review).
|
|
Only the summary suffix must be excluded from harvesting.
|
|
"""
|
|
content = msg.get("content", "")
|
|
if not isinstance(content, str) or _MERGED_SUMMARY_DELIMITER not in content:
|
|
return None
|
|
pre = content.split(_MERGED_SUMMARY_DELIMITER, 1)[0]
|
|
if pre.startswith(_MERGED_PRIOR_CONTEXT_HEADER):
|
|
pre = pre[len(_MERGED_PRIOR_CONTEXT_HEADER):]
|
|
pre = pre.strip()
|
|
return pre or None
|
|
|
|
_PREF_PATTERNS = [
|
|
re.compile(r'\bI\s+(?:prefer|like|love|use|want|need)\s+(.+)', re.IGNORECASE),
|
|
re.compile(r'\bmy\s+(?:favorite|preferred|default)\s+\w+\s+is\s+(.+)', re.IGNORECASE),
|
|
re.compile(r'\bI\s+(?:always|never|usually)\s+(.+)', re.IGNORECASE),
|
|
]
|
|
_DECISION_PATTERNS = [
|
|
re.compile(r'\bwe\s+(?:decided|agreed|chose)\s+(?:to\s+)?(.+)', re.IGNORECASE),
|
|
re.compile(r'\bthe\s+project\s+(?:uses|needs|requires)\s+(.+)', re.IGNORECASE),
|
|
]
|
|
|
|
extracted = 0
|
|
for msg in messages:
|
|
if msg.get("role") != "user":
|
|
continue
|
|
# Compaction handoff summaries can be inserted as role="user"
|
|
# messages; their prose reliably matches the decision patterns, so
|
|
# without this guard the compactor's own output is stored as a
|
|
# durable "fact" on every rollover (#57682). A merge-into-tail
|
|
# summary also carries genuine pre-delimiter user content in the
|
|
# SAME row; harvest that segment instead of dropping the whole
|
|
# message (#57690 review).
|
|
pre_delimiter_segment = _pre_delimiter_user_segment(msg)
|
|
if pre_delimiter_segment is not None:
|
|
content = pre_delimiter_segment
|
|
elif is_compaction_summary_message(msg):
|
|
continue
|
|
else:
|
|
content = msg.get("content", "")
|
|
if not isinstance(content, str) or len(content) < 10:
|
|
continue
|
|
|
|
for pattern in _PREF_PATTERNS:
|
|
if pattern.search(content):
|
|
try:
|
|
self._store.add_fact(content[:400], category="user_pref")
|
|
extracted += 1
|
|
except Exception:
|
|
pass
|
|
break
|
|
|
|
for pattern in _DECISION_PATTERNS:
|
|
if pattern.search(content):
|
|
try:
|
|
self._store.add_fact(content[:400], category="project")
|
|
extracted += 1
|
|
except Exception:
|
|
pass
|
|
break
|
|
|
|
if extracted:
|
|
logger.info("Auto-extracted %d facts from conversation", extracted)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Plugin entry point
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def register(ctx) -> None:
|
|
"""Register the holographic memory provider with the plugin system."""
|
|
config = _load_plugin_config()
|
|
provider = HolographicMemoryProvider(config=config)
|
|
ctx.register_memory_provider(provider)
|