Files
hope-voice-api/control/chat_features.py
T

778 lines
42 KiB
Python

"""Authenticated advanced chat surfaces. Hope stores metadata; Hermes executes.
Construct FeatureStore with the application's Accounts and include
create_feature_router(store, provider). No provider operation is enabled merely
because a method exists: the adapter must explicitly qualify that capability.
All persistence and extraction is offloaded by the async public methods.
"""
from __future__ import annotations
import asyncio
import base64
import importlib.util
import hashlib
import json
import os
import re
import stat
import subprocess
import sys
import threading
import time
import uuid
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from .app import current_user
MIMES = ('text/plain', 'text/markdown', 'application/pdf', 'image/png', 'image/jpeg', 'image/webp')
ID = re.compile(r'[a-f0-9]{32}\Z')
@dataclass(frozen=True)
class FeatureLimits:
upload_bytes: int = 8 * 1024 * 1024
account_bytes: int = 64 * 1024 * 1024
disk_bytes: int = 512 * 1024 * 1024
extracted_chars: int = 100_000
artifact_bytes: int = 512 * 1024
artifact_count: int = 200
account_artifact_bytes: int = 32 * 1024 * 1024
global_artifact_bytes: int = 256 * 1024 * 1024
artifact_versions: int = 100
attachments_per_message: int = 8
class FeatureStore:
def __init__(self, accounts, root_path, limits=None):
self.accounts = accounts
self.db = accounts.db
self.lock = accounts.lock
self.limits = limits or FeatureLimits()
self.upload_admission = threading.BoundedSemaphore(2)
raw = Path(root_path).absolute()
if raw.is_symlink():
raise ValueError('Private storage cannot be a symlink')
raw.mkdir(parents=True, exist_ok=True, mode=0o700)
self.root = raw.resolve()
os.chmod(self.root, 0o700)
with self.lock, self.db:
self.db.executescript('''
CREATE TABLE IF NOT EXISTS hope_attachments (
id TEXT PRIMARY KEY, owner TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
filename TEXT NOT NULL, mime TEXT NOT NULL, size INTEGER NOT NULL,
extracted TEXT, extraction_status TEXT NOT NULL, created_at REAL NOT NULL,
deleted_at REAL);
CREATE INDEX IF NOT EXISTS hope_attachments_owner ON hope_attachments(owner);
CREATE TABLE IF NOT EXISTS hope_artifacts (
id TEXT PRIMARY KEY, owner TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL, kind TEXT NOT NULL, revision INTEGER NOT NULL,
created_at REAL NOT NULL, updated_at REAL NOT NULL, deleted_at REAL);
CREATE TABLE IF NOT EXISTS hope_artifact_versions (
artifact_id TEXT NOT NULL REFERENCES hope_artifacts(id) ON DELETE CASCADE,
revision INTEGER NOT NULL, content TEXT NOT NULL, created_at REAL NOT NULL,
PRIMARY KEY(artifact_id, revision));
CREATE TABLE IF NOT EXISTS hope_feature_changes (
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
owner TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kind TEXT NOT NULL, object_id TEXT NOT NULL, deleted INTEGER NOT NULL,
created_at REAL NOT NULL);
''')
def _owner(self, owner):
row = self.db.execute("SELECT id FROM users WHERE id=? AND desired!='deleted'", (owner,)).fetchone()
if row is None:
raise HTTPException(401, 'Account unavailable')
def _path(self, identifier):
if not isinstance(identifier, str) or not ID.fullmatch(identifier):
raise HTTPException(404, 'Attachment not found')
path = self.root / identifier
if path.parent.resolve() != self.root or path.is_symlink():
raise HTTPException(404, 'Attachment not found')
return path
def _change(self, owner, kind, identifier, deleted=False):
self.db.execute('INSERT INTO hope_feature_changes(owner,kind,object_id,deleted,created_at) VALUES(?,?,?,?,?)',
(owner, kind, identifier, int(deleted), time.time()))
@staticmethod
def _attachment(row):
return {k: row[k] for k in ('id', 'filename', 'mime', 'size', 'extraction_status', 'created_at')}
def _extract(self, mime, data):
if mime.startswith('text/'):
try:
text = data.decode('utf-8')
except UnicodeDecodeError:
raise HTTPException(415, 'Text must be UTF-8') from None
if '\x00' in text:
raise HTTPException(415, 'Binary text is not supported')
return text[:self.limits.extracted_chars], 'truncated' if len(text) > self.limits.extracted_chars else 'ready'
signatures = {'application/pdf': b'%PDF-', 'image/png': b'\x89PNG\r\n\x1a\n', 'image/jpeg': b'\xff\xd8\xff'}
if mime in signatures and not data.startswith(signatures[mime]):
raise HTTPException(415, 'File does not match MIME type')
if mime == 'image/webp' and not (data.startswith(b'RIFF') and data[8:12] == b'WEBP'):
raise HTTPException(415, 'File does not match MIME type')
if mime == 'application/pdf':
if importlib.util.find_spec('pypdf') is None:
return None, 'unavailable'
# Untrusted compressed PDF structures must not run in the control
# process. Child has CPU/address-space/output limits and a wall timeout.
script = '''import resource,sys,io,json
resource.setrlimit(resource.RLIMIT_AS,(384*1024*1024,384*1024*1024))
resource.setrlimit(resource.RLIMIT_CPU,(4,4))
from pypdf import PdfReader
limit=int(sys.argv[1]); reader=PdfReader(io.BytesIO(sys.stdin.buffer.read()))
if reader.is_encrypted:
print(json.dumps([None,'encrypted'])); sys.exit(0)
parts=[]; size=0; truncated=False
for page in reader.pages:
text=page.extract_text() or ''
parts.append(text[:max(0,limit-size)])
size+=len(text)
if size>=limit:
truncated=True; break
print(json.dumps(['\\n'.join(parts)[:limit],'truncated' if truncated else 'ready']))
'''
try:
result = subprocess.run([sys.executable, '-I', '-c', script, str(self.limits.extracted_chars)],
input=data, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=6, check=True)
text, status = json.loads(result.stdout)
return text, status
except Exception:
return None, 'failed'
return None, 'image'
def _upload(self, owner, filename, mime, data):
if mime not in MIMES:
raise HTTPException(415, 'Unsupported MIME type')
if not isinstance(data, bytes) or not 0 < len(data) <= self.limits.upload_bytes:
raise HTTPException(413, 'Upload byte limit exceeded')
if not isinstance(filename, str) or not 1 <= len(filename) <= 160 or any(c in filename for c in ('/', '\\', '\x00', '\r', '\n')) or any(ord(c) < 32 for c in filename):
raise HTTPException(422, 'Invalid filename')
# Reserve parser concurrency before quota/extraction work; fail fast.
if not self.upload_admission.acquire(blocking=False):
raise HTTPException(429, 'Upload processing busy')
try:
with self.lock:
self._owner(owner)
used = self.db.execute('SELECT COALESCE(SUM(size),0) FROM hope_attachments WHERE owner=? AND deleted_at IS NULL', (owner,)).fetchone()[0]
disk = sum(p.stat(follow_symlinks=False).st_size for p in self.root.iterdir() if p.is_file() and not p.is_symlink())
if used + len(data) > self.limits.account_bytes or disk + len(data) > self.limits.disk_bytes:
raise HTTPException(413, 'Private storage quota exceeded')
text, status = self._extract(mime, data)
finally:
self.upload_admission.release()
identifier = uuid.uuid4().hex
path = self._path(identifier)
with self.lock, self.db:
self._owner(owner)
used = self.db.execute('SELECT COALESCE(SUM(size),0) FROM hope_attachments WHERE owner=? AND deleted_at IS NULL', (owner,)).fetchone()[0]
# Include retained/orphaned files after account deletion in global quota.
disk = sum(p.stat(follow_symlinks=False).st_size for p in self.root.iterdir() if p.is_file() and not p.is_symlink())
if used + len(data) > self.limits.account_bytes or disk + len(data) > self.limits.disk_bytes:
raise HTTPException(413, 'Private storage quota exceeded')
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, 'O_NOFOLLOW', 0), 0o600)
try:
with os.fdopen(fd, 'wb') as stream:
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
self.db.execute('INSERT INTO hope_attachments VALUES(?,?,?,?,?,?,?,?,NULL)',
(identifier, owner, filename, mime, len(data), text, status, time.time()))
self._change(owner, 'attachment', identifier)
except BaseException:
# _path has verified the resolved parent and server-generated ID.
path.unlink(missing_ok=True)
raise
return self._attachment(self._get_attachment(owner, identifier))
def _get_attachment(self, owner, identifier):
self._owner(owner)
row = self.db.execute('SELECT * FROM hope_attachments WHERE owner=? AND id=? AND deleted_at IS NULL', (owner, identifier)).fetchone()
if row is None:
raise HTTPException(404, 'Attachment not found')
return row
def _read_attachment(self, owner, identifier):
with self.lock:
row = self._get_attachment(owner, identifier)
path = self._path(identifier)
try:
fd = os.open(path, os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0))
with os.fdopen(fd, 'rb') as stream:
info = os.fstat(stream.fileno())
if not stat.S_ISREG(info.st_mode) or info.st_size != row['size']:
raise HTTPException(404, 'Attachment unavailable')
data = stream.read(self.limits.upload_bytes + 1)
except OSError:
raise HTTPException(404, 'Attachment unavailable') from None
return self._attachment(row), data
def _delete_attachment(self, owner, identifier):
with self.lock, self.db:
self._get_attachment(owner, identifier)
path = self._path(identifier)
path.unlink(missing_ok=True)
self.db.execute('UPDATE hope_attachments SET deleted_at=? WHERE owner=? AND id=?', (time.time(), owner, identifier))
self._change(owner, 'attachment', identifier, True)
return {'ok': True}
async def upload(self, owner, filename, mime, data):
return await asyncio.to_thread(self._upload, owner, filename, mime, data)
async def read_attachment(self, owner, identifier):
return await asyncio.to_thread(self._read_attachment, owner, identifier)
async def delete_attachment(self, owner, identifier):
return await asyncio.to_thread(self._delete_attachment, owner, identifier)
async def provider_content(self, owner, identifiers, *, vision=False):
def build():
if not isinstance(identifiers, list) or len(identifiers) > self.limits.attachments_per_message or any(not isinstance(identifier, str) or not ID.fullmatch(identifier) for identifier in identifiers) or len(set(identifiers)) != len(identifiers):
raise HTTPException(422, 'Invalid attachment selection')
parts = []
with self.lock:
for identifier in identifiers:
row = self._get_attachment(owner, identifier)
if row['mime'].startswith('image/'):
if not vision:
raise HTTPException(409, 'Selected model is not qualified for image input')
_, data = self._read_attachment(owner, identifier)
parts.append({'type': 'image_url', 'image_url': {'url': 'data:' + row['mime'] + ';base64,' + base64.b64encode(data).decode('ascii')}})
elif row['extraction_status'] in ('ready', 'truncated'):
parts.append({'type': 'text', 'text': 'Attachment: ' + row['filename'] + '\n' + (row['extracted'] or '')})
else:
raise HTTPException(409, 'Attachment text extraction unavailable')
return parts
return await asyncio.to_thread(build)
@staticmethod
def _validate_artifact(title, kind, content, limit):
if not isinstance(title, str) or not 1 <= len(title.strip()) <= 160:
raise HTTPException(422, 'Invalid artifact title')
if kind not in ('text', 'markdown', 'code'):
raise HTTPException(422, 'Unsupported artifact kind')
if not isinstance(content, str) or len(content.encode('utf-8')) > limit:
raise HTTPException(413, 'Artifact byte limit exceeded')
def _get_artifact(self, owner, identifier, revision=None):
self._owner(owner)
row = self.db.execute('SELECT * FROM hope_artifacts WHERE owner=? AND id=? AND deleted_at IS NULL', (owner, identifier)).fetchone()
if row is None:
raise HTTPException(404, 'Artifact not found')
result = {k: row[k] for k in ('id', 'title', 'kind', 'revision', 'created_at', 'updated_at')}
version = self.db.execute('SELECT content FROM hope_artifact_versions WHERE artifact_id=? AND revision=?', (identifier, revision if revision is not None else row['revision'])).fetchone()
if version is None:
raise HTTPException(404, 'Artifact version not found')
result['content'] = version['content']
if revision is not None:
result['revision'] = revision
return result
def _save_artifact(self, owner, title, kind, content, identifier=None, revision=None):
self._validate_artifact(title, kind, content, self.limits.artifact_bytes)
now = time.time()
with self.lock, self.db:
self._owner(owner)
used = self.db.execute('SELECT COALESCE(SUM(length(CAST(v.content AS BLOB))),0) FROM hope_artifact_versions v JOIN hope_artifacts a ON a.id=v.artifact_id WHERE a.owner=?', (owner,)).fetchone()[0]
total = self.db.execute('SELECT COALESCE(SUM(length(CAST(content AS BLOB))),0) FROM hope_artifact_versions').fetchone()[0]
incoming = len(content.encode('utf-8'))
if used + incoming > self.limits.account_artifact_bytes or total + incoming > self.limits.global_artifact_bytes:
raise HTTPException(413, 'Artifact storage quota exceeded')
if identifier is None:
count = self.db.execute('SELECT COUNT(*) FROM hope_artifacts WHERE owner=?', (owner,)).fetchone()[0]
if count >= self.limits.artifact_count:
raise HTTPException(413, 'Artifact quota exceeded')
identifier = uuid.uuid4().hex
self.db.execute('INSERT INTO hope_artifacts VALUES(?,?,?,?,1,?,?,NULL)', (identifier, owner, title.strip(), kind, now, now))
version = 1
else:
old = self._get_artifact(owner, identifier)
if type(revision) is not int or revision != old['revision']:
raise HTTPException(409, 'Artifact revision conflict')
if revision >= self.limits.artifact_versions:
raise HTTPException(413, 'Artifact version quota exceeded')
version = revision + 1
self.db.execute('UPDATE hope_artifacts SET title=?,kind=?,revision=?,updated_at=? WHERE owner=? AND id=?',
(title.strip(), kind, version, now, owner, identifier))
self.db.execute('INSERT INTO hope_artifact_versions VALUES(?,?,?,?)', (identifier, version, content, now))
self._change(owner, 'artifact', identifier)
return self._get_artifact(owner, identifier)
async def save_artifact(self, owner, title, kind, content, identifier=None, revision=None):
return await asyncio.to_thread(self._save_artifact, owner, title, kind, content, identifier, revision)
async def get_artifact(self, owner, identifier, revision=None):
def get():
with self.lock:
return self._get_artifact(owner, identifier, revision)
return await asyncio.to_thread(get)
async def delete_artifact(self, owner, identifier):
def remove():
with self.lock, self.db:
self._get_artifact(owner, identifier)
self.db.execute('UPDATE hope_artifacts SET deleted_at=?,updated_at=? WHERE owner=? AND id=?', (time.time(), time.time(), owner, identifier))
self._change(owner, 'artifact', identifier, True)
return {'ok': True}
return await asyncio.to_thread(remove)
async def list_objects(self, owner, kind):
def collect():
with self.lock:
self._owner(owner)
if kind == 'attachments':
return [self._attachment(r) for r in self.db.execute('SELECT * FROM hope_attachments WHERE owner=? AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 200', (owner,))]
return [dict(row) for row in self.db.execute('SELECT id,title,kind,revision,created_at,updated_at FROM hope_artifacts WHERE owner=? AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT 200', (owner,))]
return await asyncio.to_thread(collect)
async def changes(self, owner, after=0):
def collect():
with self.lock:
self._owner(owner)
rows = self.db.execute('SELECT * FROM hope_feature_changes WHERE owner=? AND sequence>? ORDER BY sequence LIMIT 200', (owner, after)).fetchall()
return {'changes': [{'sequence': r['sequence'], 'kind': r['kind'], 'id': r['object_id'], 'deleted': bool(r['deleted']), 'created_at': r['created_at']} for r in rows], 'cursor': rows[-1]['sequence'] if rows else after}
return await asyncio.to_thread(collect)
class FeatureProvider:
"""Fixed-route private gateway adapter; no client-selected URL or tool loop."""
def __init__(self, accounts, http_client=None, state_dir=None):
import httpx
self.accounts = accounts
self.http = http_client or httpx.AsyncClient(timeout=30, follow_redirects=False)
self.owns_http = http_client is None
self.state_dir = Path(state_dir or os.getenv('HOPE_HERMES_DATA_DIR', '.runtime/hermes')).absolute()
async def aclose(self):
if self.owns_http:
await self.http.aclose()
async def _account(self, owner):
user = await asyncio.to_thread(self.accounts.get, owner)
if not user or user['desired'] == 'deleted':
raise HTTPException(401, 'Account unavailable')
if user['status'] != 'ready' or not user.get('hermes_url') or not user.get('hermes_api_key'):
raise HTTPException(503, 'Hermes not ready')
return user
async def toolset_status(self, owner):
user = await self._account(owner)
try:
response = await self.http.get(user['hermes_url'].rstrip('/') + '/v1/toolsets', headers={'Authorization': 'Bearer ' + user['hermes_api_key']})
if response.status_code != 200:
raise ValueError()
body = response.json()
if not isinstance(body, dict) or not isinstance(body.get('data'), list):
raise ValueError()
return [{'name': row['name'], 'enabled': row.get('enabled') is True, 'configured': row.get('configured') is True,
'tools': [tool for tool in row.get('tools', []) if isinstance(tool, str)][:200]}
for row in body['data'][:100] if isinstance(row, dict) and isinstance(row.get('name'), str) and isinstance(row.get('tools'), list)]
except Exception:
raise HTTPException(503, 'Hermes tool capability status unavailable') from None
async def research_status(self, owner):
toolsets = await self.toolset_status(owner)
available = any(row['name'] == 'web' and row['enabled'] and row['configured'] and
{'web_search', 'web_extract'} <= set(row['tools']) for row in toolsets)
return {'available': available, 'mode': 'hermes_tools' if available else None,
'reason': None if available else 'Hermes web search and extraction must be enabled and configured'}
async def research_instruction(self, owner):
status = await self.research_status(owner)
if not status['available']:
raise HTTPException(409, 'Research tools are unavailable for this account')
return ('Research mode requested by the user. Use your Hermes web search and extraction tools to investigate the question. '
'Prefer primary sources, corroborate material claims, distinguish evidence from uncertainty, and cite source URLs in the final answer. '
'Do not claim sources were checked if tools failed or access was unavailable. Complete this research in this same Hermes run.')
async def feature_capabilities(self, owner):
await self._account(owner)
settings = await asyncio.to_thread(self.accounts.get_model_settings, owner)
# Exact pinned jobs API and previously qualified managed auto vision only.
def memory_available():
try:
self._memory_file(owner, 'memory')
self._memory_file(owner, 'user')
return True
except HTTPException:
return False
try:
research = (await self.research_status(owner))['available']
except HTTPException:
research = False
return {'scheduled_tasks': True, 'memory_controls': await asyncio.to_thread(memory_available),
'image_input': settings['model'] == 'auto', 'connectors': False, 'research': research}
async def feature_request(self, owner, method, path, *, json=None, params=None):
user = await self._account(owner)
permitted = ((path == '/api/jobs' and method in ('GET', 'POST')) or
(re.fullmatch(r'/api/jobs/[a-f0-9]{12}', path) and method in ('PATCH', 'DELETE')) or
(re.fullmatch(r'/api/jobs/[a-f0-9]{12}/(?:pause|resume|run)', path) and method == 'POST'))
if not permitted:
raise HTTPException(501, 'Unqualified provider operation')
try:
response = await self.http.request(method, user['hermes_url'].rstrip('/') + path,
headers={'Authorization': 'Bearer ' + user['hermes_api_key']}, json=json, params=params)
if response.status_code >= 400:
# Remote detail can include config, paths or credentials.
status = response.status_code if response.status_code in (400, 404, 409, 422, 429, 503) else 502
raise HTTPException(status, 'Hermes task operation rejected')
return response.json()
except HTTPException:
raise
except Exception:
raise HTTPException(502, 'Hermes task operation failed') from None
def _memory_file(self, owner, section):
if not ID.fullmatch(owner) or section not in ('memory', 'user'):
raise HTTPException(404, 'Memory section not found')
# Fixed layout shared with Provisioner._paths; never accept caller paths.
components = (self.state_dir, self.state_dir / owner, self.state_dir / owner / 'home',
self.state_dir / owner / 'home' / 'memories')
for component in components:
if component.is_symlink():
raise HTTPException(409, 'Unsafe memory storage')
directory = components[-1]
if not directory.is_dir():
raise HTTPException(503, 'Hermes memory storage unavailable')
path = directory / ('MEMORY.md' if section == 'memory' else 'USER.md')
if path.is_symlink():
raise HTTPException(409, 'Unsafe memory storage')
return path
def _memory_open(self, owner, section, flags):
self._memory_file(owner, section)
directory = os.open(self.state_dir, os.O_RDONLY | os.O_DIRECTORY | getattr(os, 'O_NOFOLLOW', 0))
try:
for name in (owner, 'home', 'memories'):
next_directory = os.open(name, os.O_RDONLY | os.O_DIRECTORY | getattr(os, 'O_NOFOLLOW', 0), dir_fd=directory)
os.close(directory)
directory = next_directory
fd = os.open('MEMORY.md' if section == 'memory' else 'USER.md', flags | getattr(os, 'O_NOFOLLOW', 0) | os.O_NONBLOCK, 0o600, dir_fd=directory)
file_info = os.fstat(fd)
if not stat.S_ISREG(file_info.st_mode) or file_info.st_nlink != 1:
os.close(fd)
raise HTTPException(409, 'Unsafe memory storage')
if flags & os.O_CREAT and os.geteuid() == 0:
info = os.fstat(directory)
os.fchown(fd, info.st_uid, info.st_gid)
return fd
finally:
os.close(directory)
def _read_memory_section(self, owner, section):
path = self._memory_file(owner, section)
try:
fd = self._memory_open(owner, section, os.O_RDONLY)
except FileNotFoundError:
data = b''
else:
with os.fdopen(fd, 'rb') as stream:
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
raise HTTPException(409, 'Unsafe memory storage')
data = stream.read(65537)
if len(data) > 65536:
raise HTTPException(413, 'Memory section too large')
try:
content = data.decode('utf-8')
except UnicodeDecodeError:
raise HTTPException(409, 'Invalid memory encoding') from None
return {'content': content, 'revision': hashlib.sha256(data).hexdigest()}
async def read_memory(self, owner):
await self._account(owner)
def read():
with self.accounts.lock:
return {section: self._read_memory_section(owner, section) for section in ('memory', 'user')}
return await asyncio.to_thread(read)
async def write_memory(self, owner, section, content, revision):
await self._account(owner)
def write():
with self.accounts.lock:
current = self.accounts.get(owner)
if not current or current['desired'] == 'deleted':
raise HTTPException(401, 'Account unavailable')
old = self._read_memory_section(owner, section)
if old['revision'] != revision:
raise HTTPException(409, 'Memory revision conflict')
path = self._memory_file(owner, section)
data = content.encode('utf-8')
if len(data) > 65536:
raise HTTPException(413, 'Memory section too large')
# Reopen without following symlinks and compare under the same
# descriptor before in-place writing. Preserve runtime ownership.
fd = self._memory_open(owner, section, os.O_RDWR | os.O_CREAT)
with os.fdopen(fd, 'r+b') as stream:
info = os.fstat(stream.fileno())
if not stat.S_ISREG(info.st_mode) or hashlib.sha256(stream.read(65537)).hexdigest() != revision:
raise HTTPException(409, 'Memory revision conflict')
stream.seek(0)
stream.write(data)
stream.truncate()
stream.flush()
os.fsync(stream.fileno())
return {'revision': hashlib.sha256(data).hexdigest()}
return await asyncio.to_thread(write)
async def feature_json(request, limit):
if request.headers.get('content-type', '').split(';')[0].lower() != 'application/json':
raise HTTPException(415, 'JSON required')
data = bytearray()
async for chunk in request.stream():
if len(data) + len(chunk) > limit:
raise HTTPException(413, 'Request body too large')
data.extend(chunk)
try:
result = json.loads(data)
except (ValueError, UnicodeDecodeError):
raise HTTPException(400, 'Invalid JSON') from None
if not isinstance(result, dict):
raise HTTPException(422, 'JSON object required')
return result
def create_feature_router(store, provider=None):
router = APIRouter()
@router.post('/api/attachments', status_code=201)
async def upload(request: Request, user=Depends(current_user)):
data = bytearray()
async for chunk in request.stream():
if len(data) + len(chunk) > store.limits.upload_bytes:
raise HTTPException(413, 'Upload byte limit exceeded')
data.extend(chunk)
user = await current_user(request) # Revalidate after potentially slow body upload.
attachment = await store.upload(user['id'], request.headers.get('x-filename', 'attachment'), request.headers.get('content-type', '').split(';')[0].strip().lower(), bytes(data))
return {'attachment': attachment}
@router.get('/api/attachments')
async def attachments(user=Depends(current_user)):
return {'attachments': await store.list_objects(user['id'], 'attachments'), 'next_cursor': None}
@router.get('/api/attachments/{identifier}')
async def attachment(identifier: str, user=Depends(current_user)):
metadata, _ = await store.read_attachment(user['id'], identifier)
return {'attachment': metadata}
@router.get('/api/attachments/{identifier}/content')
async def download(identifier: str, user=Depends(current_user)):
metadata, data = await store.read_attachment(user['id'], identifier)
return Response(data, media_type=metadata['mime'], headers={'Content-Disposition': "attachment; filename*=UTF-8''" + quote(metadata['filename'], safe=''), 'X-Content-Type-Options': 'nosniff', 'Cache-Control': 'no-store'})
@router.delete('/api/attachments/{identifier}')
async def delete_attachment(identifier: str, user=Depends(current_user)):
return await store.delete_attachment(user['id'], identifier)
@router.get('/api/artifacts')
async def artifacts(user=Depends(current_user)):
return {'artifacts': await store.list_objects(user['id'], 'artifacts'), 'next_cursor': None}
@router.post('/api/artifacts', status_code=201)
async def create_artifact(request: Request, user=Depends(current_user)):
body = await feature_json(request, store.limits.artifact_bytes + 8192)
if set(body) - {'title', 'kind', 'content'}:
raise HTTPException(422, 'Unknown artifact fields')
user = await current_user(request)
return {'artifact': await store.save_artifact(user['id'], body.get('title'), body.get('kind', 'markdown'), body.get('content', ''))}
@router.get('/api/artifacts/{identifier}')
async def artifact(identifier: str, revision: int | None = None, user=Depends(current_user)):
return {'artifact': await store.get_artifact(user['id'], identifier, revision)}
@router.patch('/api/artifacts/{identifier}')
async def edit_artifact(identifier: str, request: Request, user=Depends(current_user)):
body = await feature_json(request, store.limits.artifact_bytes + 8192)
if set(body) - {'revision', 'title', 'kind', 'content'}:
raise HTTPException(422, 'Unknown artifact fields')
user = await current_user(request)
old = await store.get_artifact(user['id'], identifier)
return {'artifact': await store.save_artifact(user['id'], body.get('title', old['title']), body.get('kind', old['kind']), body.get('content', old['content']), identifier, body.get('revision'))}
@router.get('/api/artifacts/{identifier}/versions')
async def artifact_versions(identifier: str, user=Depends(current_user)):
def collect():
with store.lock:
store._get_artifact(user['id'], identifier)
return [dict(row) for row in store.db.execute('SELECT revision,created_at FROM hope_artifact_versions WHERE artifact_id=? ORDER BY revision DESC', (identifier,))]
return {'versions': await asyncio.to_thread(collect), 'next_cursor': None}
@router.get('/api/artifacts/{identifier}/export')
async def export_artifact(identifier: str, revision: int | None = None, user=Depends(current_user)):
item = await store.get_artifact(user['id'], identifier, revision)
suffix = '.md' if item['kind'] == 'markdown' else '.txt'
return Response(item['content'].encode('utf-8'), media_type='text/plain', headers={'Content-Disposition': 'attachment; filename="artifact-' + item['id'] + suffix + '"', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'})
@router.delete('/api/artifacts/{identifier}')
async def delete_artifact(identifier: str, user=Depends(current_user)):
return await store.delete_artifact(user['id'], identifier)
@router.get('/api/features/sync')
async def sync(after: int = 0, user=Depends(current_user)):
if after < 0:
raise HTTPException(422, 'Invalid cursor')
return await store.changes(user['id'], after)
async def qualified(owner, capability):
if provider is None or not hasattr(provider, 'feature_capabilities'):
raise HTTPException(501, 'Operation is not qualified for this account')
try:
capabilities = await provider.feature_capabilities(owner)
except Exception:
raise HTTPException(503, 'Hermes capabilities unavailable') from None
if not isinstance(capabilities, dict) or capabilities.get(capability) is not True:
raise HTTPException(501, 'Operation is not qualified for this account')
async def job_request(owner, method, suffix='', body=None, params=None):
await qualified(owner, 'scheduled_tasks')
try:
return await provider.feature_request(owner, method, '/api/jobs' + suffix, json=body, params=params)
except HTTPException:
raise
except Exception:
raise HTTPException(502, 'Hermes task operation failed') from None
def task(job):
if not isinstance(job, dict) or not isinstance(job.get('id'), str) or not re.fullmatch(r'[a-f0-9]{12}', job['id']):
raise HTTPException(502, 'Invalid Hermes task response')
# Raw job rows contain upstream credentials/configuration. Never return them.
keys = ('id', 'name', 'schedule', 'prompt', 'enabled', 'repeat', 'created_at', 'last_run_at', 'next_run_at')
return {key: job[key] for key in keys if key in job}
def task_id(identifier):
if not re.fullmatch(r'[a-f0-9]{12}', identifier):
raise HTTPException(404, 'Task not found')
return identifier
async def task_body(request, *, patch=False):
body = await feature_json(request, 32768)
allowed = {'name', 'schedule', 'prompt', 'repeat', 'enabled'} if patch else {'name', 'schedule', 'prompt', 'repeat', 'paused'}
if set(body) - allowed or not body:
raise HTTPException(422, 'Invalid task fields')
for key, maximum in (('name', 200), ('schedule', 200), ('prompt', 5000)):
if key in body and (not isinstance(body[key], str) or not 1 <= len(body[key].strip()) <= maximum):
raise HTTPException(422, 'Invalid task ' + key)
if not patch and key not in body:
raise HTTPException(422, 'Task ' + key + ' required')
if 'repeat' in body and (type(body['repeat']) is not int or body['repeat'] <= 0):
raise HTTPException(422, 'Invalid task repeat')
for key in ('enabled', 'paused'):
if key in body and type(body[key]) is not bool:
raise HTTPException(422, 'Invalid task ' + key)
body['deliver'] = 'local'
return body
@router.get('/api/tasks')
async def tasks(user=Depends(current_user)):
result = await job_request(user['id'], 'GET', params={'include_disabled': 'true'})
if not isinstance(result, dict) or not isinstance(result.get('jobs'), list):
raise HTTPException(502, 'Invalid Hermes task response')
return {'tasks': [task(job) for job in result['jobs']], 'next_cursor': None}
@router.post('/api/tasks', status_code=201)
async def create_task(request: Request, user=Depends(current_user)):
body = await task_body(request)
user = await current_user(request)
result = await job_request(user['id'], 'POST', body=body)
return {'task': task(result.get('job') if isinstance(result, dict) else None)}
@router.patch('/api/tasks/{identifier}')
async def update_task(identifier: str, request: Request, user=Depends(current_user)):
body = await task_body(request, patch=True)
user = await current_user(request)
result = await job_request(user['id'], 'PATCH', '/' + task_id(identifier), body)
return {'task': task(result.get('job') if isinstance(result, dict) else None)}
@router.delete('/api/tasks/{identifier}')
async def delete_task(identifier: str, user=Depends(current_user)):
result = await job_request(user['id'], 'DELETE', '/' + task_id(identifier))
if not isinstance(result, dict) or result.get('ok') is not True:
raise HTTPException(502, 'Invalid Hermes task response')
return {'ok': True}
@router.post('/api/tasks/{identifier}/{action}')
async def task_action(identifier: str, action: str, user=Depends(current_user)):
if action not in ('pause', 'resume', 'run'):
raise HTTPException(404, 'Task action not found')
result = await job_request(user['id'], 'POST', '/' + task_id(identifier) + '/' + action)
return {'task': task(result.get('job') if isinstance(result, dict) else None)}
@router.get('/api/memory')
async def memory(user=Depends(current_user)):
await qualified(user['id'], 'memory_controls')
try:
result = await provider.read_memory(user['id'])
except HTTPException:
raise
except Exception:
raise HTTPException(502, 'Memory unavailable') from None
# Dedicated trusted adapter returns only fixed sections, never paths/config.
if not isinstance(result, dict):
raise HTTPException(502, 'Invalid memory response')
sections = []
for section in ('memory', 'user'):
value = result.get(section)
if not isinstance(value, dict) or not isinstance(value.get('content'), str) or not isinstance(value.get('revision'), str):
raise HTTPException(502, 'Invalid memory response')
sections.append({'section': section, 'content': value['content'], 'revision': value['revision']})
return {'sections': sections}
@router.put('/api/memory/{section}')
async def update_memory(section: str, request: Request, user=Depends(current_user)):
if section not in ('memory', 'user'):
raise HTTPException(404, 'Memory section not found')
await qualified(user['id'], 'memory_controls')
body = await feature_json(request, 131072)
if set(body) != {'content', 'revision'} or not isinstance(body['content'], str) or len(body['content'].encode('utf-8')) > 65536 or not isinstance(body['revision'], str) or len(body['revision']) > 128:
raise HTTPException(422, 'Invalid memory update')
user = await current_user(request)
try:
result = await provider.write_memory(user['id'], section, body['content'], body['revision'])
except HTTPException:
raise
except Exception:
raise HTTPException(502, 'Memory update failed') from None
if not isinstance(result, dict) or not isinstance(result.get('revision'), str):
raise HTTPException(502, 'Invalid memory response')
return {'section': section, 'content': body['content'], 'revision': result['revision']}
@router.get('/api/connectors')
async def connectors(user=Depends(current_user)):
# The pinned gateway has no connector management API. Do not invent one.
statuses = []
if provider is not None and hasattr(provider, 'toolset_status'):
try:
statuses = await provider.toolset_status(user['id'])
except HTTPException:
pass
return {'connectors': [], 'available': False, 'reason': 'Pinned Hermes has no connector credential/settings write API',
'toolsets': [{'name': row['name'], 'enabled': row['enabled'], 'configured': row['configured']} for row in statuses]}
@router.get('/api/research')
async def research(user=Depends(current_user)):
# Research remains normal Hermes execution, never a Hope reasoning loop.
if provider is not None and hasattr(provider, 'research_status'):
try:
return await provider.research_status(user['id'])
except HTTPException:
pass
return {'available': False, 'mode': None, 'reason': 'Hermes research tool availability could not be qualified'}
return router
# Core merges these into its single /api/capabilities response.
def local_capabilities(store):
return {'attachments': True, 'artifacts': True, 'pdf_extraction': importlib.util.find_spec('pypdf') is not None,
'attachment_mimes': list(MIMES), 'upload_bytes': store.limits.upload_bytes,
'account_attachment_bytes': store.limits.account_bytes, 'artifact_bytes': store.limits.artifact_bytes,
'image_input': False, 'scheduled_tasks': False, 'memory_controls': False,
'connectors': False, 'research': False}