mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-19 15:18:03 +00:00
Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui
# Conflicts: # tui_gateway/server.py
This commit is contained in:
commit
c83cd38391
157 changed files with 10059 additions and 831 deletions
|
|
@ -119,17 +119,20 @@ class BrowserUseBrowserProvider(BrowserProvider):
|
|||
return "Browser Use"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return self._get_config_or_none() is not None
|
||||
return self._get_config_or_none(refresh_token=False) is not None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Config resolution (direct API key OR managed Nous gateway)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_config_or_none(self) -> Optional[Dict[str, Any]]:
|
||||
def _get_config_or_none(self, *, refresh_token: bool = True) -> Optional[Dict[str, Any]]:
|
||||
# Import here to avoid a hard dependency at module-import time —
|
||||
# managed_tool_gateway pulls in the Nous auth stack which can be
|
||||
# heavy and is not needed for direct-API-key users.
|
||||
from tools.managed_tool_gateway import resolve_managed_tool_gateway
|
||||
from tools.managed_tool_gateway import (
|
||||
peek_nous_access_token,
|
||||
resolve_managed_tool_gateway,
|
||||
)
|
||||
from tools.tool_backend_helpers import prefers_gateway
|
||||
|
||||
# Direct API key wins unless the user has explicitly opted into the
|
||||
|
|
@ -142,7 +145,11 @@ class BrowserUseBrowserProvider(BrowserProvider):
|
|||
"managed_mode": False,
|
||||
}
|
||||
|
||||
managed = resolve_managed_tool_gateway("browser-use")
|
||||
# Keep availability scans off the synchronous OAuth refresh path.
|
||||
managed = resolve_managed_tool_gateway(
|
||||
"browser-use",
|
||||
token_reader=None if refresh_token else peek_nous_access_token,
|
||||
)
|
||||
if managed is None:
|
||||
return None
|
||||
|
||||
|
|
|
|||
165
plugins/kanban/dashboard/dist/index.js
vendored
165
plugins/kanban/dashboard/dist/index.js
vendored
|
|
@ -2741,6 +2741,8 @@
|
|||
// Ready/Block/Complete buttons feel like no-ops. See #26744.
|
||||
const [patchErr, setPatchErr] = useState(null);
|
||||
const [newComment, setNewComment] = useState("");
|
||||
const [uploadBusy, setUploadBusy] = useState(false);
|
||||
const [uploadErr, setUploadErr] = useState(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
// Home-channel notification toggles. homeChannels is the list of platforms
|
||||
// the user has a /sethome on; each entry has a `subscribed` bool telling
|
||||
|
|
@ -2789,6 +2791,49 @@
|
|||
}).catch(function (e) { setErr(String(e.message || e)); });
|
||||
};
|
||||
|
||||
// File upload uses raw fetch (not SDK.fetchJSON, which JSON-encodes)
|
||||
// so the browser sets the multipart boundary. Auth rides the session
|
||||
// cookie + bearer token, matching the rest of the dashboard.
|
||||
const handleUpload = function (fileList) {
|
||||
const files = Array.prototype.slice.call(fileList || []);
|
||||
if (!files.length) return;
|
||||
setUploadBusy(true);
|
||||
setUploadErr(null);
|
||||
const token = window.__HERMES_SESSION_TOKEN__ || "";
|
||||
const headers = token ? { Authorization: "Bearer " + token } : {};
|
||||
const url = withBoard(`${API}/tasks/${encodeURIComponent(props.taskId)}/attachments`, boardSlug);
|
||||
// Upload sequentially so a partial failure leaves a clear state.
|
||||
let chain = Promise.resolve();
|
||||
files.forEach(function (f) {
|
||||
chain = chain.then(function () {
|
||||
const fd = new FormData();
|
||||
fd.append("file", f, f.name);
|
||||
return fetch(url, { method: "POST", headers: headers, credentials: "same-origin", body: fd })
|
||||
.then(function (resp) {
|
||||
if (!resp.ok) {
|
||||
return resp.text().then(function (txt) {
|
||||
throw new Error(parseApiErrorMessage(new Error(resp.status + ": " + txt)));
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
chain.then(function () {
|
||||
load();
|
||||
props.onRefresh();
|
||||
}).catch(function (e) {
|
||||
setUploadErr(String(e.message || e));
|
||||
}).finally(function () {
|
||||
setUploadBusy(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteAttachment = function (attachmentId) {
|
||||
return SDK.fetchJSON(withBoard(`${API}/attachments/${attachmentId}`, boardSlug), { method: "DELETE" })
|
||||
.then(function () { load(); props.onRefresh(); })
|
||||
.catch(function (e) { setUploadErr(String(e.message || e)); });
|
||||
};
|
||||
|
||||
const doPatch = function (patch, opts) {
|
||||
if (opts && opts.confirm && !window.confirm(opts.confirm)) {
|
||||
return Promise.resolve();
|
||||
|
|
@ -2946,6 +2991,10 @@
|
|||
homeBusy: homeBusy,
|
||||
onToggleHomeSub: toggleHomeSubscription,
|
||||
onRefresh: props.onRefresh,
|
||||
onUpload: handleUpload,
|
||||
onDeleteAttachment: handleDeleteAttachment,
|
||||
uploadBusy: uploadBusy,
|
||||
uploadErr: uploadErr,
|
||||
}) : null,
|
||||
data ? h("div", { className: "hermes-kanban-drawer-comment-row" },
|
||||
h(Input, {
|
||||
|
|
@ -2968,11 +3017,118 @@
|
|||
);
|
||||
}
|
||||
|
||||
function _fmtBytes(n) {
|
||||
n = Number(n) || 0;
|
||||
if (n < 1024) return n + " B";
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
|
||||
return (n / (1024 * 1024)).toFixed(1) + " MB";
|
||||
}
|
||||
|
||||
// Attachments section in the task drawer (#35338). Upload button +
|
||||
// list with download links and a delete (×) per row. The download
|
||||
// link hits GET /attachments/:id which streams the file; the worker
|
||||
// context surfaces the same files' absolute paths so a kanban worker
|
||||
// can read them with the file/terminal tools.
|
||||
function AttachmentsSection(props) {
|
||||
const i18n = props.i18n;
|
||||
const atts = props.attachments || [];
|
||||
const fileRef = useRef(null);
|
||||
const [dlErr, setDlErr] = useState(null);
|
||||
// Download via authenticated fetch → blob → synthetic anchor click.
|
||||
// A plain <a href> can't carry the session header/bearer the dashboard
|
||||
// auth middleware requires in loopback mode, so fetch with the token
|
||||
// and hand the browser a blob URL instead.
|
||||
function downloadAttachment(a) {
|
||||
const token = window.__HERMES_SESSION_TOKEN__ || "";
|
||||
const headers = token ? { Authorization: "Bearer " + token } : {};
|
||||
const url = withBoard(`${API}/attachments/${a.id}`, props.boardSlug);
|
||||
setDlErr(null);
|
||||
fetch(url, { headers: headers, credentials: "same-origin" })
|
||||
.then(function (resp) {
|
||||
if (!resp.ok) {
|
||||
return resp.text().then(function (txt) {
|
||||
throw new Error(parseApiErrorMessage(new Error(resp.status + ": " + txt)));
|
||||
});
|
||||
}
|
||||
return resp.blob();
|
||||
})
|
||||
.then(function (blob) {
|
||||
const objUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = objUrl;
|
||||
link.download = a.filename || "attachment";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
setTimeout(function () { URL.revokeObjectURL(objUrl); }, 10000);
|
||||
})
|
||||
.catch(function (e) { setDlErr(String(e.message || e)); });
|
||||
}
|
||||
return h("div", { className: "hermes-kanban-section" },
|
||||
h("div", { className: "hermes-kanban-section-head" },
|
||||
`${tx(i18n, "attachments", "Attachments")} (${atts.length})`),
|
||||
h("input", {
|
||||
ref: fileRef,
|
||||
type: "file",
|
||||
multiple: true,
|
||||
style: { display: "none" },
|
||||
onChange: function (e) {
|
||||
if (props.onUpload) props.onUpload(e.target.files);
|
||||
// Reset so selecting the same file again re-triggers onChange.
|
||||
try { e.target.value = ""; } catch (_e) { /* ignore */ }
|
||||
},
|
||||
}),
|
||||
h("div", { className: "flex items-center gap-2 mb-2" },
|
||||
h(Button, {
|
||||
size: "sm",
|
||||
variant: "outline",
|
||||
disabled: !!props.uploadBusy,
|
||||
onClick: function () { if (fileRef.current) fileRef.current.click(); },
|
||||
}, props.uploadBusy
|
||||
? tx(i18n, "uploading", "Uploading…")
|
||||
: tx(i18n, "uploadFile", "Upload file")),
|
||||
),
|
||||
(props.uploadErr || dlErr)
|
||||
? h("div", { className: "text-xs text-destructive mb-2" }, props.uploadErr || dlErr)
|
||||
: null,
|
||||
atts.length === 0
|
||||
? h("div", { className: "text-xs text-muted-foreground" },
|
||||
tx(i18n, "noAttachments", "— no attachments —"))
|
||||
: atts.map(function (a) {
|
||||
return h("div", {
|
||||
key: a.id,
|
||||
className: "flex items-center justify-between gap-2 py-1 text-sm",
|
||||
},
|
||||
h("button", {
|
||||
type: "button",
|
||||
className: "hermes-kanban-attachment-link truncate",
|
||||
title: a.filename,
|
||||
onClick: function () { downloadAttachment(a); },
|
||||
}, a.filename),
|
||||
h("span", { className: "text-xs text-muted-foreground whitespace-nowrap" },
|
||||
_fmtBytes(a.size)),
|
||||
h("button", {
|
||||
type: "button",
|
||||
className: "hermes-kanban-drawer-close",
|
||||
title: tx(i18n, "removeAttachment", "Remove attachment"),
|
||||
onClick: function () {
|
||||
if (window.confirm(tx(i18n, "confirmRemoveAttachment",
|
||||
"Remove this attachment?"))) {
|
||||
if (props.onDelete) props.onDelete(a.id);
|
||||
}
|
||||
},
|
||||
}, "×"),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function TaskDetail(props) {
|
||||
const { t: i18n } = useI18n();
|
||||
const t = props.data.task;
|
||||
const comments = props.data.comments || [];
|
||||
const events = props.data.events || [];
|
||||
const attachments = props.data.attachments || [];
|
||||
const links = props.data.links || { parents: [], children: [] };
|
||||
|
||||
return h("div", { className: "hermes-kanban-drawer-body" },
|
||||
|
|
@ -3042,6 +3198,15 @@
|
|||
h("div", { className: "hermes-kanban-section-head" }, tx(i18n, "result", "Result")),
|
||||
h(MarkdownBlock, { source: t.result, enabled: props.renderMarkdown }),
|
||||
) : null,
|
||||
h(AttachmentsSection, {
|
||||
attachments: attachments,
|
||||
boardSlug: props.boardSlug,
|
||||
onUpload: props.onUpload,
|
||||
onDelete: props.onDeleteAttachment,
|
||||
uploadBusy: props.uploadBusy,
|
||||
uploadErr: props.uploadErr,
|
||||
i18n: i18n,
|
||||
}),
|
||||
h("div", { className: "hermes-kanban-section" },
|
||||
h("div", { className: "hermes-kanban-section-head" },
|
||||
`${tx(i18n, "comments", "Comments")} (${comments.length})`),
|
||||
|
|
|
|||
44
plugins/kanban/dashboard/dist/style.css
vendored
44
plugins/kanban/dashboard/dist/style.css
vendored
|
|
@ -334,6 +334,11 @@
|
|||
.hermes-kanban-drawer {
|
||||
width: min(var(--hermes-kanban-drawer-width, 640px), 92vw);
|
||||
height: 100vh;
|
||||
/* Dynamic viewport unit excludes the mobile browser's collapsing chrome
|
||||
(URL/nav bars) so the drawer's bottom row stays reachable. Falls back to
|
||||
100vh on browsers without dvh support. */
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
background: var(--color-card);
|
||||
border-left: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
|
|
@ -352,10 +357,23 @@
|
|||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.6rem 0.8rem;
|
||||
/* Honor the top safe-area inset (notch) so the task id / close button are
|
||||
not clipped on mobile. */
|
||||
padding-top: max(0.6rem, env(safe-area-inset-top));
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
}
|
||||
|
||||
/* On mobile the dashboard shell renders a fixed top bar (min-h-14, hidden at
|
||||
the lg breakpoint). The drawer is a body-level z-60 overlay starting at the
|
||||
viewport top, so its header would sit behind that bar. Push the header down
|
||||
by the bar height (3.5rem) plus the top safe-area inset. */
|
||||
@media (max-width: 1023px) {
|
||||
.hermes-kanban-drawer-head {
|
||||
padding-top: calc(3.5rem + env(safe-area-inset-top));
|
||||
}
|
||||
}
|
||||
|
||||
.hermes-kanban-drawer-close {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
|
|
@ -368,10 +386,33 @@
|
|||
}
|
||||
.hermes-kanban-drawer-close:hover { color: var(--color-foreground); }
|
||||
|
||||
/* Attachment download trigger — styled as a link, rendered as a <button>
|
||||
so the click handler can fetch with the session token (#35338). */
|
||||
.hermes-kanban-attachment-link {
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
color: var(--color-primary, #6ea8fe);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
.hermes-kanban-attachment-link:hover { text-decoration: underline; }
|
||||
|
||||
.hermes-kanban-drawer-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.9rem;
|
||||
/* When no comment row is rendered (loading / error states), the scrolling
|
||||
body is the bottom-most element — extend its bottom padding past the
|
||||
mobile browser chrome so the last content stays readable. */
|
||||
padding-bottom: max(0.9rem, calc(0.9rem + env(safe-area-inset-bottom)));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
|
|
@ -530,6 +571,9 @@
|
|||
display: flex;
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
/* Keep the comment input clear of the mobile browser nav bar / home
|
||||
indicator by extending the bottom padding with the safe-area inset. */
|
||||
padding-bottom: max(0.55rem, calc(0.55rem + env(safe-area-inset-bottom)));
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: color-mix(in srgb, var(--color-card) 90%, transparent);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,9 +43,11 @@ import os
|
|||
import sqlite3
|
||||
import time
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, WebSocket, WebSocketDisconnect, status as http_status
|
||||
from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile, WebSocket, WebSocketDisconnect, status as http_status
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from hermes_cli import kanban_db
|
||||
|
|
@ -186,6 +188,21 @@ def _comment_dict(c: kanban_db.Comment) -> dict[str, Any]:
|
|||
}
|
||||
|
||||
|
||||
def _attachment_dict(a: kanban_db.Attachment) -> dict[str, Any]:
|
||||
"""Serialise an Attachment for the drawer. ``stored_path`` is the
|
||||
absolute on-disk path workers read; the UI uses ``id`` for download."""
|
||||
return {
|
||||
"id": a.id,
|
||||
"task_id": a.task_id,
|
||||
"filename": a.filename,
|
||||
"content_type": a.content_type,
|
||||
"size": a.size,
|
||||
"uploaded_by": a.uploaded_by,
|
||||
"stored_path": a.stored_path,
|
||||
"created_at": a.created_at,
|
||||
}
|
||||
|
||||
|
||||
def _run_dict(r: kanban_db.Run) -> dict[str, Any]:
|
||||
"""Serialise a Run for the drawer's Run history section."""
|
||||
return {
|
||||
|
|
@ -531,6 +548,7 @@ def get_task(
|
|||
"task": task_d,
|
||||
"comments": [_comment_dict(c) for c in kanban_db.list_comments(conn, task_id)],
|
||||
"events": [_event_dict(e) for e in kanban_db.list_events(conn, task_id)],
|
||||
"attachments": [_attachment_dict(a) for a in kanban_db.list_attachments(conn, task_id)],
|
||||
"links": _links_for(conn, task_id),
|
||||
"runs": [
|
||||
_run_dict(r)
|
||||
|
|
@ -609,6 +627,165 @@ def create_task(payload: CreateTaskBody, board: Optional[str] = Query(None)):
|
|||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Attachments — upload / list / download / delete (#35338)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Cap a single upload so a runaway request can't fill the disk. 25 MB
|
||||
# comfortably covers PDFs, images, and source docs — the kanban use case.
|
||||
_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024
|
||||
|
||||
|
||||
def _safe_attachment_name(raw: str) -> str:
|
||||
"""Reduce a client-supplied filename to a safe basename.
|
||||
|
||||
Strips any directory components (``os.path.basename`` on both
|
||||
separators) so a malicious ``../../etc/passwd`` or ``C:\\x`` collapses
|
||||
to its leaf. Rejects empty / dotfile-only names. The result is only
|
||||
ever joined under the per-task attachments dir, never used verbatim
|
||||
as a path from the client.
|
||||
"""
|
||||
name = (raw or "").replace("\\", "/").split("/")[-1].strip()
|
||||
# Drop control chars and leading dots so we never write a dotfile or
|
||||
# a name with embedded NULs/newlines.
|
||||
name = "".join(ch for ch in name if ch.isprintable() and ch not in '\x00').strip()
|
||||
name = name.lstrip(".").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="invalid attachment filename")
|
||||
return name[:200]
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}/attachments")
|
||||
def list_task_attachments(task_id: str, board: Optional[str] = Query(None)):
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
if kanban_db.get_task(conn, task_id) is None:
|
||||
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
|
||||
return {
|
||||
"attachments": [
|
||||
_attachment_dict(a) for a in kanban_db.list_attachments(conn, task_id)
|
||||
]
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/attachments")
|
||||
async def upload_task_attachment(
|
||||
task_id: str,
|
||||
file: UploadFile = File(...),
|
||||
board: Optional[str] = Query(None),
|
||||
uploaded_by: Optional[str] = Form(None),
|
||||
):
|
||||
"""Store an uploaded file for a task and record its metadata.
|
||||
|
||||
The blob lands under ``attachments_root(board)/<task_id>/`` with a
|
||||
sanitised, collision-resolved name. The worker reads it via the
|
||||
absolute path surfaced in ``build_worker_context``.
|
||||
"""
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
if kanban_db.get_task(conn, task_id) is None:
|
||||
raise HTTPException(status_code=404, detail=f"task {task_id} not found")
|
||||
|
||||
safe_name = _safe_attachment_name(file.filename or "")
|
||||
|
||||
# Stream to disk with a hard size cap so a huge upload can't fill
|
||||
# the disk. Read in chunks; abort + clean up if the cap is hit.
|
||||
dest_dir = kanban_db.task_attachments_dir(task_id, board=board)
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Resolve name collisions: foo.pdf → foo (1).pdf, foo (2).pdf, …
|
||||
stem, dot, ext = safe_name.partition(".")
|
||||
candidate = safe_name
|
||||
n = 1
|
||||
while (dest_dir / candidate).exists():
|
||||
candidate = f"{stem} ({n}){dot}{ext}"
|
||||
n += 1
|
||||
dest_path = dest_dir / candidate
|
||||
|
||||
total = 0
|
||||
try:
|
||||
with open(dest_path, "wb") as out:
|
||||
while True:
|
||||
chunk = await file.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > _MAX_ATTACHMENT_BYTES:
|
||||
out.close()
|
||||
dest_path.unlink(missing_ok=True)
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=(
|
||||
f"attachment exceeds {_MAX_ATTACHMENT_BYTES // (1024 * 1024)} MB limit"
|
||||
),
|
||||
)
|
||||
out.write(chunk)
|
||||
except HTTPException:
|
||||
raise
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"failed to store attachment: {exc}")
|
||||
|
||||
att_id = kanban_db.add_attachment(
|
||||
conn,
|
||||
task_id,
|
||||
filename=candidate,
|
||||
stored_path=str(dest_path.resolve()),
|
||||
content_type=file.content_type,
|
||||
size=total,
|
||||
uploaded_by=(uploaded_by or "dashboard"),
|
||||
)
|
||||
att = kanban_db.get_attachment(conn, att_id)
|
||||
return {"attachment": _attachment_dict(att) if att else None}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/attachments/{attachment_id}")
|
||||
def download_attachment(attachment_id: int, board: Optional[str] = Query(None)):
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
att = kanban_db.get_attachment(conn, attachment_id)
|
||||
if att is None:
|
||||
raise HTTPException(status_code=404, detail="attachment not found")
|
||||
# Confirm the blob still lives under the board's attachments root
|
||||
# before serving — defense in depth against a tampered DB row.
|
||||
root = kanban_db.attachments_root(board=board).resolve()
|
||||
try:
|
||||
stored = Path(att.stored_path).resolve()
|
||||
stored.relative_to(root)
|
||||
except (ValueError, OSError):
|
||||
raise HTTPException(status_code=404, detail="attachment file unavailable")
|
||||
if not stored.is_file():
|
||||
raise HTTPException(status_code=404, detail="attachment file missing on disk")
|
||||
return FileResponse(
|
||||
path=str(stored),
|
||||
filename=att.filename,
|
||||
media_type=att.content_type or "application/octet-stream",
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.delete("/attachments/{attachment_id}")
|
||||
def remove_attachment(attachment_id: int, board: Optional[str] = Query(None)):
|
||||
board = _resolve_board(board)
|
||||
conn = _conn(board=board)
|
||||
try:
|
||||
att = kanban_db.delete_attachment(conn, attachment_id)
|
||||
if att is None:
|
||||
raise HTTPException(status_code=404, detail="attachment not found")
|
||||
return {"ok": True, "id": attachment_id}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH /tasks/:id (status / assignee / priority / title / body)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -633,7 +633,8 @@ class HindsightMemoryProvider(MemoryProvider):
|
|||
except Exception:
|
||||
pass
|
||||
existing.update(values)
|
||||
config_path.write_text(json.dumps(existing, indent=2))
|
||||
from utils import atomic_json_write
|
||||
atomic_json_write(config_path, existing, mode=0o600)
|
||||
|
||||
def post_setup(self, hermes_home: str, config: dict) -> None:
|
||||
"""Custom setup wizard — installs only the deps needed for the selected mode."""
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ AI-native cross-session user modeling with multi-pass dialectic reasoning, sessi
|
|||
## Setup
|
||||
|
||||
```bash
|
||||
hermes honcho setup # full interactive wizard (cloud or local)
|
||||
hermes memory setup # generic picker, also works
|
||||
hermes memory setup honcho # configure Honcho directly (works on a fresh install)
|
||||
hermes memory setup # generic picker, choose Honcho from the list
|
||||
```
|
||||
|
||||
Or manually:
|
||||
|
|
@ -22,6 +22,10 @@ hermes config set memory.provider honcho
|
|||
echo "HONCHO_API_KEY=***" >> ~/.hermes/.env
|
||||
```
|
||||
|
||||
> `hermes honcho setup` also works, but only **after** Honcho is the active
|
||||
> memory provider — the `honcho` subcommand is registered for the active
|
||||
> provider only. On a fresh install, use `hermes memory setup honcho`.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Two-Layer Context Injection
|
||||
|
|
@ -109,7 +113,7 @@ Config is read from the first file that exists:
|
|||
| 2 | `~/.hermes/honcho.json` | Default profile (shared host blocks) |
|
||||
| 3 | `~/.honcho/config.json` | Global (cross-app interop) |
|
||||
|
||||
Host key is derived from the active Hermes profile: `hermes` (default) or `hermes.<profile>`.
|
||||
Host key is derived from the active Hermes profile: `hermes` (default) or `hermes_<profile>`.
|
||||
|
||||
For every key, resolution order is: **host block > root > env var > default**.
|
||||
|
||||
|
|
@ -154,7 +158,7 @@ In gateway deployments (Telegram, Discord, Slack, etc.) each user arrives with a
|
|||
|
||||
**Host vs root semantics.** All three keys are accepted at both root and `hosts.<host>` levels. Host-level wins. For maps and prefixes, host-level *replaces* the root value as a whole (not merge), so a host can intentionally own its identity universe or wipe it with `userPeerAliases: {}` / `runtimePeerPrefix: ""`.
|
||||
|
||||
**Deployment shapes** (`hermes honcho setup` asks one prompt to set these):
|
||||
**Deployment shapes** (`hermes memory setup honcho` asks one prompt to set these):
|
||||
|
||||
- **Single-operator** — `pinUserPeer: true`. All gateway users → `peerName`. Recommended for personal use where you connect Hermes to your own Telegram/Discord/etc.
|
||||
- **Multi-user gateway** — `pinUserPeer: false`, optional `runtimePeerPrefix`. Each runtime user → own peer. Recommended for bots serving many humans.
|
||||
|
|
@ -225,7 +229,7 @@ Multiple Hermes profiles can share one workspace while maintaining separate AI i
|
|||
"recallMode": "hybrid",
|
||||
"sessionStrategy": "per-directory"
|
||||
},
|
||||
"hermes.coder": {
|
||||
"hermes_coder": {
|
||||
"aiPeer": "coder",
|
||||
"recallMode": "tools",
|
||||
"sessionStrategy": "per-repo"
|
||||
|
|
@ -236,7 +240,7 @@ Multiple Hermes profiles can share one workspace while maintaining separate AI i
|
|||
|
||||
Both profiles see the same user (`yourname`) in the same shared environment (`hermes`), but each AI peer builds its own observations, conclusions, and behavior patterns. The coder's memory stays code-oriented; the main agent's stays broad.
|
||||
|
||||
Host key is derived from the active Hermes profile: `hermes` (default) or `hermes.<profile>` (e.g. `hermes -p coder` → host key `hermes.coder`).
|
||||
Host key is derived from the active Hermes profile: `hermes` (default) or `hermes_<profile>` (e.g. `hermes -p coder` -> host key `hermes_coder`). Older `hermes.<profile>` host blocks are still read for compatibility and are migrated when the CLI writes profile-scoped Honcho config.
|
||||
|
||||
### Dialectic & Reasoning
|
||||
|
||||
|
|
@ -307,7 +311,8 @@ Presets:
|
|||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `hermes honcho setup` | Full interactive setup wizard |
|
||||
| `hermes memory setup honcho` | Configure Honcho directly — works on a fresh install |
|
||||
| `hermes honcho setup` | Interactive setup wizard (only registered once Honcho is the active provider; redirects to `hermes memory setup`) |
|
||||
| `hermes honcho status` | Show resolved config for active profile |
|
||||
| `hermes honcho enable` / `disable` | Toggle Honcho for active profile |
|
||||
| `hermes honcho mode <mode>` | Change recall or observation mode |
|
||||
|
|
@ -344,7 +349,7 @@ Presets:
|
|||
"dialecticMaxChars": 600,
|
||||
"saveMessages": true
|
||||
},
|
||||
"hermes.coder": {
|
||||
"hermes_coder": {
|
||||
"enabled": true,
|
||||
"aiPeer": "coder",
|
||||
"sessionStrategy": "per-repo",
|
||||
|
|
|
|||
|
|
@ -249,6 +249,7 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
def save_config(self, values, hermes_home):
|
||||
"""Write config to $HERMES_HOME/honcho.json (Honcho SDK native format)."""
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
config_path = Path(hermes_home) / "honcho.json"
|
||||
existing = {}
|
||||
|
|
@ -258,7 +259,8 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
except Exception:
|
||||
pass
|
||||
existing.update(values)
|
||||
config_path.write_text(json.dumps(existing, indent=2))
|
||||
from utils import atomic_json_write
|
||||
atomic_json_write(config_path, existing, mode=0o600)
|
||||
|
||||
def get_config_schema(self):
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import sys
|
|||
from pathlib import Path
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
from plugins.memory.honcho.client import resolve_active_host, resolve_config_path, HOST
|
||||
from plugins.memory.honcho.client import _host_block, profile_host_key, resolve_active_host, resolve_config_path, HOST
|
||||
from hermes_cli.config import cfg_get
|
||||
|
||||
|
||||
|
|
@ -36,7 +36,7 @@ def clone_honcho_for_profile(profile_name: str) -> bool:
|
|||
if not default_block and not has_key:
|
||||
return False
|
||||
|
||||
new_host = f"{HOST}.{profile_name}"
|
||||
new_host = profile_host_key(profile_name)
|
||||
if new_host in hosts:
|
||||
return False # already exists
|
||||
|
||||
|
|
@ -192,7 +192,7 @@ def cmd_sync(args) -> None:
|
|||
if p.name == "default":
|
||||
continue
|
||||
if clone_honcho_for_profile(p.name):
|
||||
print(f" + {p.name} -> hermes.{p.name}")
|
||||
print(f" + {p.name} -> {profile_host_key(p.name)}")
|
||||
created += 1
|
||||
else:
|
||||
skipped += 1
|
||||
|
|
@ -243,7 +243,7 @@ def _host_key() -> str:
|
|||
if _profile_override:
|
||||
if _profile_override in {"default", "custom"}:
|
||||
return HOST
|
||||
return f"{HOST}.{_profile_override}"
|
||||
return profile_host_key(_profile_override)
|
||||
return resolve_active_host()
|
||||
|
||||
|
||||
|
|
@ -275,10 +275,8 @@ def _read_config() -> dict:
|
|||
def _write_config(cfg: dict, path: Path | None = None) -> None:
|
||||
path = path or _local_config_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(cfg, indent=2, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
from utils import atomic_json_write
|
||||
atomic_json_write(path, cfg, mode=0o600)
|
||||
|
||||
|
||||
def _resolve_api_key(cfg: dict) -> str:
|
||||
|
|
@ -292,7 +290,7 @@ def _resolve_api_key(cfg: dict) -> str:
|
|||
config shapes, e.g. ``localhost:8000``) still pass — the Honcho SDK
|
||||
will reject them itself with a clearer error than ours.
|
||||
"""
|
||||
host_key = ((cfg.get("hosts") or {}).get(_host_key()) or {}).get("apiKey")
|
||||
host_key = _host_block(cfg, _host_key()).get("apiKey")
|
||||
key = host_key or cfg.get("apiKey", "") or os.environ.get("HONCHO_API_KEY", "")
|
||||
if not key:
|
||||
base_url = cfg.get("baseUrl") or cfg.get("base_url") or os.environ.get("HONCHO_BASE_URL", "")
|
||||
|
|
@ -462,21 +460,58 @@ def cmd_setup(args) -> None:
|
|||
cfg.pop("base_url", None)
|
||||
|
||||
if is_local:
|
||||
# --- Local: ask for base URL, skip or clear API key ---
|
||||
# --- Local: ask for base URL, optionally accept a JWT for auth ---
|
||||
current_url = cfg.get("baseUrl") or ""
|
||||
new_url = _prompt("Base URL", default=current_url or "http://localhost:8000")
|
||||
if new_url:
|
||||
cfg["baseUrl"] = new_url
|
||||
|
||||
# For local no-auth, the SDK must not send an API key.
|
||||
# We keep the key in config (for cloud switching later) but
|
||||
# the client should skip auth when baseUrl is local.
|
||||
current_key = cfg.get("apiKey", "")
|
||||
if current_key:
|
||||
print(f"\n API key present in config (kept for cloud/hybrid use).")
|
||||
print(" Local connections will skip auth automatically.")
|
||||
# Self-hosted Honcho can run with AUTH_USE_AUTH=true and an
|
||||
# AUTH_JWT_SECRET on the server side. In that case clients must
|
||||
# send a JWT signed with that secret as the bearer token (the
|
||||
# Honcho SDK takes it via ``api_key=``). Cloud users got prompted
|
||||
# for a key already; the local path historically skipped this and
|
||||
# forced users to disable auth on the server. Offer the prompt
|
||||
# here too. We store it under the host block (not the top-level
|
||||
# apiKey) so ``get_honcho_client`` recognises it as an explicit
|
||||
# local auth opt-in (see ``_host_has_key`` in client.py) and
|
||||
# cloud/hybrid switching is unaffected.
|
||||
current_host_key = hermes_host.get("apiKey", "")
|
||||
masked = (
|
||||
f"...{current_host_key[-8:]}"
|
||||
if len(current_host_key) > 8
|
||||
else ("set" if current_host_key else "not set")
|
||||
)
|
||||
print(
|
||||
"\n Local Honcho auth (JWT signed with the server's "
|
||||
"AUTH_JWT_SECRET)."
|
||||
)
|
||||
print(
|
||||
" Leave blank if your server runs with AUTH_USE_AUTH=false. "
|
||||
f"Current: {masked}"
|
||||
)
|
||||
new_local_key = _prompt(
|
||||
"Local JWT / bearer token (blank to skip / keep current)",
|
||||
secret=True,
|
||||
)
|
||||
if new_local_key:
|
||||
hermes_host["apiKey"] = new_local_key
|
||||
elif current_host_key:
|
||||
print(" Keeping existing local JWT.")
|
||||
else:
|
||||
print("\n No API key set. Local no-auth ready.")
|
||||
# Surface the top-level key situation for transparency.
|
||||
top_key = cfg.get("apiKey", "")
|
||||
if top_key:
|
||||
print(
|
||||
"\n Top-level API key present in config (kept for "
|
||||
"cloud/hybrid use)."
|
||||
)
|
||||
print(
|
||||
" Local connections will skip auth automatically "
|
||||
"until a local JWT is set above."
|
||||
)
|
||||
else:
|
||||
print("\n No local JWT set. Local no-auth ready.")
|
||||
else:
|
||||
# --- Cloud: set default base URL, require API key ---
|
||||
cfg.pop("baseUrl", None) # cloud uses SDK default
|
||||
|
|
|
|||
|
|
@ -32,6 +32,24 @@ logger = logging.getLogger(__name__)
|
|||
HOST = "hermes"
|
||||
|
||||
|
||||
def profile_host_key(profile: str | None) -> str:
|
||||
"""Return the safe Honcho host key for a Hermes profile."""
|
||||
if not profile or profile in {"default", "custom"}:
|
||||
return HOST
|
||||
sanitized = "".join(c if c.isalnum() or c in "_-" else "_" for c in profile).strip("_")
|
||||
return f"{HOST}_{sanitized or 'profile'}"
|
||||
|
||||
|
||||
def _host_block(raw: dict, host: str) -> dict:
|
||||
"""Return host config, accepting legacy dot-form profile host keys."""
|
||||
hosts = raw.get("hosts") or {}
|
||||
block = hosts.get(host, {})
|
||||
if block or not host.startswith(f"{HOST}_"):
|
||||
return block
|
||||
legacy = f"{HOST}.{host[len(HOST) + 1:]}"
|
||||
return hosts.get(legacy, {})
|
||||
|
||||
|
||||
def resolve_active_host() -> str:
|
||||
"""Derive the Honcho host key from the active Hermes profile.
|
||||
|
||||
|
|
@ -47,8 +65,7 @@ def resolve_active_host() -> str:
|
|||
try:
|
||||
from hermes_cli.profiles import get_active_profile_name
|
||||
profile = get_active_profile_name()
|
||||
if profile and profile not in {"default", "custom"}:
|
||||
return f"{HOST}.{profile}"
|
||||
return profile_host_key(profile)
|
||||
except Exception:
|
||||
pass
|
||||
return HOST
|
||||
|
|
@ -406,7 +423,7 @@ class HonchoClientConfig:
|
|||
logger.warning("Failed to read %s: %s, falling back to env", path, e)
|
||||
return cls.from_env(host=resolved_host)
|
||||
|
||||
host_block = (raw.get("hosts") or {}).get(resolved_host, {})
|
||||
host_block = _host_block(raw, resolved_host)
|
||||
# A hosts.hermes block or explicit enabled flag means the user
|
||||
# intentionally configured Honcho for this host.
|
||||
_explicitly_configured = bool(host_block) or raw.get("enabled") is True
|
||||
|
|
@ -811,7 +828,10 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
|
|||
or "::1" in resolved_base_url
|
||||
)
|
||||
if _is_local:
|
||||
# Check if the host block has its own apiKey (explicit local auth)
|
||||
# Check if the host block has its own apiKey (explicit local auth).
|
||||
# Auth-skipping is loopback-only: a stored key is likely a cloud key
|
||||
# that would break a no-auth local server, so we substitute the SDK's
|
||||
# required-non-empty placeholder unless the host block opts in.
|
||||
_raw = config.raw or {}
|
||||
_host_block = (_raw.get("hosts") or {}).get(config.host, {})
|
||||
_host_has_key = bool(_host_block.get("apiKey"))
|
||||
|
|
@ -819,6 +839,18 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
|
|||
else:
|
||||
effective_api_key = config.api_key
|
||||
|
||||
# The Honcho SDK's route builders (e.g. routes.workspaces()) already
|
||||
# include the version prefix (e.g. "/v3/workspaces"). When a user-supplied
|
||||
# base_url already ends in a version segment (e.g.
|
||||
# "http://localhost:38000/v3", "https://honcho.my.ts.net/v3"), concatenating
|
||||
# the two produces "/v3/v3/workspaces" → 404 on every call. This is a pure
|
||||
# routing concern independent of host, so strip a trailing version segment
|
||||
# from ANY base_url — loopback, LAN, custom domain, or cloud alike. The
|
||||
# SDK then appends its own versioned paths correctly.
|
||||
if resolved_base_url:
|
||||
import re as _re
|
||||
resolved_base_url = _re.sub(r"/v\d+/*$", "", resolved_base_url).rstrip("/")
|
||||
|
||||
kwargs: dict = {
|
||||
"workspace_id": config.workspace_id,
|
||||
"api_key": effective_api_key,
|
||||
|
|
|
|||
|
|
@ -155,7 +155,8 @@ class Mem0MemoryProvider(MemoryProvider):
|
|||
except Exception:
|
||||
pass
|
||||
existing.update(values)
|
||||
config_path.write_text(json.dumps(existing, indent=2))
|
||||
from utils import atomic_json_write
|
||||
atomic_json_write(config_path, existing, mode=0o600)
|
||||
|
||||
def get_config_schema(self):
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -152,7 +152,8 @@ def _save_supermemory_config(values: dict, hermes_home: str) -> None:
|
|||
except Exception:
|
||||
existing = {}
|
||||
existing.update(values)
|
||||
config_path.write_text(json.dumps(existing, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
from utils import atomic_json_write
|
||||
atomic_json_write(config_path, existing, mode=0o600, sort_keys=True)
|
||||
|
||||
|
||||
def _detect_category(text: str) -> str:
|
||||
|
|
|
|||
|
|
@ -6093,16 +6093,17 @@ def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None:
|
|||
``gateway/config.py::load_gateway_config()`` before this migration.
|
||||
|
||||
The DiscordAdapter reads its runtime configuration via ``os.getenv()``
|
||||
throughout the connect / handle code paths (``DISCORD_REQUIRE_MENTION``,
|
||||
``DISCORD_FREE_RESPONSE_CHANNELS``, ``DISCORD_AUTO_THREAD``,
|
||||
``DISCORD_REACTIONS``, ``DISCORD_IGNORED_CHANNELS``,
|
||||
``DISCORD_ALLOWED_CHANNELS``, ``DISCORD_NO_THREAD_CHANNELS``,
|
||||
``DISCORD_HISTORY_BACKFILL``, ``DISCORD_HISTORY_BACKFILL_LIMIT``,
|
||||
``DISCORD_ALLOW_MENTION_*``, ``DISCORD_REPLY_TO_MODE``,
|
||||
``DISCORD_THREAD_REQUIRE_MENTION``). Rather than rewrite ~50 call sites
|
||||
inside the adapter to read from ``PlatformConfig.extra`` instead, this
|
||||
hook keeps the existing env-driven model and merely owns the
|
||||
YAML→env translation here, next to the adapter that consumes it.
|
||||
throughout the connect / handle code paths (``DISCORD_ALLOWED_USERS``,
|
||||
``DISCORD_REQUIRE_MENTION``, ``DISCORD_FREE_RESPONSE_CHANNELS``,
|
||||
``DISCORD_AUTO_THREAD``, ``DISCORD_REACTIONS``,
|
||||
``DISCORD_IGNORED_CHANNELS``, ``DISCORD_ALLOWED_CHANNELS``,
|
||||
``DISCORD_NO_THREAD_CHANNELS``, ``DISCORD_HISTORY_BACKFILL``,
|
||||
``DISCORD_HISTORY_BACKFILL_LIMIT``, ``DISCORD_ALLOW_MENTION_*``,
|
||||
``DISCORD_REPLY_TO_MODE``, ``DISCORD_THREAD_REQUIRE_MENTION``).
|
||||
Rather than rewrite ~50 call sites inside the adapter to read from
|
||||
``PlatformConfig.extra`` instead, this hook keeps the existing
|
||||
env-driven model and merely owns the YAML→env translation here, next to
|
||||
the adapter that consumes it.
|
||||
|
||||
Env vars take precedence over YAML — every assignment is guarded by
|
||||
``not os.getenv(...)`` so explicit env vars survive a config.yaml
|
||||
|
|
@ -6113,6 +6114,22 @@ def _apply_yaml_config(yaml_cfg: dict, discord_cfg: dict) -> dict | None:
|
|||
os.environ["DISCORD_REQUIRE_MENTION"] = str(discord_cfg["require_mention"]).lower()
|
||||
if "thread_require_mention" in discord_cfg and not os.getenv("DISCORD_THREAD_REQUIRE_MENTION"):
|
||||
os.environ["DISCORD_THREAD_REQUIRE_MENTION"] = str(discord_cfg["thread_require_mention"]).lower()
|
||||
platforms_cfg = yaml_cfg.get("platforms")
|
||||
platform_extra_cfg = {}
|
||||
if isinstance(platforms_cfg, dict):
|
||||
discord_platform_cfg = platforms_cfg.get("discord")
|
||||
if isinstance(discord_platform_cfg, dict):
|
||||
candidate_extra = discord_platform_cfg.get("extra")
|
||||
if isinstance(candidate_extra, dict):
|
||||
platform_extra_cfg = candidate_extra
|
||||
allowed_users_cfg = (
|
||||
discord_cfg["allow_from"] if "allow_from" in discord_cfg
|
||||
else platform_extra_cfg.get("allow_from")
|
||||
)
|
||||
if allowed_users_cfg is not None and not os.getenv("DISCORD_ALLOWED_USERS"):
|
||||
if isinstance(allowed_users_cfg, list):
|
||||
allowed_users_cfg = ",".join(str(v) for v in allowed_users_cfg)
|
||||
os.environ["DISCORD_ALLOWED_USERS"] = str(allowed_users_cfg)
|
||||
frc = discord_cfg.get("free_response_channels")
|
||||
if frc is not None and not os.getenv("DISCORD_FREE_RESPONSE_CHANNELS"):
|
||||
if isinstance(frc, list):
|
||||
|
|
|
|||
|
|
@ -146,16 +146,16 @@ def _get_firecrawl_gateway_url() -> str:
|
|||
def _is_tool_gateway_ready() -> bool:
|
||||
"""Return True when gateway URL + Nous Subscriber token are available.
|
||||
|
||||
Reads ``read_nous_access_token`` and ``resolve_managed_tool_gateway``
|
||||
Reads ``peek_nous_access_token`` and ``resolve_managed_tool_gateway``
|
||||
via :mod:`tools.web_tools` rather than direct imports, so unit tests
|
||||
that ``patch("tools.web_tools._read_nous_access_token", ...)`` see
|
||||
that ``patch("tools.web_tools._peek_nous_access_token", ...)`` see
|
||||
their patches honored. The names are re-exported on
|
||||
:mod:`tools.web_tools` for exactly this reason.
|
||||
"""
|
||||
import tools.web_tools as _wt
|
||||
|
||||
return _wt.resolve_managed_tool_gateway(
|
||||
"firecrawl", token_reader=_wt._read_nous_access_token
|
||||
"firecrawl", token_reader=_wt._peek_nous_access_token
|
||||
) is not None
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue