mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-26 11:12:03 +00:00
fix(openviking): gate memory writes and add viking_forget
Mirror built-in memory writes to external providers only after the native memory tool succeeds and is not staged for approval. Keep OpenViking's built-in memory mirroring add-only, since Hermes native memory entries do not yet have stable OpenViking file URIs for replace/remove. Add a narrow viking_forget tool for exact user memory file deletion and document the current OpenViking write/delete behavior.
This commit is contained in:
parent
38c56a1e86
commit
70e7132e2f
9 changed files with 560 additions and 45 deletions
|
|
@ -47,5 +47,37 @@ Hermes sends `OPENVIKING_ACCOUNT` and `OPENVIKING_USER` as identity headers.
|
|||
| `viking_search` | Semantic search with fast/deep/auto modes |
|
||||
| `viking_read` | Read content at a viking:// URI (abstract/overview/full) |
|
||||
| `viking_browse` | Filesystem-style navigation (list/tree/stat) |
|
||||
| `viking_remember` | Store a fact for extraction on session commit |
|
||||
| `viking_remember` | Store a fact directly with OpenViking `content/write` |
|
||||
| `viking_forget` | Delete one exact `viking://` memory file URI |
|
||||
| `viking_add_resource` | Ingest URLs/docs into the knowledge base |
|
||||
|
||||
## Memory Writes And Deletes
|
||||
|
||||
`viking_remember` writes directly to OpenViking with `POST /api/v1/content/write`
|
||||
and `mode=create`. It creates peer-scoped memory files under
|
||||
`viking://user/peers/${OPENVIKING_AGENT}/memories/...`; OpenViking may return a
|
||||
canonical user-scoped form such as
|
||||
`viking://user/default/peers/${OPENVIKING_AGENT}/memories/...` in API-key mode.
|
||||
Explicit remembers do not depend on session commit extraction.
|
||||
|
||||
Hermes built-in `memory` tool additions are mirrored to OpenViking after the
|
||||
local memory operation succeeds:
|
||||
|
||||
| Hermes action | OpenViking operation |
|
||||
|---------------|----------------------|
|
||||
| `add` | `content/write` with `mode=create` under the configured peer memory namespace |
|
||||
|
||||
Built-in `replace` and `remove` operations are not mirrored because Hermes
|
||||
native memory entries do not yet carry stable OpenViking file URIs. Use
|
||||
`viking_forget` when the user explicitly asks to delete a specific OpenViking
|
||||
memory URI.
|
||||
|
||||
`viking_forget` is intentionally narrow. It only accepts concrete user memory
|
||||
file URIs, such as
|
||||
`viking://user/peers/hermes/memories/preferences/mem_abc123.md` or the canonical
|
||||
`viking://user/default/peers/hermes/memories/preferences/mem_abc123.md`. Files
|
||||
directly under `memories/`, such as `viking://user/default/memories/profile.md`,
|
||||
are also allowed because OpenViking supports them. The tool rejects directories,
|
||||
resources, skills, sessions, generated summary files, and URIs with query
|
||||
strings or fragments. Use OpenViking's MCP, CLI, or admin APIs for broader
|
||||
resource and directory cleanup.
|
||||
|
|
|
|||
|
|
@ -91,6 +91,13 @@ _MEMORY_WRITE_TARGET_SUBDIR_MAP = {
|
|||
"user": "preferences",
|
||||
"memory": "patterns",
|
||||
}
|
||||
_DERIVED_MEMORY_FILENAMES = {
|
||||
".abstract.md",
|
||||
".overview.md",
|
||||
".read.md",
|
||||
".full.md",
|
||||
".relations.json",
|
||||
}
|
||||
_LOCAL_OPENVIKING_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
||||
_LOCAL_OPENVIKING_AUTOSTART_TIMEOUT = 60.0
|
||||
_OPENVIKING_SERVER_LOG_RELATIVE_PATH = Path("logs") / "openviking-server.log"
|
||||
|
|
@ -320,6 +327,13 @@ class _VikingClient:
|
|||
)
|
||||
)
|
||||
|
||||
def delete(self, path: str, **kwargs) -> dict:
|
||||
return self._send_with_trusted_identity_retry(
|
||||
lambda headers: self._httpx.delete(
|
||||
self._url(path), headers=headers, timeout=_TIMEOUT, **kwargs
|
||||
)
|
||||
)
|
||||
|
||||
def upload_temp_file(self, file_path: Path) -> str:
|
||||
mime_type = mimetypes.guess_type(file_path.name)[0] or "application/octet-stream"
|
||||
|
||||
|
|
@ -460,6 +474,26 @@ REMEMBER_SCHEMA = {
|
|||
},
|
||||
}
|
||||
|
||||
FORGET_SCHEMA = {
|
||||
"name": "viking_forget",
|
||||
"description": (
|
||||
"Delete one OpenViking memory file by exact viking:// URI. "
|
||||
"Use only when the user explicitly asks to forget or delete a specific "
|
||||
"memory and you have the exact memory file URI. Resources, skills, "
|
||||
"sessions, directories, generated summaries, and broad deletes are rejected."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"uri": {
|
||||
"type": "string",
|
||||
"description": "Exact viking:// memory file URI ending in .md.",
|
||||
},
|
||||
},
|
||||
"required": ["uri"],
|
||||
},
|
||||
}
|
||||
|
||||
ADD_RESOURCE_SCHEMA = {
|
||||
"name": "viking_add_resource",
|
||||
"description": (
|
||||
|
|
@ -552,6 +586,46 @@ def _is_remote_resource_source(value: str) -> bool:
|
|||
return value.startswith(_REMOTE_RESOURCE_PREFIXES)
|
||||
|
||||
|
||||
def _memory_segment_index(parts: List[str]) -> Optional[int]:
|
||||
if len(parts) >= 2 and parts[0] == "user" and parts[1] == "memories":
|
||||
return 1
|
||||
if len(parts) >= 3 and parts[0] == "user" and parts[2] == "memories":
|
||||
return 2
|
||||
if len(parts) >= 4 and parts[0] == "user" and parts[1] == "peers" and parts[3] == "memories":
|
||||
return 3
|
||||
if len(parts) >= 5 and parts[0] == "user" and parts[2] == "peers" and parts[4] == "memories":
|
||||
return 4
|
||||
return None
|
||||
|
||||
|
||||
def _validate_forget_memory_uri(raw_uri: Any) -> tuple[Optional[str], Optional[str]]:
|
||||
if not isinstance(raw_uri, str):
|
||||
return None, "uri is required"
|
||||
|
||||
uri = raw_uri.strip()
|
||||
if not uri:
|
||||
return None, "uri is required"
|
||||
|
||||
parsed = urlparse(uri)
|
||||
if parsed.scheme != "viking" or not uri.startswith("viking://"):
|
||||
return None, "viking_forget only accepts viking:// memory file URIs"
|
||||
if parsed.query or parsed.fragment:
|
||||
return None, "viking_forget requires an exact URI without query or fragment"
|
||||
if uri.endswith("/") or not uri.endswith(".md"):
|
||||
return None, "viking_forget only deletes concrete .md memory files"
|
||||
|
||||
parts = [part for part in uri[len("viking://") :].split("/") if part]
|
||||
memories_idx = _memory_segment_index(parts)
|
||||
if memories_idx is None or len(parts) < memories_idx + 2:
|
||||
return None, "viking_forget only deletes user memory file URIs"
|
||||
|
||||
filename = uri.rsplit("/", 1)[-1]
|
||||
if filename in _DERIVED_MEMORY_FILENAMES:
|
||||
return None, "viking_forget cannot delete generated memory summary files"
|
||||
|
||||
return uri, None
|
||||
|
||||
|
||||
def _is_local_path_reference(value: str) -> bool:
|
||||
if not value or "\n" in value or "\r" in value:
|
||||
return False
|
||||
|
|
@ -2047,7 +2121,8 @@ class OpenVikingMemoryProvider(MemoryProvider):
|
|||
f"Active. Endpoint: {self._endpoint}\n"
|
||||
"Use viking_search to find information, viking_read for details "
|
||||
"(abstract/overview/full), viking_browse to explore.\n"
|
||||
"Use viking_remember to store facts, viking_add_resource to index URLs/docs."
|
||||
"Use viking_remember to store facts, viking_forget to delete exact memory "
|
||||
"file URIs, and viking_add_resource to index URLs/docs."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("OpenViking system_prompt_block failed: %s", e)
|
||||
|
|
@ -2055,7 +2130,7 @@ class OpenVikingMemoryProvider(MemoryProvider):
|
|||
"# OpenViking Knowledge Base\n"
|
||||
f"Active. Endpoint: {self._endpoint}\n"
|
||||
"Use viking_search, viking_read, viking_browse, "
|
||||
"viking_remember, viking_add_resource."
|
||||
"viking_remember, viking_forget, viking_add_resource."
|
||||
)
|
||||
|
||||
def prefetch(self, query: str, *, session_id: str = "") -> str:
|
||||
|
|
@ -2806,7 +2881,7 @@ class OpenVikingMemoryProvider(MemoryProvider):
|
|||
content: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Mirror built-in memory writes to OpenViking via content/write."""
|
||||
"""Mirror successful built-in memory additions to OpenViking."""
|
||||
if not self._client or action != "add" or not content:
|
||||
return
|
||||
|
||||
|
|
@ -2831,7 +2906,14 @@ class OpenVikingMemoryProvider(MemoryProvider):
|
|||
t.start()
|
||||
|
||||
def get_tool_schemas(self) -> List[Dict[str, Any]]:
|
||||
return [SEARCH_SCHEMA, READ_SCHEMA, BROWSE_SCHEMA, REMEMBER_SCHEMA, ADD_RESOURCE_SCHEMA]
|
||||
return [
|
||||
SEARCH_SCHEMA,
|
||||
READ_SCHEMA,
|
||||
BROWSE_SCHEMA,
|
||||
REMEMBER_SCHEMA,
|
||||
FORGET_SCHEMA,
|
||||
ADD_RESOURCE_SCHEMA,
|
||||
]
|
||||
|
||||
def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:
|
||||
if not self._client:
|
||||
|
|
@ -2846,6 +2928,8 @@ class OpenVikingMemoryProvider(MemoryProvider):
|
|||
return self._tool_browse(args)
|
||||
elif tool_name == "viking_remember":
|
||||
return self._tool_remember(args)
|
||||
elif tool_name == "viking_forget":
|
||||
return self._tool_forget(args)
|
||||
elif tool_name == "viking_add_resource":
|
||||
return self._tool_add_resource(args)
|
||||
return tool_error(f"Unknown tool: {tool_name}")
|
||||
|
|
@ -3097,6 +3181,31 @@ class OpenVikingMemoryProvider(MemoryProvider):
|
|||
logger.error("OpenViking content/write failed: %s", e)
|
||||
return tool_error(f"Failed to store memory: {e}")
|
||||
|
||||
def _tool_forget(self, args: dict) -> str:
|
||||
uri, error = _validate_forget_memory_uri(args.get("uri"))
|
||||
if error:
|
||||
return tool_error(error)
|
||||
|
||||
resp = self._client.delete(
|
||||
"/api/v1/fs",
|
||||
params={"uri": uri, "recursive": False},
|
||||
)
|
||||
result = self._unwrap_result(resp)
|
||||
payload: Dict[str, Any] = {"status": "deleted", "uri": uri}
|
||||
if isinstance(result, dict):
|
||||
payload["uri"] = result.get("uri") or uri
|
||||
for key in (
|
||||
"estimated_deleted_count",
|
||||
"memory_cleanup",
|
||||
"semantic_root_uri",
|
||||
"semantic_status",
|
||||
"queue_status",
|
||||
):
|
||||
if key in result:
|
||||
payload[key] = result[key]
|
||||
|
||||
return json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
def _tool_add_resource(self, args: dict) -> str:
|
||||
url = args.get("url", "")
|
||||
if not url:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue