Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and this project adheres to [SemVer](https://semver.org/) starting from v3.1.2.

## [Unreleased]

### Changed

- **Hermes sync role default now saves user turns only.** The default for
`memory.mnemosyne.sync_roles` changed from `["user", "assistant"]` to
`["user"]` so automatic Mnemosyne autosave avoids assistant transcript noise.
Existing deployments that want the previous assistant-turn autosave behavior
should set `memory.mnemosyne.sync_roles: ["user", "assistant"]` in
`config.yaml`.

## [3.10.1] — 2026-06-22

### Security
Expand Down
370 changes: 365 additions & 5 deletions hermes_memory_provider/__init__.py

Large diffs are not rendered by default.

297 changes: 294 additions & 3 deletions integrations/hermes/src/mnemosyne_hermes/__init__.py

Large diffs are not rendered by default.

34 changes: 0 additions & 34 deletions integrations/hermes/src/mnemosyne_hermes/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,6 @@
from typing import Optional


class PluginState(Enum):
"""Possible states of the Hermes plugin symlink."""
OK = "ok"
BROKEN_SYMLINK = "broken_symlink"
MISSING = "missing"


PLUGIN_NAME = "mnemosyne"


Expand Down Expand Up @@ -431,33 +424,6 @@ def is_installed(*, hermes_home_path: str | Path | None = None) -> bool:
return plugin_state(hermes_home_path=hermes_home_path).installed


def plugin_state(*, hermes_home_path: str | Path | None = None) -> PluginState:
"""Return the current state of the Hermes plugin installation.

Distinguishes between:
- OK: symlink exists and points to a valid plugin
- BROKEN_SYMLINK: symlink exists but target is missing (venv rebuild, etc.)
- MISSING: no symlink at all
"""
target = plugin_target_dir(hermes_home_path)

if target.is_symlink():
try:
real_target = target.resolve(strict=True)
if real_target.exists():
if is_installed(hermes_home_path=hermes_home_path):
return PluginState.OK
except (FileNotFoundError, RuntimeError):
pass
return PluginState.BROKEN_SYMLINK

if target.exists():
if is_installed(hermes_home_path=hermes_home_path):
return PluginState.OK

return PluginState.MISSING


def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="mnemosyne-hermes",
Expand Down
111 changes: 109 additions & 2 deletions integrations/hermes/src/mnemosyne_hermes/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,47 @@
},
}

MODEL_CARD_SCHEMA = {
"name": "mnemosyne_model_card",
"description": (
"Render current canonical slots as a compact deterministic model card. "
"Use this for Hindsight-style user, workflow, project, or agent mental-model "
"summaries when the facts already live in canonical storage. This does not "
"call an LLM or create a new memory; it is a view over current canonical facts."
),
"parameters": {
"type": "object",
"properties": {
"category": {"type": "string", "description": "Canonical category to render, e.g. 'model:user' or 'identity'"},
"title": {"type": "string", "description": "Optional display title", "default": ""},
"names": {
"type": "array",
"items": {"type": "string"},
"description": "Optional ordered subset of slot names to include",
"default": [],
},
},
"required": ["category"],
},
}

MODEL_REFRESH_SCHEMA = {
"name": "mnemosyne_model_refresh",
"description": (
"Inspect sleep-time LLM-inferred canonical model update outcomes. "
"Normal behavior is automated during sleep: validated candidates are "
"auto-applied or auto-rejected by policy. This tool is diagnostic only."
),
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["list"], "default": "list"},
"status": {"type": "string", "description": "pending, applied, rejected, or all", "default": "all"},
"limit": {"type": "integer", "description": "Max proposals to list", "default": 20},
},
},
}

SCRATCHPAD_WRITE_SCHEMA = {
"name": "mnemosyne_scratchpad_write",
"description": "Write a temporary note to the Mnemosyne scratchpad.",
Expand Down Expand Up @@ -504,6 +545,70 @@
},
}

# These schemas intentionally expose operational surfaces rather than new
# memory-writing behavior: diagnostics lets operators observe recall health,
# while task_progress stores a curated current-state pointer in canonical facts.
# Keeping both as explicit tools prevents silent prompt injection or background
# transcript autosave from becoming the source of truth for task continuity.
RECALL_DIAGNOSTICS_SCHEMA = {
"name": "mnemosyne_recall_diagnostics",
"description": (
"Return recall path diagnostics: per-tier hit counts, fallback rates, "
"and total call counts. Use to monitor recall health — high fallback "
"rates indicate weak-signal recall paths dominating. Pass reset=true "
"to clear counters and start a fresh measurement window."
),
"parameters": {
"type": "object",
"properties": {
"reset": {
"type": "boolean",
"description": "If true, reset all counters after snapshotting. Default false.",
"default": False,
},
},
},
}

