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:
Hao Zhe 2026-06-19 18:44:57 +08:00 committed by Teknium
parent 38c56a1e86
commit 70e7132e2f
9 changed files with 560 additions and 45 deletions

View file

@ -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.

View file

@ -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: