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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ By default, workspace metadata and layout are stored in:
~/.hermes/visual-workspace/workspaces.sqlite3
```

Hermes remains the canonical store for conversation history. The app database stores only application ownership, node mappings, rendered documents and layout.
Hermes remains the canonical live store for conversation history. The app database keeps application ownership, node mappings, rendered documents, layout, **completed session snapshots**, and queued follow-ups locally. Every completed owned session is snapshotted into this SQLite database; use **Export archive** in the toolbar to download the active Rabbithole, its local snapshots, and any queued follow-ups as JSON.

Back up the SQLite database before destructive maintenance. Uninstalling the service does not remove data.

Expand Down
346 changes: 346 additions & 0 deletions backend/attachments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,346 @@
"""Attachment storage, classification and multipart parsing.

Mirrors the contract Hermes core uses for platform attachments
(``gateway/platforms/base.py::cache_media_bytes``): images become inline
``image_url`` content parts so the model actually sees them, while every other
file kind is written to disk and surfaced to the agent as a path note it can
open with its own tools. The Hermes session API rejects ``input_file`` parts
outright, so the path-note route is the only way non-image files can reach a
session.
"""

from __future__ import annotations

import base64
import mimetypes
import os
import re
import unicodedata
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterator

# Matches the Telegram adapter's ``_max_doc_bytes`` default so uploads here
# behave like uploads on every other Hermes platform.
MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024
MAX_ATTACHMENTS_PER_TURN = 10

# Images the model can see inline. Kept deliberately narrow: these are the
# types the upstream vision path accepts as ``data:image/...`` URLs.
IMAGE_TYPES = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
}
VIDEO_TYPES = {
".mp4": "video/mp4",
".mov": "video/quicktime",
".webm": "video/webm",
".mkv": "video/x-matroska",
}
AUDIO_TYPES = {
".ogg": "audio/ogg",
".mp3": "audio/mpeg",
".wav": "audio/wav",
".m4a": "audio/mp4",
".opus": "audio/opus",
".flac": "audio/flac",
}

_UNSAFE_NAME = re.compile(r"[^\w.\- ]")


class AttachmentError(ValueError):
"""Raised when an upload cannot be accepted."""


def safe_filename(raw: object) -> str:
"""Reduce a browser-supplied filename to a safe display name.

Strips any directory component, normalises unicode and removes characters
outside a conservative allowlist. Never returns an empty string, and never
returns a name that could traverse out of the upload directory.
"""
text = raw if isinstance(raw, str) else ""
text = unicodedata.normalize("NFKC", text)
# Windows and POSIX separators both, before basename.
text = text.replace("\\", "/").split("/")[-1]
text = _UNSAFE_NAME.sub("_", text).strip().strip(".")
if not text:
return "upload.bin"
return text[:255]


def classify(filename: str, declared_type: str = "") -> tuple[str, str]:
"""Return ``(kind, media_type)`` for an upload.

Classification is permissive by design. Hermes core notes that "the
file-extension allowlist must not silently drop their uploads", so an
unrecognised type is stored as a document with an octet-stream MIME rather
than being rejected.
"""
ext = os.path.splitext(filename)[1].lower()
declared = (declared_type or "").split(";")[0].strip().lower()

if ext in IMAGE_TYPES:
return "image", IMAGE_TYPES[ext]
if ext in VIDEO_TYPES:
return "video", VIDEO_TYPES[ext]
if ext in AUDIO_TYPES:
return "audio", AUDIO_TYPES[ext]

# Trust a declared image type only when it is one we can actually inline.
if declared in set(IMAGE_TYPES.values()):
return "image", declared
if declared.startswith("video/"):
return "video", declared
if declared.startswith("audio/"):
return "audio", declared

guessed = declared or (mimetypes.guess_type(filename)[0] or "")
return "document", guessed or "application/octet-stream"


@dataclass
class UploadedPart:
"""One file field decoded from a multipart body."""

filename: str
content_type: str
data: bytes


def _split_headers(chunk: bytes) -> tuple[dict[str, str], bytes]:
head, _, body = chunk.partition(b"\r\n\r\n")
headers: dict[str, str] = {}
for line in head.split(b"\r\n"):
name, sep, value = line.partition(b":")
if sep:
headers[name.decode("latin-1").strip().lower()] = value.decode("latin-1").strip()
return headers, body


def _header_param(value: str, key: str) -> str:
# RFC 2183 style: name="value". Quoted form first, then bare token.
match = re.search(rf'{key}="([^"]*)"', value)
if match:
return match.group(1)
match = re.search(rf"{key}=([^;]+)", value)
return match.group(1).strip() if match else ""


def parse_multipart(body: bytes, content_type: str) -> Iterator[UploadedPart]:
"""Yield file parts from a ``multipart/form-data`` body.

Implemented directly because the backend is Python-stdlib-only and
``cgi.FieldStorage`` was removed in 3.13. Only parts carrying a filename
are returned; plain text fields are ignored.
"""
boundary = _header_param(content_type, "boundary")
if not boundary:
raise AttachmentError("multipart body is missing its boundary")

delimiter = b"--" + boundary.encode("latin-1")
segments = body.split(delimiter)
# First segment is the preamble, last is the closing "--" epilogue.
for segment in segments[1:]:
if segment[:2] == b"--":
break
segment = segment.lstrip(b"\r\n")
if not segment:
continue
headers, payload = _split_headers(segment)
disposition = headers.get("content-disposition", "")
if "filename" not in disposition:
continue
# Each part's payload is terminated by the CRLF preceding the next
# delimiter, which the split leaves attached.
if payload.endswith(b"\r\n"):
payload = payload[:-2]
filename = _header_param(disposition, "filename")
if not filename:
continue
yield UploadedPart(
filename=filename,
content_type=headers.get("content-type", ""),
data=payload,
)


class AttachmentStorage:
"""Writes attachment bytes under a per-node directory."""

def __init__(self, root: str | Path):
self.root = Path(root).expanduser().resolve()

def _node_dir(self, node_id: str) -> Path:
return self.root / node_id

def write(self, node_id: str, attachment_id: str, filename: str, data: bytes) -> Path:
"""Persist bytes and return the absolute path written.

The stored basename is derived from the attachment id, not the
user-supplied filename, so a hostile name cannot collide with or
overwrite another upload. The display name lives in SQLite.
"""
if len(data) > MAX_ATTACHMENT_BYTES:
raise AttachmentError("file exceeds the maximum upload size")
ext = os.path.splitext(safe_filename(filename))[1][:16]
directory = self._node_dir(node_id)
directory.mkdir(parents=True, exist_ok=True)
target = directory / f"{attachment_id}{ext}"
# Written 0600: uploads may contain private documents.
handle = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
try:
os.write(handle, data)
finally:
os.close(handle)
return target

def resolve(self, storage_path: str) -> Path:
"""Return a path guaranteed to sit inside the upload root."""
candidate = Path(storage_path).expanduser().resolve()
if candidate != self.root and self.root not in candidate.parents:
raise AttachmentError("attachment path escapes the upload root")
return candidate

def move_to_node(self, storage_path: str, node_id: str) -> Path:
"""Relocate an existing upload into another node's directory.

Used when a pending attachment is handed from a parent node to the
child branch that actually sends it: the bytes must follow the row so
the per-node directory stays the single place a node's files live, and
so ``remove_node`` on the parent cannot delete a file the child owns.

The basename is preserved (it is the attachment id, not user input) and
the source is resolved through ``resolve`` first, so a tampered
``storage_path`` cannot make this read or write outside the root. If
the file is already in the right place, or its bytes have gone missing,
the existing path is returned rather than raising -- losing the move
must not fail the branch that triggered it.
"""
source = self.resolve(storage_path)
directory = self._node_dir(node_id)
target = directory / source.name
if source == target or not source.exists():
return source
directory.mkdir(parents=True, exist_ok=True)
os.replace(source, target)
return target

def remove(self, storage_path: str) -> None:
try:
self.resolve(storage_path).unlink(missing_ok=True)
except (AttachmentError, OSError):
# Losing the bytes must never fail the API call that deletes the row.
pass

def remove_node(self, node_id: str) -> None:
directory = self._node_dir(node_id)
if not directory.exists():
return
for child in directory.iterdir():
try:
child.unlink()
except OSError:
pass
try:
directory.rmdir()
except OSError:
pass


def agent_visible_path(storage_path: str, translation: str = "") -> str:
"""Map a backend path to the path the agent sees.

The workspace backend and the Hermes agent can run on opposite sides of a
container boundary, where the same bytes are reachable at two different
absolute paths. ``translation`` (``HERMES_VISUAL_AGENT_PATH_MAP``) describes
that relationship as ``from:to`` -- e.g. ``/root/.hermes:/opt/data`` when the
agent's ``/opt/data`` is a bind mount of the host's ``/root/.hermes``. A bare
value with no colon is treated as a prefix to prepend, which covers the
simpler case of the agent seeing the host tree under a mount point.

Empty means both processes share one filesystem view and paths pass through.
"""
if not translation:
return storage_path
source, separator, target = translation.partition(":")
if not separator:
return translation.rstrip("/") + storage_path
source = source.rstrip("/")
if storage_path == source or storage_path.startswith(source + "/"):
return target.rstrip("/") + storage_path[len(source):]
return storage_path


def context_note(kind: str, filename: str, agent_path: str) -> str:
"""One-line note pointing the agent at an uploaded file.

Deliberately identical in shape to ``CachedMedia.context_note`` in Hermes
core so the agent sees the exact phrasing it already handles from Telegram
and the other platform adapters.
"""
return f"[{kind} '{filename}' saved at: {agent_path}]"


def data_url(media_type: str, data: bytes) -> str:
return f"data:{media_type};base64,{base64.b64encode(data).decode('ascii')}"


def build_chat_input(
prompt: str,
attachments: list[dict[str, Any]],
storage: AttachmentStorage,
translation: str = "",
) -> Any:
"""Compose the Hermes ``input`` payload for a turn with attachments.

Images become ``image_url`` parts carrying a base64 data URL, which is the
only inline form the session API accepts. Non-image attachments cannot be
inlined at all, so their location is appended to the text as a note.
Returns a plain string when there is nothing to attach, keeping the common
case byte-identical to the previous behaviour.
"""
if not attachments:
return prompt

notes: list[str] = []
image_parts: list[dict[str, Any]] = []

for attachment in attachments:
kind = attachment.get("kind", "document")
filename = attachment.get("filename", "file")
stored = attachment.get("storage_path", "")
if kind == "image":
try:
payload = storage.resolve(stored).read_bytes()
except (AttachmentError, OSError):
notes.append(f"[image '{filename}' could not be read from disk]")
continue
image_parts.append(
{
"type": "image_url",
"image_url": {
"url": data_url(attachment.get("media_type", "image/png"), payload)
},
}
)
# Named so the agent can refer to the image, and so a later
# tool-based re-read can find the same bytes on disk.
notes.append(
context_note("image", filename, agent_visible_path(stored, translation))
)
else:
notes.append(
context_note(kind, filename, agent_visible_path(stored, translation))
)

text = prompt
if notes:
text = (prompt + "\n\n" + "\n".join(notes)).strip()
if not image_parts:
return text
return [{"type": "text", "text": text}, *image_parts]
Loading
Loading