Skip to content
Closed
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
79 changes: 78 additions & 1 deletion agent/memory_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import json
import logging
import re
from difflib import SequenceMatcher
from typing import Any, Dict, List, Optional

from agent.memory_provider import MemoryProvider
Expand Down Expand Up @@ -192,7 +193,83 @@ def prefetch_all(self, query: str, *, session_id: str = "") -> str:
"Memory provider '%s' prefetch failed (non-fatal): %s",
provider.name, e,
)
return "\n\n".join(parts)
return self._merge_prefetch_parts(parts)

def _merge_prefetch_parts(self, parts: List[str]) -> str:
"""Merge provider prefetch blocks while deduplicating repeated memory lines."""
merged_blocks = []
seen_content: List[str] = []

for part in parts:
block = self._deduplicate_prefetch_block(part, seen_content)
if block:
merged_blocks.append(block)

return "\n\n".join(merged_blocks)

def _deduplicate_prefetch_block(self, block: str, seen_content: List[str]) -> str:
headers: List[str] = []
body: List[str] = []

for raw_line in block.splitlines():
line = raw_line.rstrip()
if not line.strip():
if body and body[-1] != "":
body.append("")
continue

normalized = self._normalize_prefetch_line(line)
if self._is_prefetch_header_line(line, normalized):
headers.append(line)
continue

if any(self._prefetch_lines_match(normalized, prior) for prior in seen_content):
continue

seen_content.append(normalized)
body.append(line)

while body and body[-1] == "":
body.pop()

if not body:
return ""

lines = list(headers)
if headers and body:
lines.append("")
lines.extend(body)
return "\n".join(lines)

def _is_prefetch_header_line(self, line: str, normalized: str) -> bool:
stripped = line.strip()
if not normalized:
return True
if stripped.endswith(":"):
return True
if stripped.startswith(("#", "[")):
return True
return False

def _normalize_prefetch_line(self, line: str) -> str:
normalized = line.strip()
normalized = re.sub(r"^[-*•]+\s*", "", normalized)
normalized = re.sub(r"^\d+[.)]\s*", "", normalized)
normalized = re.sub(r"^\[[^\]]+\]\s*", "", normalized)
normalized = re.sub(r"\s+", " ", normalized)
normalized = re.sub(r"[`*_#>]", "", normalized)
normalized = re.sub(r"[^\w\s\u4e00-\u9fff]", "", normalized.casefold())
normalized = re.sub(r"\s+", " ", normalized)
return normalized.strip()

def _prefetch_lines_match(self, left: str, right: str) -> bool:
if not left or not right:
return False
if left == right:
return True
if len(left) >= 24 and len(right) >= 24 and SequenceMatcher(a=left, b=right).ratio() >= 0.96:
return True
return False

def queue_prefetch_all(self, query: str, *, session_id: str = "") -> None:
"""Queue background prefetch on all providers for the next turn."""
Expand Down
284 changes: 284 additions & 0 deletions plugins/memory/mempalace/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
# MemPalace Memory Provider

Local-first memory provider for Hermes Agent using [MemPalace](https://github.com/mempalace/mempalace). It stores memories in a configurable ChromaDB collection, supports semantic search, optional knowledge-graph initialization, room scoping, structured metadata, and Hermes memory-provider hooks.

## What this plugin provides

- Configurable collection naming via `collection_name` or `collection_template`
- Configurable room derivation via `room_strategy`
- Structured metadata for tool writes, sync-turn writes, compression writes, and builtin memory mirroring
- Five Hermes tools:
- `mempalace_memorize`
- `mempalace_search`
- `mempalace_recall`
- `mempalace_forget`
- `mempalace_status`
- Hook support for:
- `sync_turn()`
- `prefetch()` / `queue_prefetch()`
- `on_memory_write()`
- `on_pre_compress()`
- `on_session_end()`

## Requirements

- Python environment with the `mempalace` package installed
- Hermes Agent plugin system enabled
- Writable Hermes home directory

Example install:

```bash
pip install mempalace
```

## Setup

### 1. Put the plugin in the Hermes plugins directory

Expected location:

```text
plugins/memory/mempalace/
```

Files included by this plugin:

```text
__init__.py
plugin.yaml
provider.py
tools.py
hooks.py
config.py
collections.py
metadata.py
errors.py
store.py
writer.py
events.py
schemas.py
```

### 2. Enable the provider in Hermes config

Set the active memory provider to `mempalace` in `~/.hermes/config.yaml`:

```yaml
memory:
provider: mempalace

mempalace:
palace_path: ~/.hermes/mempalace
wing: conversations
n_results: 5
tool_max_results: 20
enable_kg: true
collection_template: hermes-{platform}-{user_id}
room_strategy: platform_session
fixed_room: memory
```

## Configuration

The plugin reads config from the nested `mempalace:` block in Hermes config.

| Key | Default | Description |
|---|---|---|
| `palace_path` | `$HERMES_HOME/mempalace` | Root directory for MemPalace persistent data |
| `wing` | `conversations` | Logical MemPalace wing used for records |
| `n_results` | `5` | Default semantic search result count |
| `tool_max_results` | `20` | Hard cap for tool result counts |
| `enable_kg` | `true` | Initialize knowledge graph when available |
| `collection_name` | empty | Explicit collection name; overrides template |
| `collection_template` | `hermes-{platform}-{user_id}` | Collection naming template |
| `room_strategy` | `platform_session` | Default room derivation strategy |
| `fixed_room` | `memory` | Used when `room_strategy: fixed` |

### `collection_name` vs `collection_template`

If `collection_name` is set, it wins.

Example:

```yaml
mempalace:
collection_name: hermes-telegram-jessica
```

If `collection_name` is empty, the plugin renders `collection_template` using runtime fields:

- `{user_id}`
- `{platform}`
- `{session_id}`
- `{agent_id}`

Example:

```yaml
mempalace:
collection_template: hermes-{platform}-{user_id}
```

This might resolve to:

```text
hermes-telegram-7892983586
```

### Room strategies

Available `room_strategy` values:

- `fixed`
- `session`
- `platform_session`
- `user_platform`

Examples:

```yaml
mempalace:
room_strategy: fixed
fixed_room: memory
```

```yaml
mempalace:
room_strategy: platform_session
```

With `platform_session`, a Telegram thread/session may resolve to a room like:

```text
telegram-thread-42
```

## Tools

### `mempalace_memorize`

Store an explicit memory.

Arguments:

- `content` (required)
- `memory_type` (optional)
- `importance` (optional)
- `room` (optional)

Example:

```json
{
"content": "Jessica prefers detailed technical explanations in Chinese.",
"memory_type": "preference",
"importance": 0.95,
"room": "prefs"
}
```

### `mempalace_search`

Semantic search over stored memories.

Arguments:

- `query` (required)
- `room` (optional)
- `top_k` (optional)

### `mempalace_recall`

Fetch memories from a room.

Arguments:

- `room` (optional)
- `n_results` (optional)

### `mempalace_forget`

Delete a memory by ID.

Arguments:

- `memory_id` (required)

### `mempalace_status`

Returns runtime status such as:

- active collection name
- room strategy
- wing
- configured result limits
- knowledge-graph initialization status

## Notes for users

### Collection stability matters

If you previously stored memories in a collection like:

```text
hermes-telegram-jessica
```

but your current runtime resolves to:

```text
hermes-telegram-7892983586
```

the plugin is still working, but it will read/write a different collection. In that case, either:

1. set `collection_name` explicitly to the historical collection name, or
2. migrate old records into the new collection

### Metadata shape

Stored records include normalized metadata such as:

- `room`
- `wing`
- `source`
- `message_kind`
- `session_id`
- `platform`
- `user_id`
- `agent_id`
- `created_at`

Optional fields such as `memory_type` and `importance` are included when provided.

## Verification

The plugin was verified with:

```bash
pytest tests/plugins/test_mempalace_v2_foundation.py \
tests/plugins/test_mempalace_module_layout.py \
tests/plugins/test_mempalace_plugin_loader.py \
tests/plugins/test_mempalace_e2e.py -q
```

It also passed a live importlib-style runtime check covering:

- plugin load
- provider initialization
- `mempalace_status`
- `mempalace_memorize`
- `mempalace_search`
- `mempalace_recall`

## Suggested reviewer quick start

1. Install `mempalace`
2. Copy the plugin into `plugins/memory/mempalace/`
3. Set `memory.provider: mempalace`
4. Add a `mempalace:` block in `~/.hermes/config.yaml`
5. Start Hermes
6. Call `mempalace_status`
7. Store a test fact with `mempalace_memorize`
8. Verify retrieval with `mempalace_search`
17 changes: 17 additions & 0 deletions plugins/memory/mempalace/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"""MemPalace memory plugin — MemoryProvider interface.

Local-first AI memory with semantic search, knowledge graphs, and spatial
memory palace. Stores memories in ChromaDB with entity extraction via
knowledge graph.
"""

from __future__ import annotations

from .provider import MemPalaceMemoryProvider

__all__ = ["MemPalaceMemoryProvider", "register"]


def register(ctx) -> None:
"""Register MemPalace as a memory provider plugin."""
ctx.register_memory_provider(MemPalaceMemoryProvider())
Loading