TASK_PROGRESS_SCHEMA = {
"name": "mnemosyne_task_progress",
"description": (
"Track and recall cross-session task progression. Uses canonical "
"memory slots with category 'task:progress' to store where you left "
"off on a specific task. Set a task's current state with "
"action='set', query the latest state with action='get', list all "
"tracked tasks with action='list'. This solves the 'where did we "
"leave off?' problem across sessions — session_search finds old "
"transcripts, but this gives you the curated current state."
),
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"description": "set | get | list | clear",
"default": "get",
},
"task": {
"type": "string",
"description": "Task identifier (e.g. 'pdas-q08', 'mnemo-impl', 'qudomec-deploy'). Required for set/get/clear.",
"default": "",
},
"state": {
"type": "string",
"description": "Current task state description. Required for set.",
"default": "",
},
"metadata": {
"type": "object",
"description": "Optional metadata (status, next_step, blockers, etc.).",
"default": {},
},
},
"required": ["action"],
},
}

GRAPH_QUERY_SCHEMA = {
"name": "mnemosyne_graph_query",
"description": "Traverse the memory graph to find memories related to a seed memory. Uses multi-hop BFS through graph_edges with optional edge_type and min_weight filtering.",
Expand Down Expand Up @@ -610,9 +715,11 @@
SHARED_FORGET_SCHEMA, SHARED_STATS_SCHEMA, SLEEP_SCHEMA, STATS_SCHEMA,
INVALIDATE_SCHEMA, VALIDATE_SCHEMA, GET_SCHEMA, TRIPLE_ADD_SCHEMA, TRIPLE_QUERY_SCHEMA,
TRIPLE_END_SCHEMA,
REMEMBER_CANONICAL_SCHEMA, RECALL_CANONICAL_SCHEMA,
SCRATCHPAD_WRITE_SCHEMA, SCRATCHPAD_READ_SCHEMA, SCRATCHPAD_CLEAR_SCHEMA,
REMEMBER_CANONICAL_SCHEMA, RECALL_CANONICAL_SCHEMA, MODEL_CARD_SCHEMA,
MODEL_REFRESH_SCHEMA, SCRATCHPAD_WRITE_SCHEMA, SCRATCHPAD_READ_SCHEMA, SCRATCHPAD_CLEAR_SCHEMA,
EXPORT_SCHEMA, UPDATE_SCHEMA, FORGET_SCHEMA, IMPORT_SCHEMA, DIAGNOSE_SCHEMA,
RECALL_DIAGNOSTICS_SCHEMA,
TASK_PROGRESS_SCHEMA,
GRAPH_QUERY_SCHEMA, GRAPH_LINK_SCHEMA,
SYNC_PUSH_SCHEMA, SYNC_PULL_SCHEMA, SYNC_STATUS_SCHEMA,
PERSONA_PROMOTE_SCHEMA, PERSONA_DEMOTE_SCHEMA, PERSONA_LIST_SCHEMA, PERSONA_REINFORCE_SCHEMA,
Expand Down
2 changes: 2 additions & 0 deletions integrations/hermes/tests/test_canonical_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ def _provider(tmp_path, profile: str = "profile_a") -> MnemosyneMemoryProvider:
agent_identity=profile,
)
assert provider._beam is not None
assert provider._beam.canonical_owner_id == profile
assert provider._beam.agent_context == "primary"
return provider


Expand Down
62 changes: 62 additions & 0 deletions integrations/hermes/tests/test_recall_diagnostics_tool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from __future__ import annotations

import json

from mnemosyne_hermes import MnemosyneMemoryProvider


def _provider(tmp_path) -> MnemosyneMemoryProvider:
provider = MnemosyneMemoryProvider()
provider.initialize("session-1", hermes_home=str(tmp_path), agent_identity="profile_a")
assert provider._beam is not None
return provider


def _close(provider: MnemosyneMemoryProvider) -> None:
try:
provider._beam.conn.close()
except Exception:
pass


def test_recall_diagnostics_disabled_by_default(tmp_path, monkeypatch):
monkeypatch.delenv("MNEMOSYNE_RECALL_DIAGNOSTICS", raising=False)
provider = _provider(tmp_path)
try:
result = json.loads(provider.handle_tool_call(
"mnemosyne_recall_diagnostics", {}
))
assert result["status"] == "disabled"
finally:
_close(provider)


def test_recall_diagnostics_enabled_and_reset(tmp_path, monkeypatch):
monkeypatch.setenv("MNEMOSYNE_RECALL_DIAGNOSTICS", "1")
provider = _provider(tmp_path)
try:
from mnemosyne.core.recall_diagnostics import get_recall_diagnostics, reset_recall_diagnostics

reset_recall_diagnostics()
provider._beam.remember("Diagnostic test memory about recall", importance=0.7)
provider._beam.recall("diagnostic recall", top_k=5)

before = get_recall_diagnostics()
assert before["totals"]["calls"] >= 1

result = json.loads(provider.handle_tool_call(
"mnemosyne_recall_diagnostics", {"reset": True}
))
assert result["reset"] is True
assert result["diagnostics"]["totals"]["calls"] >= 1

after = get_recall_diagnostics()
assert after["totals"]["calls"] == 0
finally:
_close(provider)


def test_recall_diagnostics_schema_exposed():
provider = MnemosyneMemoryProvider()
names = {schema["name"] for schema in provider.get_tool_schemas()}
assert "mnemosyne_recall_diagnostics" in names
105 changes: 105 additions & 0 deletions integrations/hermes/tests/test_task_progress_tool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
from __future__ import annotations

import json

from mnemosyne_hermes import MnemosyneMemoryProvider


def _provider(tmp_path, profile: str = "profile_a") -> MnemosyneMemoryProvider:
provider = MnemosyneMemoryProvider()
provider.initialize(
"session-1",
hermes_home=str(tmp_path),
agent_context="primary",
agent_identity=profile,
)
assert provider._beam is not None
return provider


def _close(provider: MnemosyneMemoryProvider) -> None:
try:
provider._beam.conn.close()
except Exception:
pass


def test_task_progress_roundtrip_and_list(tmp_path):
provider = _provider(tmp_path, profile="profile_a")
try:
set_result = json.loads(provider.handle_tool_call(
"mnemosyne_task_progress",
{
"action": "set",
"task": "mnemosyne-pr",
"state": "Implemented recall diagnostics. Next: open PR.",
"metadata": {"status": "in_progress"},
},
))
assert set_result["status"] == "set"
assert set_result["owner_id"] == "profile_a"

get_result = json.loads(provider.handle_tool_call(
"mnemosyne_task_progress",
{"action": "get", "task": "mnemosyne-pr"},
))
assert get_result["status"] == "found"
assert get_result["owner_id"] == "profile_a"
assert "Implemented recall diagnostics" in get_result["state"]
assert "status" in get_result["state"]

list_result = json.loads(provider.handle_tool_call(
"mnemosyne_task_progress",
{"action": "list"},
))
assert list_result["count"] == 1
assert list_result["tasks"][0]["task"] == "mnemosyne-pr"
finally:
_close(provider)


def test_task_progress_is_profile_scoped(tmp_path):
provider = _provider(tmp_path, profile="profile_a")
try:
provider.handle_tool_call(
"mnemosyne_task_progress",
{"action": "set", "task": "shared-name", "state": "Profile A state"},
)
provider._agent_identity = "profile_b"
get_result = json.loads(provider.handle_tool_call(
"mnemosyne_task_progress",
{"action": "get", "task": "shared-name"},
))
assert get_result == {"status": "not_found", "task": "shared-name"}
finally:
_close(provider)


def test_task_progress_clear_and_validation(tmp_path):
provider = _provider(tmp_path, profile="profile_a")
try:
assert json.loads(provider.handle_tool_call(
"mnemosyne_task_progress", {"action": "set", "task": "x"}
)) == {"error": "state is required for set"}

provider.handle_tool_call(
"mnemosyne_task_progress",
{"action": "set", "task": "to-clear", "state": "temporary"},
)
cleared = json.loads(provider.handle_tool_call(
"mnemosyne_task_progress", {"action": "clear", "task": "to-clear"}
))
assert cleared == {"status": "cleared", "task": "to-clear"}

missing = json.loads(provider.handle_tool_call(
"mnemosyne_task_progress", {"action": "get", "task": "to-clear"}
))
assert missing == {"status": "not_found", "task": "to-clear"}
finally:
_close(provider)


def test_task_progress_schema_exposed():
provider = MnemosyneMemoryProvider()
names = {schema["name"] for schema in provider.get_tool_schemas()}
assert "mnemosyne_task_progress" in names
2 changes: 1 addition & 1 deletion mnemosyne/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
>>> results = recall("user preferences")
"""

__version__ = "3.10.1"
__version__ = "3.11.0"
__author__ = "Abdias J"
__license__ = "MIT"

Expand Down
Loading
Loading