Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d459868776 | ||
|
|
0401176c7a | ||
|
|
f31c950182 | ||
|
|
ffb53767bf |
@@ -313,6 +313,25 @@ def _load_prefill_messages(file_path: str) -> List[Dict[str, Any]]:
|
||||
return []
|
||||
|
||||
|
||||
def _resolve_prefill_messages_file(config: Dict[str, Any]) -> str:
|
||||
"""Resolve the prefill file path from env/config.
|
||||
|
||||
``prefill_messages_file`` at the top level is the canonical config key.
|
||||
``agent.prefill_messages_file`` remains a legacy fallback for older CLI and
|
||||
godmode-generated configs.
|
||||
"""
|
||||
env_path = os.getenv("HERMES_PREFILL_MESSAGES_FILE", "").strip()
|
||||
if env_path:
|
||||
return env_path
|
||||
top_level = str(config.get("prefill_messages_file", "") or "").strip()
|
||||
if top_level:
|
||||
return top_level
|
||||
agent_cfg = config.get("agent", {})
|
||||
if isinstance(agent_cfg, dict):
|
||||
return str(agent_cfg.get("prefill_messages_file", "") or "").strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _parse_reasoning_config(effort: str) -> dict | None:
|
||||
"""Parse a reasoning effort level into an OpenRouter reasoning config dict."""
|
||||
from hermes_constants import parse_reasoning_effort
|
||||
@@ -3272,7 +3291,7 @@ class HermesCLI:
|
||||
|
||||
# Ephemeral prefill messages (few-shot priming, never persisted)
|
||||
self.prefill_messages = _load_prefill_messages(
|
||||
CLI_CONFIG["agent"].get("prefill_messages_file", "")
|
||||
_resolve_prefill_messages_file(CLI_CONFIG)
|
||||
)
|
||||
|
||||
# Reasoning config (OpenRouter reasoning effort level)
|
||||
|
||||
+9
-2
@@ -1551,9 +1551,16 @@ def _run_job_impl(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
effort = str(_cfg.get("agent", {}).get("reasoning_effort", "")).strip()
|
||||
reasoning_config = parse_reasoning_effort(effort)
|
||||
|
||||
# Prefill messages from env or config.yaml
|
||||
# Prefill messages from env or config.yaml. The top-level
|
||||
# prefill_messages_file key is canonical; agent.prefill_messages_file is
|
||||
# retained as a legacy fallback for older CLI/godmode configs.
|
||||
prefill_messages = None
|
||||
prefill_file = os.getenv("HERMES_PREFILL_MESSAGES_FILE", "") or _cfg.get("prefill_messages_file", "")
|
||||
agent_cfg = _cfg.get("agent", {}) if isinstance(_cfg.get("agent", {}), dict) else {}
|
||||
prefill_file = (
|
||||
os.getenv("HERMES_PREFILL_MESSAGES_FILE", "")
|
||||
or _cfg.get("prefill_messages_file", "")
|
||||
or agent_cfg.get("prefill_messages_file", "")
|
||||
)
|
||||
if prefill_file:
|
||||
pfpath = Path(prefill_file).expanduser()
|
||||
if not pfpath.is_absolute():
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
# Why IDE-Embedded Coding Agents Feel Better — and Where Hermes Stands
|
||||
|
||||
A study of the *harness* techniques that make in-editor coding agents punch above
|
||||
the raw model, and an honest map of which ones Hermes already implements, which it
|
||||
approximates, and which it lacks.
|
||||
|
||||
## TL;DR
|
||||
|
||||
The leading IDE-embedded coding products are **not better models** — they call the
|
||||
same frontier models (Claude, GPT) that power terminal agents like this one. Their
|
||||
edge comes entirely from the **harness**: how context is retrieved and assembled,
|
||||
how mechanical edits are applied reliably, and how cheap specialized models are
|
||||
routed in for sub-tasks. The model is a commodity; the meal it's fed is not.
|
||||
|
||||
This document breaks the advantage into five concrete subsystems, each backed by
|
||||
published engineering from the vendors, and maps each onto the Hermes codebase.
|
||||
|
||||
| # | Technique | What it buys | Hermes status |
|
||||
|---|-----------|--------------|---------------|
|
||||
| 1 | Indexed semantic retrieval (Merkle delta-sync + content-addressed embedding cache) | Knows the repo in ms; feeds the *right* snippets | ❌ **Gap** (grep/FTS5 only, no vector index) |
|
||||
| 2 | Retrieval as the accuracy driver | +~12.5% answer accuracy (vendor eval) | ⚠️ **Approximated** (lexical search, not semantic) |
|
||||
| 3 | Decoupled "apply" model + line-number-free search/replace | Frontier model only *reasons*; mechanical patching never botches the file | ✅ **Have an analog** (`tools/fuzzy_match.py`) |
|
||||
| 4 | Ambient IDE context (cursor pos, selection, live diagnostics) | More intent-signal per token | ⚠️ **Partial** (context files + LSP, no live cursor/selection) |
|
||||
| 5 | Per-task model routing (tiny model for autocomplete, frontier for reasoning) | Right tool per job | ✅ **Have** (`agent/auxiliary_client.py`) |
|
||||
|
||||
---
|
||||
|
||||
## 1. Indexed semantic retrieval — the actual moat
|
||||
|
||||
The headline trick is *not* the system prompt. It's a vector index over the repo
|
||||
that is kept fresh cheaply:
|
||||
|
||||
- **Merkle tree for change detection.** Every file is SHA-256 hashed; folder hashes
|
||||
derive from children; the root summarizes the repo. An edit changes only that
|
||||
file's hash plus the path to the root, so the indexer walks **only the branches
|
||||
that differ** instead of rescanning. (This is git's own content-addressing trick
|
||||
repurposed for indexing.)
|
||||
- **Syntax-aware chunking.** Changed files are split on function/class boundaries,
|
||||
not arbitrary token windows, then embedded.
|
||||
- **Content-addressed embedding cache.** Embeddings are keyed by the hash of the
|
||||
chunk content. Re-indexing unchanged code is a cache hit → zero embedding cost.
|
||||
Embedding is the expensive step, so this is the whole ballgame for speed.
|
||||
- **Cross-clone index reuse.** Vendors observe that clones of one repo are ~92%
|
||||
identical across an org; a "simhash" lets a new clone reuse a teammate's index,
|
||||
collapsing time-to-first-query from hours (99th pct) to seconds. Access is gated
|
||||
cryptographically: you can only compute a Merkle node's hash if you actually hold
|
||||
the file, so results you can't *prove* you possess are dropped.
|
||||
|
||||
**Hermes status: this is the real gap.** Core Hermes has no vector index, no
|
||||
embedding store, no Merkle delta-sync. Codebase awareness is achieved at task time
|
||||
via lexical tools (`search_files` → ripgrep) and session recall via SQLite FTS5
|
||||
(`hermes_state.py`, `tools/session_search_tool.py`). The only embedding-flavored
|
||||
retrieval lives in an optional plugin (`plugins/memory/holographic/`), and even
|
||||
that is FTS5-backed, not dense-vector.
|
||||
|
||||
This is a *defensible* design choice for a terminal-first agent — ripgrep over a
|
||||
known working tree is fast, dependency-free, and always current — but it means
|
||||
Hermes "discovers" a codebase cold each task rather than walking in pre-indexed.
|
||||
|
||||
## 2. Retrieval is the accuracy driver (empirically)
|
||||
|
||||
Vendor evals attribute **~+12.5% answer accuracy** and higher edit-retention to
|
||||
semantic search alone — same model, better-retrieved context. This is the
|
||||
empirical proof of the thesis: *a worse model with better context beats a better
|
||||
model with worse context.*
|
||||
|
||||
**Hermes status: approximated, lexically.** Hermes gets the *shape* of this through
|
||||
aggressive context assembly — `agent/prompt_builder.py` and `agent/system_prompt.py`
|
||||
inject project context files (AGENTS.md / CLAUDE.md / .cursorrules), and
|
||||
`agent/subdirectory_hints.py` surfaces local structure. What's missing is *ranked
|
||||
semantic* retrieval: Hermes finds text by pattern, not by meaning. For
|
||||
"where is the thing that does X" questions, lexical search is strictly weaker than
|
||||
embeddings.
|
||||
|
||||
## 3. Decoupled apply model — the most underrated trick
|
||||
|
||||
Leading products split edits into two stages:
|
||||
|
||||
1. **Plan** — the frontier model emits a *terse* edit, often with
|
||||
`// ... existing code ...` placeholders.
|
||||
2. **Apply** — a separate, cheap, often self-hosted model turns that sketch into the
|
||||
final file.
|
||||
|
||||
Why bother? Frontier models are *lazy and inaccurate* at large rewrites: they drop
|
||||
code, emit `...`, "helpfully" reformat unrelated lines, miscount line numbers, and
|
||||
can trap the agent in retry loops. Three published findings drive the design:
|
||||
|
||||
- **Whole-file rewrites beat diffs** for the model, because diffs force fewer output
|
||||
tokens (less room to "think"), are out-of-distribution (models saw far more whole
|
||||
files in training), and line numbers are tokenizer poison (a number is one token,
|
||||
forcing a one-shot commit, and models can't count lines).
|
||||
- So edits use **search/replace blocks with no line numbers**, with redundant
|
||||
context lines so the parser tolerates model slips.
|
||||
- **Speculative decoding** makes apply fast (~1000 tok/s) because the unchanged file
|
||||
*is* the draft — and because that can't be built into hosted Anthropic/OpenAI
|
||||
models, vendors train and self-host their own apply model.
|
||||
|
||||
**Hermes status: it has a deterministic analog, and it's good.**
|
||||
`tools/fuzzy_match.py` implements an **8-strategy search/replace matcher** (exact →
|
||||
line-trimmed → whitespace-normalized → indentation-flexible → escape-normalized →
|
||||
trimmed-boundary → block-anchor → context-aware-similarity) that is *precisely* the
|
||||
"tolerate model slips in line-number-free search/replace" idea — just solved with
|
||||
`difflib.SequenceMatcher` instead of a trained model. `tools/patch_parser.py` and
|
||||
`tools/file_tools.py` wire it into the `patch` tool. Hermes also already adopts the
|
||||
correct *interface*: the model emits `old_string`/`new_string`, never line numbers.
|
||||
|
||||
Where the vendors go further: a *trained* apply model can reconstruct intent from a
|
||||
sketch (resolve `// ... existing ...` placeholders against the real file), whereas a
|
||||
fuzzy matcher can only locate-and-substitute text the model actually wrote. Hermes
|
||||
trades that capability for zero latency, zero cost, and full determinism — a sound
|
||||
trade for a local agent, but worth naming.
|
||||
|
||||
## 4. Ambient context — the editor's free advantage
|
||||
|
||||
Because the product *is* the editor, it injects for free: the open file, **cursor
|
||||
position**, current selection, **live LSP/linter diagnostics**, and recent diffs.
|
||||
Terminal agents must spend tool calls reconstructing all of this. More intent-signal
|
||||
per token → better output from the same model.
|
||||
|
||||
**Hermes status: partial.** Hermes injects project context files and has LSP
|
||||
plumbing (`agent/lsp/`), and the ACP adapter (`acp_adapter/`) gives editors a way to
|
||||
feed edits/approvals back. What it lacks is the *passive* signal: it doesn't know
|
||||
where your cursor is or what you've selected, because in a terminal there is no
|
||||
cursor to read. The ACP integration narrows this gap when Hermes runs inside an
|
||||
editor, but the default terminal surface is signal-poorer by construction.
|
||||
|
||||
## 5. Per-task model routing
|
||||
|
||||
Autocomplete uses a tiny fast model; chat uses a frontier model; apply uses the
|
||||
custom fast model; the agent loop uses a frontier model plus tools. Nothing is
|
||||
forced through one monolith.
|
||||
|
||||
**Hermes status: have it.** `agent/auxiliary_client.py` provides a routed auxiliary
|
||||
model used for cheaper sub-tasks — title generation (`agent/title_generator.py`),
|
||||
vision routing (`tools/computer_use/vision_routing.py`), background review
|
||||
(`agent/background_review.py`), and conversation compression
|
||||
(`agent/context_compressor.py`, `trajectory_compressor.py`). The pattern — reserve
|
||||
the expensive model for reasoning, route mechanical sub-tasks to a cheap one — is
|
||||
already core to Hermes.
|
||||
|
||||
---
|
||||
|
||||
## Synthesis: where Hermes wins, ties, and trails
|
||||
|
||||
**Ties or wins:**
|
||||
- **Apply reliability** — the 8-strategy fuzzy matcher is a genuinely strong,
|
||||
zero-cost analog to a trained apply model, and uses the same line-number-free
|
||||
search/replace interface the research converged on.
|
||||
- **Model routing** — auxiliary-client routing already reserves the frontier model
|
||||
for reasoning.
|
||||
- **Context-file injection & session memory** — robust, and FTS5 session search is a
|
||||
real recall capability terminal-first.
|
||||
|
||||
**Trails:**
|
||||
- **Semantic codebase retrieval** is the one structural gap. Hermes is lexical
|
||||
(ripgrep + FTS5) where the leaders are dense-vector with a cheaply-maintained
|
||||
index. This is the highest-leverage area if Hermes ever wants to close the
|
||||
"feels like it already knows my repo" gap.
|
||||
- **Ambient passive context** (cursor/selection/live diagnostics) is inherently
|
||||
weaker outside an editor; the ACP path is the right place to invest if that
|
||||
matters.
|
||||
|
||||
**The single most transferable insight:** the research independently concluded that
|
||||
it is *better to rewrite via fuzzy-tolerant, line-number-free search/replace than to
|
||||
trust the smart model to emit a precise diff* — and Hermes already lands on the same
|
||||
answer in `tools/fuzzy_match.py`. That convergence is a good sign the harness
|
||||
fundamentals here are sound; the missing piece is retrieval, not editing.
|
||||
|
||||
## Suggested follow-ups (not implemented here — analysis only)
|
||||
|
||||
1. **Optional semantic index plugin.** A content-addressed embedding cache keyed by
|
||||
file hash, behind the existing plugin interface, would give ranked semantic
|
||||
retrieval without bloating the core terminal path. Merkle delta-sync keeps it
|
||||
cheap to refresh.
|
||||
2. **Apply-from-sketch mode.** Let the model emit `// ... existing ...` placeholders
|
||||
and resolve them against the real file before handing to the fuzzy matcher —
|
||||
captures most of a trained apply model's benefit deterministically.
|
||||
3. **Richer ambient context over ACP.** Pipe editor cursor/selection/diagnostics
|
||||
into prompt assembly when running embedded, closing the passive-signal gap.
|
||||
+4
-1
@@ -3034,13 +3034,16 @@ class GatewayRunner:
|
||||
"""Load ephemeral prefill messages from config or env var.
|
||||
|
||||
Checks HERMES_PREFILL_MESSAGES_FILE env var first, then falls back to
|
||||
the prefill_messages_file key in ~/.hermes/config.yaml.
|
||||
the top-level prefill_messages_file key in ~/.hermes/config.yaml.
|
||||
agent.prefill_messages_file is accepted as a legacy fallback.
|
||||
Relative paths are resolved from ~/.hermes/.
|
||||
"""
|
||||
file_path = os.getenv("HERMES_PREFILL_MESSAGES_FILE", "")
|
||||
if not file_path:
|
||||
cfg = _load_gateway_runtime_config()
|
||||
file_path = str(cfg.get("prefill_messages_file", "") or "")
|
||||
if not file_path:
|
||||
file_path = str(cfg_get(cfg, "agent", "prefill_messages_file", default="") or "")
|
||||
if not file_path:
|
||||
return []
|
||||
path = Path(file_path).expanduser()
|
||||
|
||||
@@ -1,300 +0,0 @@
|
||||
"""``hermes dashboard register`` — register a self-hosted dashboard OAuth client.
|
||||
|
||||
Automates what a user otherwise does by hand: open the Nous Portal
|
||||
``/local-dashboards`` page in a browser, click "register", copy the
|
||||
resulting ``agent:{id}`` OAuth client ID, and paste it into ``~/.hermes/.env``
|
||||
as ``HERMES_DASHBOARD_OAUTH_CLIENT_ID``.
|
||||
|
||||
This command:
|
||||
1. Resolves a fresh Nous Portal access token from the existing login
|
||||
(``~/.hermes/auth.json``), refreshing it if needed. Fails fast with a
|
||||
"run `hermes setup`" hint when the user isn't logged in.
|
||||
2. POSTs to ``{portal}/api/oauth/self-hosted-client`` with that bearer
|
||||
token, which creates a SELF_HOSTED agent client owned by the caller's
|
||||
org and returns the fully-formed ``agent:{id}`` client_id.
|
||||
3. Writes ``HERMES_DASHBOARD_OAUTH_CLIENT_ID`` and (if absent)
|
||||
``HERMES_DASHBOARD_PORTAL_URL`` into ``~/.hermes/.env`` idempotently.
|
||||
4. Prints a post-register hint explaining that the OAuth gate only engages
|
||||
on a non-loopback bind.
|
||||
|
||||
The portal endpoint is the NAS half of this feature (POST
|
||||
/api/oauth/self-hosted-client). The ``agent:`` prefix is applied server-side,
|
||||
so this client never needs to know the namespace convention.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# Docker-style name generator. Same vibe as Docker's adjective_surname, but
|
||||
# adjective_noun with a space-free underscore join so it drops cleanly into a
|
||||
# label field. There is NO uniqueness constraint on the portal side (the row
|
||||
# id is the key), so collisions are harmless and we don't retry.
|
||||
_NAME_ADJECTIVES = (
|
||||
"amber", "bold", "brave", "bright", "calm", "clever", "cosmic", "crisp",
|
||||
"dreamy", "eager", "electric", "fancy", "gentle", "golden", "happy",
|
||||
"hidden", "jolly", "keen", "lively", "lucid", "lunar", "mellow", "merry",
|
||||
"mighty", "nimble", "noble", "polished", "quiet", "quirky", "rapid",
|
||||
"serene", "sharp", "shiny", "silent", "snappy", "solar", "spry", "stellar",
|
||||
"sunny", "swift", "tidy", "vivid", "vibrant", "witty", "zesty",
|
||||
)
|
||||
|
||||
_NAME_NOUNS = (
|
||||
"albatross", "antelope", "badger", "beacon", "comet", "condor", "cypress",
|
||||
"dolphin", "ember", "falcon", "ferret", "galaxy", "glacier", "harbor",
|
||||
"heron", "ibex", "jaguar", "kestrel", "lantern", "lynx", "meadow", "nebula",
|
||||
"ocelot", "orchid", "otter", "panther", "petrel", "quasar", "raven", "reef",
|
||||
"sparrow", "summit", "tundra", "vortex", "walrus", "willow", "yarrow",
|
||||
# A couple of scientist surnames in the Docker spirit.
|
||||
"kepler", "tesla", "curie", "hopper", "turing", "lovelace",
|
||||
)
|
||||
|
||||
|
||||
def _generate_dashboard_name() -> str:
|
||||
"""Return a human-readable ``adjective_noun`` name (Docker-style)."""
|
||||
return f"{random.choice(_NAME_ADJECTIVES)}_{random.choice(_NAME_NOUNS)}"
|
||||
|
||||
|
||||
def _resolve_portal_base_url(override: Optional[str] = None) -> str:
|
||||
"""Resolve the portal base URL for the registration request.
|
||||
|
||||
Precedence:
|
||||
1. ``override`` — explicit ``--portal-url`` flag or
|
||||
``HERMES_DASHBOARD_PORTAL_URL`` env (used for testing against a
|
||||
preview/staging portal). NOTE: the access token must be valid at
|
||||
this portal — it's minted by whatever portal you logged into, so an
|
||||
override only works if the token's issuer matches (e.g. you logged
|
||||
into the same staging/preview portal).
|
||||
2. The ``portal_base_url`` stored on the Nous login — this is the
|
||||
portal that issued the token, so it's the correct default target.
|
||||
3. The production default.
|
||||
"""
|
||||
if isinstance(override, str) and override.strip():
|
||||
return override.rstrip("/")
|
||||
try:
|
||||
from hermes_cli.auth import DEFAULT_NOUS_PORTAL_URL, get_provider_auth_state
|
||||
|
||||
state = get_provider_auth_state("nous") or {}
|
||||
base = state.get("portal_base_url")
|
||||
if isinstance(base, str) and base.strip():
|
||||
return base.rstrip("/")
|
||||
return str(DEFAULT_NOUS_PORTAL_URL).rstrip("/")
|
||||
except Exception:
|
||||
return "https://portal.nousresearch.com"
|
||||
|
||||
|
||||
def _register_self_hosted_client(
|
||||
*,
|
||||
access_token: str,
|
||||
portal_base_url: str,
|
||||
name: str,
|
||||
custom_redirect_uri: Optional[str],
|
||||
timeout: float = 15.0,
|
||||
) -> dict:
|
||||
"""POST to the portal's self-hosted-client endpoint and return the JSON body.
|
||||
|
||||
Raises RuntimeError with a user-facing message on any non-2xx response or
|
||||
transport failure.
|
||||
"""
|
||||
url = f"{portal_base_url.rstrip('/')}/api/oauth/self-hosted-client"
|
||||
body: dict[str, str] = {"name": name}
|
||||
if custom_redirect_uri:
|
||||
body["custom_redirect_uri"] = custom_redirect_uri
|
||||
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
method="POST",
|
||||
headers={
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
payload = json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as exc:
|
||||
# The endpoint returns structured JSON errors ({error, error_description}).
|
||||
detail = ""
|
||||
try:
|
||||
err_body = json.loads(exc.read().decode())
|
||||
detail = (
|
||||
err_body.get("error_description")
|
||||
or err_body.get("error")
|
||||
or ""
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if exc.code == 401:
|
||||
raise RuntimeError(
|
||||
"Nous Portal rejected the access token (401). "
|
||||
"Try `hermes auth login nous` to re-authenticate."
|
||||
) from exc
|
||||
if exc.code == 403:
|
||||
raise RuntimeError(
|
||||
detail
|
||||
or "Your account is not permitted to register a self-hosted dashboard."
|
||||
) from exc
|
||||
raise RuntimeError(
|
||||
f"Portal returned HTTP {exc.code}"
|
||||
+ (f": {detail}" if detail else "")
|
||||
) from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise RuntimeError(
|
||||
f"Could not reach Nous Portal at {portal_base_url}: {exc.reason}"
|
||||
) from exc
|
||||
|
||||
if not isinstance(payload, dict) or not payload.get("client_id"):
|
||||
raise RuntimeError("Portal returned an unexpected response (no client_id).")
|
||||
return payload
|
||||
|
||||
|
||||
def _print_post_register_hint(
|
||||
*,
|
||||
client_id: str,
|
||||
portal_base_url: str,
|
||||
custom_redirect_uri: Optional[str],
|
||||
wrote_portal_url: bool,
|
||||
) -> None:
|
||||
"""Print the success summary + the gate-engagement caveat."""
|
||||
from hermes_cli.config import get_env_path
|
||||
|
||||
env_path = get_env_path()
|
||||
print()
|
||||
print(f" Wrote to {env_path}:")
|
||||
print(f" HERMES_DASHBOARD_OAUTH_CLIENT_ID={client_id}")
|
||||
if wrote_portal_url:
|
||||
print(f" HERMES_DASHBOARD_PORTAL_URL={portal_base_url}")
|
||||
print()
|
||||
print(
|
||||
" Heads up — Nous login only *engages* on a non-loopback bind. A plain\n"
|
||||
" `hermes dashboard` (localhost) leaves the gate off and serves locally\n"
|
||||
" without auth, which is fine for your own machine."
|
||||
)
|
||||
print()
|
||||
if custom_redirect_uri:
|
||||
# Derive the host the user registered so the example matches it.
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
|
||||
host = urlparse(custom_redirect_uri).hostname or "your-host"
|
||||
except Exception:
|
||||
host = "your-host"
|
||||
print(" To require Nous login on your registered host, run the dashboard")
|
||||
print(f" bound publicly (it must be reachable at https://{host}) and log in")
|
||||
print(" at its /login page.")
|
||||
else:
|
||||
print(" To require Nous login (e.g. exposing on your LAN or a public host):")
|
||||
print(" hermes dashboard --host 0.0.0.0")
|
||||
print(" …then log in at the dashboard's /login page.")
|
||||
print()
|
||||
print(
|
||||
" If the dashboard is already running, restart it to pick up the new env."
|
||||
)
|
||||
print(
|
||||
f" Manage or revoke this dashboard at {portal_base_url}/local-dashboards"
|
||||
)
|
||||
|
||||
|
||||
def cmd_dashboard_register(args) -> None:
|
||||
"""Register a self-hosted dashboard OAuth client with Nous Portal."""
|
||||
from hermes_cli.auth import AuthError, resolve_nous_access_token
|
||||
from hermes_cli.config import get_env_value, is_managed, save_env_value
|
||||
|
||||
# Managed (Docker/hosted) installs get their dashboard OAuth client_id
|
||||
# stamped in by the orchestrator (NAS sets HERMES_DASHBOARD_OAUTH_CLIENT_ID
|
||||
# via buildContainerEnvVars). Registering from inside such a container is a
|
||||
# mistake — and save_env_value refuses to write anyway.
|
||||
if is_managed():
|
||||
print(
|
||||
"✗ `hermes dashboard register` is not available in a managed/hosted "
|
||||
"install.\n"
|
||||
" The dashboard OAuth client is provisioned by the hosting platform."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# 1. Resolve a fresh Nous access token (refreshes if near expiry). Fail fast
|
||||
# with a setup hint when the user isn't logged in.
|
||||
try:
|
||||
access_token = resolve_nous_access_token()
|
||||
except AuthError as exc:
|
||||
if getattr(exc, "relogin_required", False):
|
||||
print("✗ You're not logged into Nous Portal.")
|
||||
print(" Run `hermes setup` (or `hermes auth login nous`) first, then retry.")
|
||||
else:
|
||||
print(f"✗ Could not resolve a Nous Portal access token: {exc}")
|
||||
sys.exit(1)
|
||||
except Exception as exc:
|
||||
print(f"✗ Could not resolve a Nous Portal access token: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
# Portal override: explicit --portal-url flag wins, else the
|
||||
# HERMES_DASHBOARD_PORTAL_URL env var, else the stored login's portal.
|
||||
portal_override = getattr(args, "portal_url", None) or os.environ.get(
|
||||
"HERMES_DASHBOARD_PORTAL_URL"
|
||||
)
|
||||
portal_base_url = _resolve_portal_base_url(portal_override)
|
||||
|
||||
name = getattr(args, "name", None) or _generate_dashboard_name()
|
||||
custom_redirect_uri = getattr(args, "redirect_uri", None)
|
||||
|
||||
# 2. Register with the portal.
|
||||
try:
|
||||
result = _register_self_hosted_client(
|
||||
access_token=access_token,
|
||||
portal_base_url=portal_base_url,
|
||||
name=name,
|
||||
custom_redirect_uri=custom_redirect_uri,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
print(f"✗ Registration failed: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
client_id = str(result["client_id"])
|
||||
registered_name = str(result.get("name") or name)
|
||||
|
||||
print(f'✓ Registered dashboard "{registered_name}"')
|
||||
|
||||
# 3. Write env vars idempotently. Always set the client_id. Only set the
|
||||
# portal URL when it isn't already configured (env or config) AND differs
|
||||
# from the production default, so we don't clutter .env for the common case
|
||||
# but DO persist a non-default portal (e.g. a preview deploy used in dev).
|
||||
try:
|
||||
save_env_value("HERMES_DASHBOARD_OAUTH_CLIENT_ID", client_id)
|
||||
except Exception as exc:
|
||||
print(f"✗ Failed to write HERMES_DASHBOARD_OAUTH_CLIENT_ID to .env: {exc}")
|
||||
print(f" Set it manually: HERMES_DASHBOARD_OAUTH_CLIENT_ID={client_id}")
|
||||
sys.exit(1)
|
||||
|
||||
wrote_portal_url = False
|
||||
default_portal = "https://portal.nousresearch.com"
|
||||
existing_portal = None
|
||||
try:
|
||||
existing_portal = get_env_value("HERMES_DASHBOARD_PORTAL_URL")
|
||||
except Exception:
|
||||
existing_portal = None
|
||||
if not existing_portal and portal_base_url.rstrip("/") != default_portal:
|
||||
try:
|
||||
save_env_value("HERMES_DASHBOARD_PORTAL_URL", portal_base_url)
|
||||
wrote_portal_url = True
|
||||
except Exception:
|
||||
# Non-fatal: the client_id is the load-bearing value.
|
||||
pass
|
||||
|
||||
# 4. Hint.
|
||||
_print_post_register_hint(
|
||||
client_id=client_id,
|
||||
portal_base_url=portal_base_url,
|
||||
custom_redirect_uri=custom_redirect_uri,
|
||||
wrote_portal_url=wrote_portal_url,
|
||||
)
|
||||
@@ -11978,13 +11978,6 @@ def cmd_dashboard(args):
|
||||
)
|
||||
|
||||
|
||||
def cmd_dashboard_register(args):
|
||||
"""Register a self-hosted dashboard OAuth client with Nous Portal."""
|
||||
from hermes_cli.dashboard_register import cmd_dashboard_register as _impl
|
||||
|
||||
_impl(args)
|
||||
|
||||
|
||||
def cmd_completion(args, parser=None):
|
||||
"""Print shell completion script."""
|
||||
from hermes_cli.completion import generate_bash, generate_zsh, generate_fish
|
||||
@@ -15295,50 +15288,6 @@ Examples:
|
||||
)
|
||||
dashboard_parser.set_defaults(func=cmd_dashboard)
|
||||
|
||||
# `hermes dashboard register` — register a self-hosted dashboard OAuth
|
||||
# client with Nous Portal and write the client_id into ~/.hermes/.env.
|
||||
# Nested subparser so bare `hermes dashboard` keeps launching the server
|
||||
# (set_defaults(func=cmd_dashboard) above remains the default).
|
||||
dashboard_subparsers = dashboard_parser.add_subparsers(
|
||||
dest="dashboard_subcommand"
|
||||
)
|
||||
dashboard_register_parser = dashboard_subparsers.add_parser(
|
||||
"register",
|
||||
help="Register a self-hosted dashboard with Nous Portal (writes the OAuth client ID to .env)",
|
||||
description=(
|
||||
"Register this install as a self-hosted dashboard with your Nous "
|
||||
"Portal account. Creates an OAuth client, writes "
|
||||
"HERMES_DASHBOARD_OAUTH_CLIENT_ID into ~/.hermes/.env, and prints "
|
||||
"how to engage the login gate. Requires being logged in (hermes setup)."
|
||||
),
|
||||
)
|
||||
dashboard_register_parser.add_argument(
|
||||
"--name",
|
||||
default=None,
|
||||
help="Human-readable label for the dashboard (default: an auto-generated name)",
|
||||
)
|
||||
dashboard_register_parser.add_argument(
|
||||
"--redirect-uri",
|
||||
dest="redirect_uri",
|
||||
default=None,
|
||||
help=(
|
||||
"Optional public HTTPS OAuth redirect URI for the dashboard, e.g. "
|
||||
"https://hermes.example.com/auth/callback. Omit for localhost-only use."
|
||||
),
|
||||
)
|
||||
dashboard_register_parser.add_argument(
|
||||
"--portal-url",
|
||||
dest="portal_url",
|
||||
default=None,
|
||||
help=(
|
||||
"Override the Nous Portal base URL for registration (default: the "
|
||||
"portal you logged into). The access token must be valid at this "
|
||||
"portal. Also settable via HERMES_DASHBOARD_PORTAL_URL. Mainly for "
|
||||
"testing against a staging/preview portal."
|
||||
),
|
||||
)
|
||||
dashboard_register_parser.set_defaults(func=cmd_dashboard_register)
|
||||
|
||||
# =========================================================================
|
||||
# desktop (a.k.a. gui) command
|
||||
#
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Supermemory Memory Provider
|
||||
|
||||
Semantic long-term memory with profile recall, semantic search, explicit memory tools, and session-end conversation ingest.
|
||||
Semantic long-term memory with profile recall, semantic search, explicit memory tools, and full-session conversation ingest (one ingest per session) for richer profiles.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -45,22 +45,34 @@ Config file: `$HERMES_HOME/supermemory.json`
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `supermemory_store` | Store an explicit memory |
|
||||
| `supermemory_search` | Search memories by semantic similarity |
|
||||
| `supermemory_forget` | Forget a memory by ID or best-match query |
|
||||
| `supermemory_profile` | Retrieve persistent profile and recent context |
|
||||
Kebab-case names are registered for the agent; snake_case aliases remain supported.
|
||||
|
||||
| Tool | Alias | Description |
|
||||
|------|-------|-------------|
|
||||
| `supermemory-save` | `supermemory_store` | Store an explicit memory |
|
||||
| `supermemory-search` | `supermemory_search` | Search memories by semantic similarity |
|
||||
| `supermemory-forget` | `supermemory_forget` | Forget a memory by ID or best-match query |
|
||||
| `supermemory-profile` | `supermemory_profile` | Retrieve persistent profile and recent context |
|
||||
|
||||
## Source attribution
|
||||
|
||||
All Supermemory API calls send `x-sm-source: hermes`, and document writes stamp
|
||||
`metadata.sm_source: hermes`. This is a **functional routing key, not telemetry**:
|
||||
it groups Hermes-written memories into a dedicated "Hermes" Space in the
|
||||
Supermemory app, so you can filter, browse, and bulk-manage them per source agent
|
||||
(alongside Codex, Claude Code, etc.) from the Supermemory UI.
|
||||
|
||||
## Behavior
|
||||
|
||||
When enabled, Hermes can:
|
||||
|
||||
- prefetch relevant memory context before each turn
|
||||
- store cleaned conversation turns after each completed response
|
||||
- ingest the full session on session end for richer graph updates
|
||||
- buffer the full conversation and ingest it as **one session** at session end (or on `/reset`, branch, compression, or shutdown)
|
||||
- ingest the full session to the conversations endpoint for richer profile/graph updates
|
||||
- expose explicit tools for search, store, forget, and profile access
|
||||
|
||||
The session is written once via the conversations endpoint, which drives Supermemory's entity extraction and profile building while keeping a clean, retrievable full transcript.
|
||||
|
||||
## Profile-Scoped Containers
|
||||
|
||||
Use `{identity}` in the `container_tag` to scope memories per Hermes profile:
|
||||
@@ -87,7 +99,7 @@ For advanced setups (e.g. OpenClaw-style multi-workspace), you can enable custom
|
||||
```
|
||||
|
||||
When enabled:
|
||||
- `supermemory_search`, `supermemory_store`, `supermemory_forget`, and `supermemory_profile` accept an optional `container_tag` parameter
|
||||
- `supermemory-search`, `supermemory-save`, `supermemory-forget`, and `supermemory-profile` accept an optional `container_tag` parameter
|
||||
- The tag must be in the whitelist: primary container + `custom_containers`
|
||||
- Automatic operations (turn sync, prefetch, memory write mirroring, session ingest) always use the **primary** container only
|
||||
- Custom container instructions are injected into the system prompt
|
||||
|
||||
@@ -269,7 +269,22 @@ class _SupermemoryClient:
|
||||
self._container_tag = container_tag
|
||||
self._search_mode = search_mode if search_mode in _VALID_SEARCH_MODES else _DEFAULT_SEARCH_MODE
|
||||
self._timeout = timeout
|
||||
self._client = Supermemory(api_key=api_key, timeout=timeout, max_retries=0)
|
||||
self._client = Supermemory(
|
||||
api_key=api_key,
|
||||
timeout=timeout,
|
||||
max_retries=0,
|
||||
default_headers={"x-sm-source": "hermes"},
|
||||
)
|
||||
|
||||
def _merge_metadata(self, metadata: Optional[dict]) -> dict:
|
||||
# sm_source routes Hermes writes into the "Hermes" Space in the Supermemory
|
||||
# app so the user can filter / bulk-manage them per source agent. This is a
|
||||
# functional routing key for the user, not vendor telemetry.
|
||||
merged = {"sm_source": "hermes", **(metadata or {})}
|
||||
legacy_source = merged.pop("source", None)
|
||||
if legacy_source and "type" not in merged:
|
||||
merged["type"] = str(legacy_source)
|
||||
return merged
|
||||
|
||||
def add_memory(self, content: str, metadata: Optional[dict] = None, *,
|
||||
entity_context: str = "", container_tag: Optional[str] = None,
|
||||
@@ -280,7 +295,7 @@ class _SupermemoryClient:
|
||||
"container_tags": [tag],
|
||||
}
|
||||
if metadata:
|
||||
kwargs["metadata"] = metadata
|
||||
kwargs["metadata"] = self._merge_metadata(metadata)
|
||||
if entity_context:
|
||||
kwargs["entity_context"] = _clamp_entity_context(entity_context)
|
||||
if custom_id:
|
||||
@@ -349,18 +364,22 @@ class _SupermemoryClient:
|
||||
preview = (target.get("memory") or "")[:100]
|
||||
return {"success": True, "message": f'Forgot: "{preview}"', "id": memory_id}
|
||||
|
||||
def ingest_conversation(self, session_id: str, messages: list[dict]) -> None:
|
||||
payload = json.dumps({
|
||||
def ingest_conversation(self, session_id: str, messages: list[dict], metadata: dict | None = None) -> None:
|
||||
payload: dict = {
|
||||
"conversationId": session_id,
|
||||
"messages": messages,
|
||||
"containerTags": [self._container_tag],
|
||||
}).encode("utf-8")
|
||||
}
|
||||
if metadata:
|
||||
payload["metadata"] = self._merge_metadata(metadata)
|
||||
|
||||
req = urllib.request.Request(
|
||||
_CONVERSATIONS_URL,
|
||||
data=payload,
|
||||
data=json.dumps(payload).encode("utf-8"),
|
||||
headers={
|
||||
"Authorization": f"Bearer {self._api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"x-sm-source": "hermes",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
@@ -447,6 +466,7 @@ class SupermemoryMemoryProvider(MemoryProvider):
|
||||
self._custom_containers: List[str] = []
|
||||
self._custom_container_instructions = ""
|
||||
self._allowed_containers: List[str] = []
|
||||
self._session_turns: List[Dict[str, str]] = []
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@@ -501,13 +521,13 @@ class SupermemoryMemoryProvider(MemoryProvider):
|
||||
self._search_mode = self._config["search_mode"]
|
||||
self._entity_context = self._config["entity_context"]
|
||||
self._api_timeout = self._config["api_timeout"]
|
||||
|
||||
# Multi-container setup
|
||||
self._enable_custom_containers = self._config["enable_custom_container_tags"]
|
||||
self._custom_containers = self._config["custom_containers"]
|
||||
self._custom_container_instructions = self._config["custom_container_instructions"]
|
||||
self._allowed_containers = [self._container_tag] + list(self._custom_containers)
|
||||
|
||||
self._session_turns = []
|
||||
|
||||
agent_context = kwargs.get("agent_context", "")
|
||||
self._write_enabled = agent_context not in {"cron", "flush", "subagent"}
|
||||
self._active = bool(self._api_key)
|
||||
@@ -534,7 +554,7 @@ class SupermemoryMemoryProvider(MemoryProvider):
|
||||
lines = [
|
||||
"# Supermemory",
|
||||
f"Active. Container: {self._container_tag}.",
|
||||
"Use supermemory_search, supermemory_store, supermemory_forget, and supermemory_profile for explicit memory operations.",
|
||||
"Use supermemory-search, supermemory-save, supermemory-forget, and supermemory-profile (aliases: supermemory_search, supermemory_store, supermemory_forget, supermemory_profile).",
|
||||
]
|
||||
if self._enable_custom_containers and self._custom_containers:
|
||||
tags_str = ", ".join(self._allowed_containers)
|
||||
@@ -567,31 +587,11 @@ class SupermemoryMemoryProvider(MemoryProvider):
|
||||
|
||||
clean_user = _clean_text_for_capture(user_content)
|
||||
clean_assistant = _clean_text_for_capture(assistant_content)
|
||||
if not clean_user or not clean_assistant:
|
||||
if not clean_user and not clean_assistant:
|
||||
return
|
||||
if self._capture_mode == "all":
|
||||
if len(clean_user) < _MIN_CAPTURE_LENGTH or len(clean_assistant) < _MIN_CAPTURE_LENGTH:
|
||||
return
|
||||
if _is_trivial_message(clean_user):
|
||||
return
|
||||
|
||||
content = (
|
||||
f"[role: user]\n{clean_user}\n[user:end]\n\n"
|
||||
f"[role: assistant]\n{clean_assistant}\n[assistant:end]"
|
||||
)
|
||||
metadata = {"source": "hermes", "type": "conversation_turn"}
|
||||
|
||||
def _run():
|
||||
try:
|
||||
self._client.add_memory(content, metadata=metadata, entity_context=self._entity_context)
|
||||
except Exception:
|
||||
logger.debug("Supermemory sync_turn failed", exc_info=True)
|
||||
|
||||
if self._sync_thread and self._sync_thread.is_alive():
|
||||
self._sync_thread.join(timeout=2.0)
|
||||
self._sync_thread = None
|
||||
self._sync_thread = threading.Thread(target=_run, daemon=True, name="supermemory-sync")
|
||||
self._sync_thread.start()
|
||||
# Buffer every turn for the single full-session document written at end/switch/shutdown
|
||||
self._session_turns.append({"user": clean_user, "assistant": clean_assistant})
|
||||
|
||||
def on_session_end(self, messages: List[Dict[str, Any]]) -> None:
|
||||
if not self._active or not self._write_enabled or not self._client or not self._session_id:
|
||||
@@ -609,12 +609,68 @@ class SupermemoryMemoryProvider(MemoryProvider):
|
||||
if len(cleaned) == 1 and len(cleaned[0].get("content", "")) < 20:
|
||||
return
|
||||
try:
|
||||
self._client.ingest_conversation(self._session_id, cleaned)
|
||||
self._client.ingest_conversation(
|
||||
self._session_id,
|
||||
cleaned,
|
||||
metadata={
|
||||
"type": "full_session",
|
||||
"session_id": self._session_id,
|
||||
"message_count": len(cleaned),
|
||||
},
|
||||
)
|
||||
except urllib.error.HTTPError:
|
||||
logger.warning("Supermemory session ingest failed", exc_info=True)
|
||||
except Exception:
|
||||
logger.warning("Supermemory session ingest failed", exc_info=True)
|
||||
|
||||
# Clear buffer so shutdown() doesn't duplicate on normal exit
|
||||
self._session_turns = []
|
||||
|
||||
def on_session_switch(
|
||||
self,
|
||||
new_session_id: str,
|
||||
*,
|
||||
parent_session_id: str = "",
|
||||
reset: bool = False,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
"""Flush any buffered turns from the old session as one document, then reset for the new session."""
|
||||
if not self._active or not self._write_enabled or not self._client:
|
||||
self._session_id = str(new_session_id or "").strip() or self._session_id
|
||||
self._session_turns = []
|
||||
return
|
||||
|
||||
old_session_id = self._session_id
|
||||
old_turns = list(self._session_turns)
|
||||
|
||||
# Flush previous session via conversations ingest (with metadata)
|
||||
if old_turns and old_session_id:
|
||||
messages: list[dict] = []
|
||||
for turn in old_turns:
|
||||
if turn.get("user"):
|
||||
messages.append({"role": "user", "content": turn["user"]})
|
||||
if turn.get("assistant"):
|
||||
messages.append({"role": "assistant", "content": turn["assistant"]})
|
||||
|
||||
try:
|
||||
self._client.ingest_conversation(
|
||||
old_session_id,
|
||||
messages,
|
||||
metadata={
|
||||
"type": "full_session",
|
||||
"session_id": old_session_id,
|
||||
"message_count": len(old_turns) * 2,
|
||||
"partial": not reset,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Supermemory session-switch ingest failed", exc_info=True)
|
||||
|
||||
# Reset for new session
|
||||
self._session_id = str(new_session_id or "").strip() or old_session_id
|
||||
self._session_turns = []
|
||||
self._turn_count = 0
|
||||
|
||||
def on_memory_write(self, action: str, target: str, content: str) -> None:
|
||||
if not self._active or not self._write_enabled or not self._client:
|
||||
return
|
||||
@@ -625,7 +681,7 @@ class SupermemoryMemoryProvider(MemoryProvider):
|
||||
try:
|
||||
self._client.add_memory(
|
||||
content.strip(),
|
||||
metadata={"source": "hermes_memory", "target": target, "type": "explicit_memory"},
|
||||
metadata={"target": target, "type": "explicit_memory"},
|
||||
entity_context=self._entity_context,
|
||||
)
|
||||
except Exception:
|
||||
@@ -638,6 +694,31 @@ class SupermemoryMemoryProvider(MemoryProvider):
|
||||
self._write_thread.start()
|
||||
|
||||
def shutdown(self) -> None:
|
||||
# Emergency fallback (crashes only). Buffer is cleared on normal on_session_end().
|
||||
if self._active and self._write_enabled and self._client and self._session_turns and self._session_id:
|
||||
logger.warning("Supermemory: Saving session via shutdown (session=%s, turns=%d)", self._session_id, len(self._session_turns))
|
||||
|
||||
messages: list[dict] = []
|
||||
for turn in self._session_turns:
|
||||
if turn.get("user"):
|
||||
messages.append({"role": "user", "content": turn["user"]})
|
||||
if turn.get("assistant"):
|
||||
messages.append({"role": "assistant", "content": turn["assistant"]})
|
||||
|
||||
try:
|
||||
self._client.ingest_conversation(
|
||||
self._session_id,
|
||||
messages,
|
||||
metadata={
|
||||
"type": "full_session",
|
||||
"session_id": self._session_id,
|
||||
"message_count": len(self._session_turns) * 2,
|
||||
"partial": True,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Supermemory shutdown ingest failed", exc_info=True)
|
||||
|
||||
for attr_name in ("_prefetch_thread", "_sync_thread", "_write_thread"):
|
||||
thread = getattr(self, attr_name, None)
|
||||
if thread and thread.is_alive():
|
||||
@@ -665,8 +746,25 @@ class SupermemoryMemoryProvider(MemoryProvider):
|
||||
return sanitized
|
||||
|
||||
def get_tool_schemas(self) -> List[Dict[str, Any]]:
|
||||
def with_kebab_aliases(schemas: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
aliases = {
|
||||
"supermemory_store": "supermemory-save",
|
||||
"supermemory_search": "supermemory-search",
|
||||
"supermemory_forget": "supermemory-forget",
|
||||
"supermemory_profile": "supermemory-profile",
|
||||
}
|
||||
expanded = list(schemas)
|
||||
for schema in schemas:
|
||||
kebab = aliases.get(schema.get("name", ""))
|
||||
if not kebab:
|
||||
continue
|
||||
copy = json.loads(json.dumps(schema))
|
||||
copy["name"] = kebab
|
||||
expanded.append(copy)
|
||||
return expanded
|
||||
|
||||
if not self._enable_custom_containers:
|
||||
return [STORE_SCHEMA, SEARCH_SCHEMA, FORGET_SCHEMA, PROFILE_SCHEMA]
|
||||
return with_kebab_aliases([STORE_SCHEMA, SEARCH_SCHEMA, FORGET_SCHEMA, PROFILE_SCHEMA])
|
||||
|
||||
# When multi-container is enabled, add optional container_tag to relevant tools
|
||||
container_param = {
|
||||
@@ -678,7 +776,7 @@ class SupermemoryMemoryProvider(MemoryProvider):
|
||||
schema = json.loads(json.dumps(base)) # deep copy
|
||||
schema["parameters"]["properties"]["container_tag"] = container_param
|
||||
schemas.append(schema)
|
||||
return schemas
|
||||
return with_kebab_aliases(schemas)
|
||||
|
||||
def _tool_store(self, args: dict) -> str:
|
||||
content = str(args.get("content") or "").strip()
|
||||
@@ -692,7 +790,7 @@ class SupermemoryMemoryProvider(MemoryProvider):
|
||||
if not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
metadata.setdefault("type", _detect_category(content))
|
||||
metadata["source"] = "hermes_tool"
|
||||
metadata.pop("source", None)
|
||||
try:
|
||||
result = self._client.add_memory(content, metadata=metadata, entity_context=self._entity_context, container_tag=tag)
|
||||
preview = content[:80] + ("..." if len(content) > 80 else "")
|
||||
@@ -777,6 +875,13 @@ class SupermemoryMemoryProvider(MemoryProvider):
|
||||
def handle_tool_call(self, tool_name: str, args: Dict[str, Any], **kwargs) -> str:
|
||||
if not self._active or not self._client:
|
||||
return tool_error("Supermemory is not configured")
|
||||
aliases = {
|
||||
"supermemory-save": "supermemory_store",
|
||||
"supermemory-search": "supermemory_search",
|
||||
"supermemory-forget": "supermemory_forget",
|
||||
"supermemory-profile": "supermemory_profile",
|
||||
}
|
||||
tool_name = aliases.get(tool_name, tool_name)
|
||||
if tool_name == "supermemory_store":
|
||||
return self._tool_store(args)
|
||||
if tool_name == "supermemory_search":
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: supermemory
|
||||
version: 1.0.0
|
||||
version: 1.0.1
|
||||
description: "Supermemory semantic long-term memory with profile recall, semantic search, explicit memory tools, and session ingest."
|
||||
pip_dependencies:
|
||||
- supermemory
|
||||
|
||||
@@ -70,6 +70,7 @@ AUTHOR_MAP = {
|
||||
"524706+Twanislas@users.noreply.github.com": "Twanislas",
|
||||
"9592417+adam91holt@users.noreply.github.com": "adam91holt",
|
||||
"kchuang1015@users.noreply.github.com": "kchuang1015",
|
||||
"maheshthedev@gmail.com": "MaheshtheDev",
|
||||
"kyssta-exe@users.noreply.github.com": "kyssta-exe",
|
||||
"45688690+fujinice@users.noreply.github.com": "fujinice",
|
||||
"276689385+carltonawong@users.noreply.github.com": "carltonawong",
|
||||
|
||||
@@ -90,7 +90,7 @@ undo_jailbreak()
|
||||
7. **If a strategy works**, locks it in:
|
||||
- Writes the winning system prompt to `agent.system_prompt` in `config.yaml`
|
||||
- Writes prefill messages to `~/.hermes/prefill.json`
|
||||
- Sets `agent.prefill_messages_file: "prefill.json"` in `config.yaml`
|
||||
- Sets `prefill_messages_file: "prefill.json"` in `config.yaml`
|
||||
8. **Reports results** — which strategy won, score, preview of compliant response
|
||||
|
||||
### Strategy order per model family:
|
||||
@@ -171,8 +171,7 @@ Create `~/.hermes/prefill.json`:
|
||||
|
||||
Then set in `~/.hermes/config.yaml`:
|
||||
```yaml
|
||||
agent:
|
||||
prefill_messages_file: "prefill.json"
|
||||
prefill_messages_file: "prefill.json"
|
||||
```
|
||||
|
||||
Prefill messages are injected at the start of every API call, after the system prompt. They are ephemeral — never saved to sessions or trajectories. The model sees them as prior conversation context, establishing a pattern of compliance.
|
||||
|
||||
@@ -397,7 +397,8 @@ def _write_config(system_prompt: str = None, prefill_file: str = None):
|
||||
cfg["agent"]["system_prompt"] = system_prompt
|
||||
|
||||
if prefill_file is not None:
|
||||
cfg["agent"]["prefill_messages_file"] = prefill_file
|
||||
cfg["prefill_messages_file"] = prefill_file
|
||||
cfg["agent"].pop("prefill_messages_file", None)
|
||||
|
||||
with open(CONFIG_PATH, "w") as f:
|
||||
yaml.dump(cfg, f, default_flow_style=False, allow_unicode=True,
|
||||
@@ -721,6 +722,7 @@ def undo_jailbreak(verbose=True):
|
||||
if "agent" in cfg:
|
||||
cfg["agent"].pop("system_prompt", None)
|
||||
cfg["agent"].pop("prefill_messages_file", None)
|
||||
cfg.pop("prefill_messages_file", None)
|
||||
with open(CONFIG_PATH, "w") as f:
|
||||
yaml.dump(cfg, f, default_flow_style=False, allow_unicode=True,
|
||||
width=120, sort_keys=False)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Regression tests for CLI prefill config key compatibility."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import cli
|
||||
|
||||
|
||||
def test_resolve_prefill_messages_file_uses_top_level(monkeypatch):
|
||||
monkeypatch.delenv("HERMES_PREFILL_MESSAGES_FILE", raising=False)
|
||||
|
||||
assert cli._resolve_prefill_messages_file(
|
||||
{
|
||||
"prefill_messages_file": "top.json",
|
||||
"agent": {"prefill_messages_file": "legacy.json"},
|
||||
}
|
||||
) == "top.json"
|
||||
|
||||
|
||||
def test_resolve_prefill_messages_file_accepts_legacy_agent_key(monkeypatch):
|
||||
monkeypatch.delenv("HERMES_PREFILL_MESSAGES_FILE", raising=False)
|
||||
|
||||
assert cli._resolve_prefill_messages_file(
|
||||
{"agent": {"prefill_messages_file": "legacy.json"}}
|
||||
) == "legacy.json"
|
||||
|
||||
|
||||
def test_resolve_prefill_messages_file_prefers_env(monkeypatch):
|
||||
monkeypatch.setenv("HERMES_PREFILL_MESSAGES_FILE", "env.json")
|
||||
|
||||
assert cli._resolve_prefill_messages_file(
|
||||
{
|
||||
"prefill_messages_file": "top.json",
|
||||
"agent": {"prefill_messages_file": "legacy.json"},
|
||||
}
|
||||
) == "env.json"
|
||||
@@ -1546,6 +1546,36 @@ class TestRunJobConfigEnvVarExpansion:
|
||||
"config.yaml ${VAR} was not expanded in the cron execution path."
|
||||
)
|
||||
|
||||
def test_legacy_agent_prefill_messages_file_is_loaded(self, tmp_path, monkeypatch):
|
||||
"""Cron accepts the legacy agent.prefill_messages_file fallback."""
|
||||
prefill = [{"role": "system", "content": "legacy cron prefill"}]
|
||||
(tmp_path / "prefill.json").write_text(json.dumps(prefill), encoding="utf-8")
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
"agent:\n"
|
||||
" prefill_messages_file: prefill.json\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
job = {"id": "prefill-job", "name": "prefill test", "prompt": "hi"}
|
||||
fake_db = MagicMock()
|
||||
|
||||
with patch("cron.scheduler._hermes_home", tmp_path), \
|
||||
patch("cron.scheduler._resolve_origin", return_value=None), \
|
||||
patch("dotenv.load_dotenv"), \
|
||||
patch("hermes_state.SessionDB", return_value=fake_db), \
|
||||
patch("hermes_cli.runtime_provider.resolve_runtime_provider",
|
||||
return_value=self._RUNTIME), \
|
||||
patch("tools.mcp_tool.discover_mcp_tools", return_value=[]), \
|
||||
patch("run_agent.AIAgent") as mock_agent_cls:
|
||||
mock_agent = MagicMock()
|
||||
mock_agent.run_conversation.return_value = {"final_response": "ok"}
|
||||
mock_agent_cls.return_value = mock_agent
|
||||
success, _, _, error = run_job(job)
|
||||
|
||||
assert success is True
|
||||
assert error is None
|
||||
assert mock_agent_cls.call_args.kwargs["prefill_messages"] == prefill
|
||||
|
||||
def test_fallback_model_env_ref_in_config_yaml_is_expanded(self, tmp_path, monkeypatch):
|
||||
"""${VAR} in config.yaml fallback_providers model: is expanded."""
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
|
||||
@@ -33,6 +33,29 @@ def test_load_prefill_messages_expands_env_var_path(monkeypatch, gateway_home):
|
||||
assert gateway_run.GatewayRunner._load_prefill_messages() == prefill
|
||||
|
||||
|
||||
def test_load_prefill_messages_accepts_legacy_agent_key(monkeypatch, gateway_home):
|
||||
prefill = [{"role": "system", "content": "legacy few-shot"}]
|
||||
(gateway_home / "prefill.json").write_text(json.dumps(prefill), encoding="utf-8")
|
||||
_write_config(gateway_home, "agent:\n prefill_messages_file: prefill.json\n")
|
||||
|
||||
assert gateway_run.GatewayRunner._load_prefill_messages() == prefill
|
||||
|
||||
|
||||
def test_load_prefill_messages_prefers_top_level_over_legacy(monkeypatch, gateway_home):
|
||||
top_level = [{"role": "system", "content": "top-level"}]
|
||||
legacy = [{"role": "system", "content": "legacy"}]
|
||||
(gateway_home / "top.json").write_text(json.dumps(top_level), encoding="utf-8")
|
||||
(gateway_home / "legacy.json").write_text(json.dumps(legacy), encoding="utf-8")
|
||||
_write_config(
|
||||
gateway_home,
|
||||
"prefill_messages_file: top.json\n"
|
||||
"agent:\n"
|
||||
" prefill_messages_file: legacy.json\n",
|
||||
)
|
||||
|
||||
assert gateway_run.GatewayRunner._load_prefill_messages() == top_level
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config_body", "env_name", "env_value", "loader_name", "expected"),
|
||||
[
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
"""Tests for ``hermes dashboard register``.
|
||||
|
||||
Covers the CLI half of self-hosted dashboard registration:
|
||||
- Docker-style auto-name generation
|
||||
- not-logged-in fast-fail (AuthError with relogin_required)
|
||||
- managed-install refusal
|
||||
- the happy path: POST shape, env-var writes, custom redirect URI
|
||||
- portal-URL write logic (only when non-default and not already set)
|
||||
- portal HTTP error mapping (401/403)
|
||||
|
||||
The portal HTTP call and the Nous token resolution are both mocked — this
|
||||
file proves the CLI wiring + env-write behaviour. The live end-to-end token
|
||||
round-trip against the Vercel preview build is a separate manual step.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import urllib.error
|
||||
from io import BytesIO
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_cli.dashboard_register as dr
|
||||
|
||||
|
||||
def _ns(**kw):
|
||||
defaults = dict(name=None, redirect_uri=None)
|
||||
defaults.update(kw)
|
||||
return argparse.Namespace(**defaults)
|
||||
|
||||
|
||||
class TestNameGenerator:
|
||||
def test_shape_is_adjective_underscore_noun(self):
|
||||
for _ in range(50):
|
||||
name = dr._generate_dashboard_name()
|
||||
assert "_" in name
|
||||
adj, _, noun = name.partition("_")
|
||||
assert adj in dr._NAME_ADJECTIVES
|
||||
assert noun in dr._NAME_NOUNS
|
||||
|
||||
|
||||
class TestFastFails:
|
||||
def test_not_logged_in_exits_1_with_setup_hint(self, capsys):
|
||||
from hermes_cli.auth import AuthError
|
||||
|
||||
err = AuthError("not logged in", provider="nous", relogin_required=True)
|
||||
with patch.object(dr, "cmd_dashboard_register", dr.cmd_dashboard_register):
|
||||
with patch(
|
||||
"hermes_cli.auth.resolve_nous_access_token", side_effect=err
|
||||
), patch("hermes_cli.config.is_managed", return_value=False):
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
dr.cmd_dashboard_register(_ns())
|
||||
assert exc.value.code == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "not logged into Nous Portal" in out
|
||||
assert "hermes setup" in out
|
||||
|
||||
def test_managed_install_refuses(self, capsys):
|
||||
with patch("hermes_cli.config.is_managed", return_value=True):
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
dr.cmd_dashboard_register(_ns())
|
||||
assert exc.value.code == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "not available in a managed" in out
|
||||
|
||||
|
||||
def _fake_http_ok(payload: dict):
|
||||
"""Return a context-manager urlopen stub yielding `payload` as JSON."""
|
||||
cm = MagicMock()
|
||||
cm.__enter__.return_value.read.return_value = json.dumps(payload).encode()
|
||||
return cm
|
||||
|
||||
|
||||
class TestHappyPath:
|
||||
def _run(self, *, args, account_token="tok_abc", portal="https://portal.nousresearch.com",
|
||||
response=None, captured=None):
|
||||
response = response or {
|
||||
"client_id": "agent:selfhost-1",
|
||||
"id": "selfhost-1",
|
||||
"name": "dreamy_tesla",
|
||||
"kind": "SELF_HOSTED",
|
||||
"custom_redirect_uri": None,
|
||||
"created_at": "2026-06-04T12:00:00.000Z",
|
||||
}
|
||||
|
||||
def fake_urlopen(req, timeout=None):
|
||||
if captured is not None:
|
||||
captured["url"] = req.full_url
|
||||
captured["headers"] = dict(req.header_items())
|
||||
captured["body"] = json.loads(req.data.decode())
|
||||
return _fake_http_ok(response)
|
||||
|
||||
saved = {}
|
||||
|
||||
def fake_save(key, value):
|
||||
saved[key] = value
|
||||
|
||||
with patch(
|
||||
"hermes_cli.auth.resolve_nous_access_token", return_value=account_token
|
||||
), patch("hermes_cli.config.is_managed", return_value=False), patch.object(
|
||||
dr, "_resolve_portal_base_url", return_value=portal
|
||||
), patch(
|
||||
"hermes_cli.config.get_env_value", return_value=None
|
||||
), patch(
|
||||
"hermes_cli.config.save_env_value", side_effect=fake_save
|
||||
), patch.object(
|
||||
dr.urllib.request, "urlopen", side_effect=fake_urlopen
|
||||
):
|
||||
dr.cmd_dashboard_register(args)
|
||||
return saved
|
||||
|
||||
def test_writes_client_id_and_posts_generated_name(self, capsys):
|
||||
captured: dict = {}
|
||||
saved = self._run(args=_ns(), captured=captured)
|
||||
|
||||
# POST shape
|
||||
assert captured["url"].endswith("/api/oauth/self-hosted-client")
|
||||
assert captured["headers"]["Authorization"] == "Bearer tok_abc"
|
||||
assert "name" in captured["body"] and captured["body"]["name"]
|
||||
assert "custom_redirect_uri" not in captured["body"]
|
||||
|
||||
# env write: client_id present, portal URL NOT written (default portal)
|
||||
assert saved["HERMES_DASHBOARD_OAUTH_CLIENT_ID"] == "agent:selfhost-1"
|
||||
assert "HERMES_DASHBOARD_PORTAL_URL" not in saved
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Registered dashboard" in out
|
||||
assert "non-loopback bind" in out # the gate-engagement hint
|
||||
|
||||
def test_explicit_name_is_sent(self, capsys):
|
||||
captured: dict = {}
|
||||
self._run(args=_ns(name="my_box"), captured=captured)
|
||||
assert captured["body"]["name"] == "my_box"
|
||||
|
||||
def test_custom_redirect_uri_is_forwarded(self, capsys):
|
||||
captured: dict = {}
|
||||
self._run(
|
||||
args=_ns(redirect_uri="https://hermes.example.com/auth/callback"),
|
||||
captured=captured,
|
||||
)
|
||||
assert (
|
||||
captured["body"]["custom_redirect_uri"]
|
||||
== "https://hermes.example.com/auth/callback"
|
||||
)
|
||||
|
||||
def test_non_default_portal_is_persisted(self, capsys):
|
||||
saved = self._run(
|
||||
args=_ns(),
|
||||
portal="https://nous-account-service-git-feat-x.vercel.app",
|
||||
)
|
||||
assert (
|
||||
saved["HERMES_DASHBOARD_PORTAL_URL"]
|
||||
== "https://nous-account-service-git-feat-x.vercel.app"
|
||||
)
|
||||
|
||||
|
||||
class TestPortalResolution:
|
||||
def test_override_arg_wins(self):
|
||||
assert (
|
||||
dr._resolve_portal_base_url("https://preview.example.com/")
|
||||
== "https://preview.example.com"
|
||||
)
|
||||
|
||||
def test_falls_back_to_stored_login_portal(self):
|
||||
with patch(
|
||||
"hermes_cli.auth.get_provider_auth_state",
|
||||
return_value={"portal_base_url": "https://portal.staging-nousresearch.com"},
|
||||
):
|
||||
assert (
|
||||
dr._resolve_portal_base_url(None)
|
||||
== "https://portal.staging-nousresearch.com"
|
||||
)
|
||||
|
||||
def test_blank_override_ignored(self):
|
||||
with patch(
|
||||
"hermes_cli.auth.get_provider_auth_state",
|
||||
return_value={"portal_base_url": "https://portal.staging-nousresearch.com"},
|
||||
):
|
||||
assert (
|
||||
dr._resolve_portal_base_url(" ")
|
||||
== "https://portal.staging-nousresearch.com"
|
||||
)
|
||||
|
||||
|
||||
class TestPortalErrors:
|
||||
def _run_http_error(self, code, body):
|
||||
err = urllib.error.HTTPError(
|
||||
url="https://portal.nousresearch.com/api/oauth/self-hosted-client",
|
||||
code=code,
|
||||
msg="err",
|
||||
hdrs=None,
|
||||
fp=BytesIO(json.dumps(body).encode()),
|
||||
)
|
||||
|
||||
with patch(
|
||||
"hermes_cli.auth.resolve_nous_access_token", return_value="tok"
|
||||
), patch("hermes_cli.config.is_managed", return_value=False), patch.object(
|
||||
dr, "_resolve_portal_base_url", return_value="https://portal.nousresearch.com"
|
||||
), patch.object(dr.urllib.request, "urlopen", side_effect=err):
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
dr.cmd_dashboard_register(_ns())
|
||||
return exc.value.code
|
||||
|
||||
def test_401_maps_to_reauth_message(self, capsys):
|
||||
code = self._run_http_error(401, {"error": "invalid_token"})
|
||||
assert code == 1
|
||||
assert "re-authenticate" in capsys.readouterr().out
|
||||
|
||||
def test_403_surfaces_server_detail(self, capsys):
|
||||
code = self._run_http_error(
|
||||
403, {"error": "access_denied", "error_description": "Not permitted here."}
|
||||
)
|
||||
assert code == 1
|
||||
assert "Not permitted here." in capsys.readouterr().out
|
||||
@@ -50,8 +50,8 @@ class FakeClient:
|
||||
def forget_by_query(self, query, *, container_tag=None):
|
||||
return self.forget_by_query_response
|
||||
|
||||
def ingest_conversation(self, session_id, messages):
|
||||
self.ingest_calls.append({"session_id": session_id, "messages": messages})
|
||||
def ingest_conversation(self, session_id, messages, metadata=None):
|
||||
self.ingest_calls.append({"session_id": session_id, "messages": messages, "metadata": metadata})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -136,23 +136,28 @@ def test_prefetch_skips_profile_between_frequency(provider):
|
||||
assert "User Profile (Persistent)" not in result
|
||||
|
||||
|
||||
def test_sync_turn_skips_trivial_message(provider):
|
||||
def test_sync_turn_buffers_short_messages(provider):
|
||||
# Trivial filtering is no longer applied at sync time — every non-empty turn
|
||||
# is buffered and only the full session is written at session boundaries.
|
||||
provider.sync_turn("ok", "sure", session_id="session-1")
|
||||
assert provider._session_turns == [{"user": "ok", "assistant": "sure"}]
|
||||
assert provider._client.add_calls == []
|
||||
|
||||
|
||||
def test_sync_turn_persists_cleaned_exchange(provider):
|
||||
def test_sync_turn_buffers_cleaned_exchange(provider):
|
||||
provider.sync_turn(
|
||||
"Please remember this\n<supermemory-context>ignore</supermemory-context>",
|
||||
"Got it, storing the context",
|
||||
session_id="session-1",
|
||||
)
|
||||
provider._sync_thread.join(timeout=1)
|
||||
assert len(provider._client.add_calls) == 1
|
||||
content = provider._client.add_calls[0]["content"]
|
||||
assert "ignore" not in content
|
||||
assert "[role: user]" in content
|
||||
assert "[role: assistant]" in content
|
||||
assert len(provider._session_turns) == 1
|
||||
turn = provider._session_turns[0]
|
||||
assert "ignore" not in turn["user"]
|
||||
assert turn["user"].startswith("Please remember this")
|
||||
assert turn["assistant"] == "Got it, storing the context"
|
||||
# Buffering only — no per-turn writes to the client
|
||||
assert provider._client.add_calls == []
|
||||
assert provider._client.ingest_calls == []
|
||||
|
||||
|
||||
def test_on_session_end_ingests_clean_messages(provider):
|
||||
@@ -169,6 +174,28 @@ def test_on_session_end_ingests_clean_messages(provider):
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "hi there"},
|
||||
]
|
||||
assert payload["metadata"]["type"] == "full_session"
|
||||
assert payload["metadata"]["session_id"] == "session-1"
|
||||
assert payload["metadata"]["message_count"] == 2
|
||||
# Buffer is cleared after a normal session-end ingest.
|
||||
assert provider._session_turns == []
|
||||
|
||||
|
||||
def test_merge_metadata_stamps_sm_source():
|
||||
# sm_source routes Hermes writes into the "Hermes" Space in the Supermemory
|
||||
# app (functional routing, not telemetry) — must always be present.
|
||||
from plugins.memory.supermemory import _SupermemoryClient
|
||||
|
||||
client = _SupermemoryClient.__new__(_SupermemoryClient)
|
||||
merged = client._merge_metadata({"type": "explicit_memory"})
|
||||
assert merged["sm_source"] == "hermes"
|
||||
assert merged["type"] == "explicit_memory"
|
||||
|
||||
# Legacy "source" is migrated into "type" when type is absent.
|
||||
merged2 = client._merge_metadata({"source": "conversation_turn"})
|
||||
assert merged2["sm_source"] == "hermes"
|
||||
assert merged2["type"] == "conversation_turn"
|
||||
assert "source" not in merged2
|
||||
|
||||
|
||||
def test_on_memory_write_tracks_thread(provider):
|
||||
@@ -179,7 +206,7 @@ def test_on_memory_write_tracks_thread(provider):
|
||||
assert provider._client.add_calls[0]["metadata"]["type"] == "explicit_memory"
|
||||
|
||||
|
||||
def test_shutdown_joins_and_clears_threads(provider, monkeypatch):
|
||||
def test_shutdown_joins_threads_and_flushes_buffer(provider, monkeypatch):
|
||||
started = threading.Event()
|
||||
release = threading.Event()
|
||||
|
||||
@@ -196,15 +223,16 @@ def test_shutdown_joins_and_clears_threads(provider, monkeypatch):
|
||||
|
||||
monkeypatch.setattr(provider._client, "add_memory", slow_add_memory)
|
||||
|
||||
# sync_turn now only buffers — no thread is spawned.
|
||||
provider.sync_turn(
|
||||
"Please remember this request in long-term memory",
|
||||
"Absolutely, I will keep that in long-term memory.",
|
||||
session_id="session-1",
|
||||
)
|
||||
assert started.wait(timeout=1)
|
||||
assert provider._sync_thread is not None
|
||||
assert provider._sync_thread is None
|
||||
assert len(provider._session_turns) == 1
|
||||
|
||||
started.clear()
|
||||
# on_memory_write still runs on a background thread.
|
||||
provider.on_memory_write("add", "memory", "Jordan likes concise docs")
|
||||
assert started.wait(timeout=1)
|
||||
assert provider._write_thread is not None
|
||||
@@ -212,10 +240,18 @@ def test_shutdown_joins_and_clears_threads(provider, monkeypatch):
|
||||
release.set()
|
||||
provider.shutdown()
|
||||
|
||||
# All tracked threads joined and cleared.
|
||||
assert provider._sync_thread is None
|
||||
assert provider._write_thread is None
|
||||
assert provider._prefetch_thread is None
|
||||
assert len(provider._client.add_calls) == 2
|
||||
# Explicit memory write went through.
|
||||
assert len(provider._client.add_calls) == 1
|
||||
# Buffered turn was flushed as a partial full-session ingest.
|
||||
assert len(provider._client.ingest_calls) == 1
|
||||
payload = provider._client.ingest_calls[0]
|
||||
assert payload["session_id"] == "session-1"
|
||||
assert payload["metadata"]["partial"] is True
|
||||
assert payload["metadata"]["type"] == "full_session"
|
||||
|
||||
|
||||
def test_store_tool_returns_saved_payload(provider):
|
||||
|
||||
@@ -66,7 +66,7 @@ AI-native cross-session user modeling with dialectic reasoning, session-scoped c
|
||||
hermes memory setup # select "honcho" — runs the Honcho-specific post-setup
|
||||
```
|
||||
|
||||
On a fresh install, configure Honcho directly with `hermes memory setup honcho`. The legacy `hermes honcho setup` command still works (it now redirects to `hermes memory setup`), but is only registered after Honcho is selected as the active memory provider.
|
||||
The legacy `hermes honcho setup` command still works (it now redirects to `hermes memory setup`), but is only registered after Honcho is selected as the active memory provider.
|
||||
|
||||
**Config:** `$HERMES_HOME/honcho.json` (profile-local) or `~/.honcho/config.json` (global). Resolution order: `$HERMES_HOME/honcho.json` > `~/.hermes/honcho.json` > `~/.honcho/config.json`. See the [config reference](https://github.com/NousResearch/hermes-agent/blob/main/plugins/memory/honcho/README.md) and the [Honcho integration guide](https://docs.honcho.dev/v3/guides/integrations/hermes).
|
||||
|
||||
@@ -498,11 +498,11 @@ echo 'SUPERMEMORY_API_KEY=***' >> ~/.hermes/.env
|
||||
|
||||
**Key features:**
|
||||
- Automatic context fencing — strips recalled memories from captured turns to prevent recursive memory pollution
|
||||
- Session-end conversation ingest for richer graph-level knowledge building
|
||||
- Full-session ingest — the entire conversation is sent once at session boundaries
|
||||
- Session-end conversation ingest (to `/v4/conversations`) for richer profile + graph building in Supermemory
|
||||
- Profile facts injected on first turn and at configurable intervals
|
||||
- Trivial message filtering (skips "ok", "thanks", etc.)
|
||||
- **Profile-scoped containers** — use `{identity}` in `container_tag` (e.g. `hermes-{identity}` → `hermes-coder`) to isolate memories per Hermes profile
|
||||
- **Multi-container mode** — enable `enable_custom_container_tags` with a `custom_containers` list to let the agent read/write across named containers. Automatic operations (sync, prefetch) stay on the primary container.
|
||||
- **Multi-container mode** — enable `enable_custom_container_tags` with a `custom_containers` list to let the agent read/write across named containers. Automatic operations stay on the primary container.
|
||||
|
||||
<details>
|
||||
<summary>Multi-container example</summary>
|
||||
|
||||
@@ -108,7 +108,7 @@ undo_jailbreak()
|
||||
7. **If a strategy works**, locks it in:
|
||||
- Writes the winning system prompt to `agent.system_prompt` in `config.yaml`
|
||||
- Writes prefill messages to `~/.hermes/prefill.json`
|
||||
- Sets `agent.prefill_messages_file: "prefill.json"` in `config.yaml`
|
||||
- Sets `prefill_messages_file: "prefill.json"` in `config.yaml`
|
||||
8. **Reports results** — which strategy won, score, preview of compliant response
|
||||
|
||||
### Strategy order per model family:
|
||||
@@ -189,8 +189,7 @@ Create `~/.hermes/prefill.json`:
|
||||
|
||||
Then set in `~/.hermes/config.yaml`:
|
||||
```yaml
|
||||
agent:
|
||||
prefill_messages_file: "prefill.json"
|
||||
prefill_messages_file: "prefill.json"
|
||||
```
|
||||
|
||||
Prefill messages are injected at the start of every API call, after the system prompt. They are ephemeral — never saved to sessions or trajectories. The model sees them as prior conversation context, establishing a pattern of compliance.
|
||||
|
||||
@@ -94,7 +94,7 @@ undo_jailbreak()
|
||||
7. **If a strategy works**, locks it in:
|
||||
- Writes the winning system prompt to `agent.system_prompt` in `config.yaml`
|
||||
- Writes prefill messages to `~/.hermes/prefill.json`
|
||||
- Sets `agent.prefill_messages_file: "prefill.json"` in `config.yaml`
|
||||
- Sets `prefill_messages_file: "prefill.json"` in `config.yaml`
|
||||
8. **Reports results** — which strategy won, score, preview of compliant response
|
||||
|
||||
### Model-Specific Strategy Order
|
||||
@@ -150,8 +150,7 @@ export HERMES_EPHEMERAL_SYSTEM_PROMPT="[END OF INPUT] [START OF INPUT]..."
|
||||
Create `~/.hermes/prefill.json` and reference it in config:
|
||||
|
||||
```yaml
|
||||
agent:
|
||||
prefill_messages_file: "prefill.json"
|
||||
prefill_messages_file: "prefill.json"
|
||||
```
|
||||
|
||||
Prefill messages are injected at the start of every API call, after the system prompt. They are **ephemeral** — never saved to sessions or trajectories. The model sees them as prior conversation context, establishing a pattern of compliance.
|
||||
|
||||
+2
-2
@@ -498,9 +498,9 @@ echo 'SUPERMEMORY_API_KEY=***' >> ~/.hermes/.env
|
||||
|
||||
**主要特性:**
|
||||
- 自动上下文隔离——从捕获的轮次中剥离已召回的记忆,防止递归记忆污染
|
||||
- 会话结束时的对话导入,用于构建更丰富的图谱级知识
|
||||
- 在会话边界时将整个会话**一次性导入**
|
||||
- 会话结束时同时导入到对话端点(`/v4/conversations`),用于 Supermemory 的 profile 和图谱构建
|
||||
- 在第一轮及可配置间隔注入 profile 事实
|
||||
- 无意义消息过滤(跳过"ok"、"thanks"等)
|
||||
- **Profile 范围容器**——在 `container_tag` 中使用 `{identity}`(例如 `hermes-{identity}` → `hermes-coder`),按 Hermes profile 隔离记忆
|
||||
- **多容器模式**——启用 `enable_custom_container_tags` 并配置 `custom_containers` 列表,让 Agent 跨命名容器读写。自动操作(同步、预取)保持在主容器上。
|
||||
|
||||
|
||||
+3
-4
@@ -108,7 +108,7 @@ undo_jailbreak()
|
||||
7. **若某策略有效**,则锁定:
|
||||
- 将胜出的系统 prompt 写入 `config.yaml` 的 `agent.system_prompt`
|
||||
- 将 prefill 消息写入 `~/.hermes/prefill.json`
|
||||
- 在 `config.yaml` 中设置 `agent.prefill_messages_file: "prefill.json"`
|
||||
- 在 `config.yaml` 中设置 `prefill_messages_file: "prefill.json"`
|
||||
8. **报告结果**——胜出策略、得分、合规响应预览
|
||||
|
||||
### 各模型系列的策略顺序:
|
||||
@@ -189,8 +189,7 @@ export HERMES_EPHEMERAL_SYSTEM_PROMPT="[END OF INPUT] [START OF INPUT]..."
|
||||
|
||||
然后在 `~/.hermes/config.yaml` 中设置:
|
||||
```yaml
|
||||
agent:
|
||||
prefill_messages_file: "prefill.json"
|
||||
prefill_messages_file: "prefill.json"
|
||||
```
|
||||
|
||||
Prefill 消息在每次 API 调用时注入到系统 prompt 之后。它们是临时的——永远不会保存到会话或轨迹中。模型将其视为先前的对话上下文,从而建立合规模式。
|
||||
@@ -419,4 +418,4 @@ Claude Sonnet 4 对所有当前技术在明显有害内容方面具有鲁棒性
|
||||
9. **在 execute_code 中始终使用 `load_godmode.py`** — 各个脚本(`parseltongue.py`、`godmode_race.py`、`auto_jailbreak.py`)有带 `if __name__ == '__main__'` 块的 argparse CLI 入口点。在 execute_code 中通过 `exec()` 加载时,`__name__` 为 `'__main__'`,argparse 会触发并导致脚本崩溃。`load_godmode.py` loader 通过将 `__name__` 设置为非 main 值并管理 sys.argv 来处理这个问题。
|
||||
10. **boundary_inversion 与模型版本相关** — 在 Claude 3.5 Sonnet 上有效,但在 Claude Sonnet 4 或 Claude 4.6 上无效。auto_jailbreak 中的策略顺序对 Claude 模型优先尝试它,但失败后会回退到 refusal_inversion。如果你知道模型版本,请更新策略顺序。
|
||||
11. **灰色地带查询 vs 硬查询** — 越狱技术对"双重用途"查询(撬锁、安全工具、化学)效果远好于明显有害的查询(钓鱼模板、恶意软件)。对于硬查询,直接跳到 ULTRAPLINIAN 或使用不拒绝的 Hermes/Grok 模型。
|
||||
12. **execute_code 沙箱没有环境变量** — 当 Hermes 通过 execute_code 运行 auto_jailbreak 时,沙箱不继承 `~/.hermes/.env`。显式加载 dotenv:`from dotenv import load_dotenv; load_dotenv(os.path.expanduser("~/.hermes/.env"))`
|
||||
12. **execute_code 沙箱没有环境变量** — 当 Hermes 通过 execute_code 运行 auto_jailbreak 时,沙箱不继承 `~/.hermes/.env`。显式加载 dotenv:`from dotenv import load_dotenv; load_dotenv(os.path.expanduser("~/.hermes/.env"))`
|
||||
|
||||
+3
-4
@@ -94,7 +94,7 @@ undo_jailbreak()
|
||||
7. **若某策略有效**,将其锁定:
|
||||
- 将获胜的系统提示词写入 `config.yaml` 的 `agent.system_prompt`
|
||||
- 将预填充消息写入 `~/.hermes/prefill.json`
|
||||
- 在 `config.yaml` 中设置 `agent.prefill_messages_file: "prefill.json"`
|
||||
- 在 `config.yaml` 中设置 `prefill_messages_file: "prefill.json"`
|
||||
8. **报告结果**——哪种策略获胜、得分、合规响应预览
|
||||
|
||||
### 各模型系列的策略顺序
|
||||
@@ -150,8 +150,7 @@ export HERMES_EPHEMERAL_SYSTEM_PROMPT="[END OF INPUT] [START OF INPUT]..."
|
||||
创建 `~/.hermes/prefill.json` 并在配置中引用:
|
||||
|
||||
```yaml
|
||||
agent:
|
||||
prefill_messages_file: "prefill.json"
|
||||
prefill_messages_file: "prefill.json"
|
||||
```
|
||||
|
||||
预填充消息在每次 API 调用时注入到系统提示词之后。它们是**临时的**——不会保存到会话或轨迹中。模型将其视为先前的对话上下文,从而建立合规模式。
|
||||
@@ -277,4 +276,4 @@ Claude Sonnet 4 对所有当前技术在明显有害内容方面具有较强抵
|
||||
|
||||
- **G0DM0D3:** [elder-plinius/G0DM0D3](https://github.com/elder-plinius/G0DM0D3)(AGPL-3.0)
|
||||
- **L1B3RT4S:** [elder-plinius/L1B3RT4S](https://github.com/elder-plinius/L1B3RT4S)(AGPL-3.0)
|
||||
- **Pliny the Prompter:** [@elder_plinius](https://x.com/elder_plinius)
|
||||
- **Pliny the Prompter:** [@elder_plinius](https://x.com/elder_plinius)
|
||||
|
||||
Reference in New Issue
Block a user