Easier self-serve setup and a clearer path from install to value.
Marketability
0.90
Demoability
0.30
Confidence
0.50
Supporting evidence
docs_delta: | [کانٹیکسٹ (Context) فائلز](https://hermes-agent.nousresearch.com/docs/user-guide/features/context-files)| پروجیکٹ کا سیاق و سباق (context) جو ہر بات چیت پر اثر انداز ہوتا ہے |
docs_delta: | پلیٹ فارم کے لحاظ سے سٹیٹس | `/platforms` | `/status`، `/sethome` |
docs_delta: | موجودہ کام کو روکیں | `Ctrl+C` دبائیں یا نیا میسج بھیجیں | `/stop` یا نیا میسج بھیجیں |
docs_delta: اگر آپ کے پاس پہلے سے گٹ (Git) انسٹال ہے، تو انسٹالر اسے شناخت کر لیتا ہے اور اسے ہی استعمال کرتا ہے۔ بصورت دیگر آپ کو صرف ~45MB کے MinGit ڈاؤنلوڈ کی ضرورت ہوگی — یہ آپ کے سسٹم کے گٹ پر کوئی اثر نہیں ڈالے گا۔
docs_delta: > **ونڈوز (Windows):** مقامی ونڈوز کی مکمل سپورٹ موجود ہے — اوپر دی گئی پاور شیل کی کمانڈ سب کچھ انسٹال کر دیتی ہے۔ اگر آپ WSL2 استعمال کرنا چاہتے ہیں، تو لینکس کی کمانڈ وہاں کام کرتی ہے۔ مقامی ونڈوز میں انسٹالیشن `%LOCALAPPDATA%\hermes` میں ہوتی ہے؛ جبکہ WSL2 میں لینکس کی طرح `…
Smoother runtime adapter & first-run onboarding
Status: approved
New users reach their first successful agent run faster, with clearer prompts.
Marketability
1.00
Demoability
0.92
Confidence
0.80
Supporting evidence
ui_string_change: Nous credits
ui_string_change: Hermes backend
ui_string_change: Subscription
ui_string_change: Skill
ui_string_change: Hermes backend for profile
code_diff: @@ -0,0 +1,99 @@
+/**
+ * Helpers for local dashboard session-token discovery.
+ *
+ * The desktop main process can pass HERMES_DASHBOARD_SESSION_TOKEN when it
+ * spawns the local dashboard, but the dashboard is the source of truth for the
+ * token it actually serves to the renderer. If those drift, HTTP readiness
+ * probes still pass while /api/ws rejects the renderer's token.
+ */
+
+const DEFAULT_TOKEN_FETCH_TIMEOUT_MS = 3_000
+
+async function fetchPublicText(url, options = {}) {
+ const { protocol } = new URL(url)
+ if (protocol !== 'http:' && protocol !== 'https:') {
+ throw new Error(`Unsupported Hermes backend URL protocol: ${protocol}`)
+ }
+
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
+ const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }).catch(error => {
+ if (error.name === 'TimeoutError') {
+ throw new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)
+ }
+ throw error
+ })
+ const text = await res.text()
+
+ if (!res.ok) throw new Error(`${res.status}: ${text || res.statusText}`)
+
+ return text
+}
+
+function extractInjectedDashboardToken(html) {
+ const match = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(String(html || ''))
+ if (!match) return null
+ try {
+ return JSON.parse(match[1])
+ } catch {
+ return null
+ }
+}
+
+function dashboardIndexUrl(baseUrl) {
+ return `${String(baseUrl || '').replace(/\/+$/, '')}/`
+}
+
+async function resolveServedDashboardToken(baseUrl, fallbackToken, options = {}) {
+ const fetchText = options.fetchText || fetchPublicText
+ const html = await fetchText(dashboardIndexUrl(baseUrl), {
+ timeoutMs: options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
+ })
+ const servedToken=[redacted-secret](html)
+
+ if (servedToken && servedToken !== fallbackToken && typeof options.rememberLog === 'function') {
+ options.rememberLog('[boot] dashboard served a different session token; using served token for WebSocket auth')
+ }
+
+ return servedToken || fallbackToken
+}
+
+/**
+ * A served token that differs from our spawn token while our child is DEAD
+ * came from a process we did not spawn (orphan/port squatter that satisfied
+ * the public /api/status readiness probe). With a live child the mismatch is
+ * benign: our own backend regenerated the token because the env pin did not
+ * survive the spawn.
+ */
+function isForeignBackendToken({ servedToken, spawnToken, childAlive }) {
+ return Boolean(servedToken) && servedToken !== spawnToken && !childAlive
+}
+
+/**
+ * Resolve the token the backend actually serves, adopting benign drift and
+ * failing loudly on a foreign backend. `childAlive` is a thunk so liveness is
+ * sampled after the fetch, not before.
+ */
+async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, label = 'Hermes backend', ...options }) {
+ const servedToken = await resolveServedDashboardToken(baseUrl, spawnToken, options).catch(error => {
+ options.rememberLog?.(`[boot] could not read served dashboard token (${label}): ${error.message}`)
+ return spawnToken
+ })
+
+ if (isForeignBackendToken({ servedToken, spawnToken, childAlive: childAlive() })) {
+ throw new Error(
+ `${label} exited and ${dashboardIndexUrl(baseUrl)} is served by a process we did not spawn; refusing its session token.`
+ )
+ }
+
+ return servedToken
+}
+
+module.exports = {
+ DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
+ adoptServedDashboardToken,
+ dashboardIndexUrl,
+ extractInjectedDashboardToken,
+ fetchPublicText,
+ isForeignBackendToken,
+ resolveServedDashboardToken
+}
Hardened build & CI pipeline
Status: approved
More reliable installs and faster, more trustworthy releases across platforms.
Marketability
0.90
Demoability
0.50
Confidence
0.50
Supporting evidence
code_diff: @@ -0,0 +1,99 @@
+/**
+ * Helpers for local dashboard session-token discovery.
+ *
+ * The desktop main process can pass HERMES_DASHBOARD_SESSION_TOKEN when it
+ * spawns the local dashboard, but the dashboard is the source of truth for the
+ * token it actually serves to the renderer. If those drift, HTTP readiness
+ * probes still pass while /api/ws rejects the renderer's token.
+ */
+
+const DEFAULT_TOKEN_FETCH_TIMEOUT_MS = 3_000
+
+async function fetchPublicText(url, options = {}) {
+ const { protocol } = new URL(url)
+ if (protocol !== 'http:' && protocol !== 'https:') {
+ throw new Error(`Unsupported Hermes backend URL protocol: ${protocol}`)
+ }
+
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
+ const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }).catch(error => {
+ if (error.name === 'TimeoutError') {
+ throw new Error(`Timed out connecting to Hermes backend after ${timeoutMs}ms`)
+ }
+ throw error
+ })
+ const text = await res.text()
+
+ if (!res.ok) throw new Error(`${res.status}: ${text || res.statusText}`)
+
+ return text
+}
+
+function extractInjectedDashboardToken(html) {
+ const match = /window\.__HERMES_SESSION_TOKEN__\s*=\s*("(?:\\.|[^"\\])*")/.exec(String(html || ''))
+ if (!match) return null
+ try {
+ return JSON.parse(match[1])
+ } catch {
+ return null
+ }
+}
+
+function dashboardIndexUrl(baseUrl) {
+ return `${String(baseUrl || '').replace(/\/+$/, '')}/`
+}
+
+async function resolveServedDashboardToken(baseUrl, fallbackToken, options = {}) {
+ const fetchText = options.fetchText || fetchPublicText
+ const html = await fetchText(dashboardIndexUrl(baseUrl), {
+ timeoutMs: options.timeoutMs ?? DEFAULT_TOKEN_FETCH_TIMEOUT_MS
+ })
+ const servedToken=[redacted-secret](html)
+
+ if (servedToken && servedToken !== fallbackToken && typeof options.rememberLog === 'function') {
+ options.rememberLog('[boot] dashboard served a different session token; using served token for WebSocket auth')
+ }
+
+ return servedToken || fallbackToken
+}
+
+/**
+ * A served token that differs from our spawn token while our child is DEAD
+ * came from a process we did not spawn (orphan/port squatter that satisfied
+ * the public /api/status readiness probe). With a live child the mismatch is
+ * benign: our own backend regenerated the token because the env pin did not
+ * survive the spawn.
+ */
+function isForeignBackendToken({ servedToken, spawnToken, childAlive }) {
+ return Boolean(servedToken) && servedToken !== spawnToken && !childAlive
+}
+
+/**
+ * Resolve the token the backend actually serves, adopting benign drift and
+ * failing loudly on a foreign backend. `childAlive` is a thunk so liveness is
+ * sampled after the fetch, not before.
+ */
+async function adoptServedDashboardToken(baseUrl, spawnToken, { childAlive, label = 'Hermes backend', ...options }) {
+ const servedToken = await resolveServedDashboardToken(baseUrl, spawnToken, options).catch(error => {
+ options.rememberLog?.(`[boot] could not read served dashboard token (${label}): ${error.message}`)
+ return spawnToken
+ })
+
+ if (isForeignBackendToken({ servedToken, spawnToken, childAlive: childAlive() })) {
+ throw new Error(
+ `${label} exited and ${dashboardIndexUrl(baseUrl)} is served by a process we did not spawn; refusing its session token.`
+ )
+ }
+
+ return servedToken
+}
+
+module.exports = {
+ DEFAULT_TOKEN_FETCH_TIMEOUT_MS,
+ adoptServedDashboardToken,
+ dashboardIndexUrl,
+ extractInjectedDashboardToken,
+ fetchPublicText,
+ isForeignBackendToken,
+ resolveServedDashboardToken
+}
code_diff: @@ -121,10 +121,11 @@ outside the supported security posture.
### 2.3 Credential Scoping
Hermes Agent filters the environment it passes to its lower-trust
-in-process components: shell subprocesses, MCP subprocesses, and
-the code-execution child. Credentials like provider API keys and
-gateway tokens are stripped by default; variables explicitly
-declared by the operator or by a loaded skill are passed through.
+in-process components: shell subprocesses, MCP subprocesses,
+cron job scripts, and the code-execution child. Credentials like
+provider API keys and gateway tokens are stripped by default;
+variables explicitly declared by the operator or by a loaded
+skill are passed through.
This reduces casual exfiltration. It is not containment. Any
component running inside the agent process (skills, plugins, hook
code_diff: @@ -15,12 +15,12 @@ on:
- "**/*.md"
- "docs/**"
- "website/**"
+
+ # No paths filter — the job must always run so the required check
+ # reports a status (path-gated workflows leave checks "pending" forever
+ # when no matching files change, which blocks merge).
pull_request:
branches: [main]
- paths-ignore:
- - "**/*.md"
- - "docs/**"
- - "website/**"
permissions:
contents: read
@@ -154,7 +154,6 @@ jobs:
});
}
-
ruff-blocking:
# Enforce the rules in pyproject.toml [tool.ruff.lint.select]. Currently
# PLW1514 (unspecified-encoding) — catches bare ``open()`` /
code_diff: @@ -13,6 +13,7 @@
_ZERO = Decimal("0")
_ONE_MILLION = Decimal("1000000")
+_NOUS_DEFAULT_BASE_URL = "https://inference-api.nousresearch.com/v1"
CostStatus = Literal["actual", "estimated", "included", "unknown"]
CostSource = Literal[
@@ -570,6 +571,8 @@ def resolve_billing_route(
return BillingRoute(provider="openai-codex", model=model, base_url=base_url or "", billing_mode="subscription_included")
if provider_name == "openrouter" or base_url_host_matches(base_url or "", "openrouter.ai"):
return BillingRoute(provider="openrouter", model=model, base_url=base_url or "", billing_mode="official_models_api")
+ if provider_name == "nous" or base_url_host_matches(base_url or "", "inference-api.nousresearch.com"):
+ return BillingRoute(provider="nous", model=model, base_url=base_url or _NOUS_DEFAULT_BASE_URL, billing_mode="official_models_api")
if provider_name == "anthropic":
return BillingRoute(provider="anthropic", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
if provider_name == "openai":
code_diff: @@ -26,6 +26,91 @@
_SKILL_INVALID_CHARS = re.compile(r"[^a-z0-9-]")
_SKILL_MULTI_HYPHEN = re.compile(r"-{2,}")
+# ---------------------------------------------------------------------------
+# Skill-scaffolding markers and the canonical extractor.
+#
+# When a user invokes a /skill (or /bundle), Hermes expands the turn into a
+# model-facing message that embeds the full skill body plus scaffolding. That
+# expanded text is what flows into the agent loop — and into memory providers
+# via MemoryManager. Providers that store or embed the raw user turn (mem0,
+# openviking, hindsight, retaindb, byterover, honcho, supermemory) would
+# otherwise capture the entire skill body instead of what the user actually
+# asked. ``extract_user_instruction_from_skill_message`` recovers just the
+# user's instruction so memory stays clean.
+#
+# These markers MUST stay byte-identical to the builders below
+# (``_build_skill_message`` here, ``build_bundle_invocation_message`` in
+# agent/skill_bundles.py). They are co-located with the single-skill builder
+# on purpose, and the bundle markers are asserted against the bundle builder in
+# tests/openviking_plugin/test_openviking.py::test_skill_markers_match_hermes_scaffolding.
+# ---------------------------------------------------------------------------
+_SKILL_INVOCATION_PREFIX = "[IMPORTANT: The user has invoked the "
+_SINGLE_SKILL_MARKER = "The full skill content is loaded below.]"
+_SINGLE_SKILL_INSTRUCTION = (
+ "The user has provided the following instruction alongside the skill invocation: "
+)
+_RUNTIME_NOTE = "\n\n[Runtime note:"
+_BUNDLE_MARKER = " skill bundle,"
+_BUNDLE_USER_INSTRUCTION = "\nUser instruction: "
+_BUNDLE_FIRST_SKILL_BLOCK = "\n\n[Loaded as part of the "
+
+
+def extract_user_instruction_from_skill_message(content: Any) -> Optional[str]:
+ """Recover the user's instruction from a slash-skill-expanded turn.
+
+ Returns:
+ - The original string unchanged when it is NOT skill scaffolding
+ (a normal user message passes straight through).
+ - The extracted user instruction when the scaffolding carried one.
+ - ``None`` when the content is skill scaffolding with no user
+ instruction (i.e. a bare ``/skill`` invocation). Callers that feed
+ memory providers should skip the turn in that case — there is no
+ user content worth storing.
+ """
+ if not isinstance(content, str):
+ return None
+
+ if not content.startswith(_SKILL_INVOCATION_PREFIX):
+ return content
+
+ if _BUNDLE_MARKER in content:
+ return _extract_bundle_user_instruction(content)
+
+ if _SINGLE_SKILL_MARKER in content:
+ return _extract_single_skill_user_instruction(content)
+
+ return None
+
+
+def _extract_single_skill_user_instruction(message: str) -> Optional[str]:
+ # Single-skill format appends the user instruction after the skill body, so
+ # the last occurrence is the user-provided one; the body may quote this text.
+ marker_idx = message.rfind(_SINGLE_SKILL_INSTRUCTION)
+ if marker_idx < 0:
+ return None
+
+ instruction = message[marker_idx + len(_SINGLE_SKILL_INSTRUCTION):]
+ runtime_idx = instruction.find(_RUNTIME_NOTE)
+ if runtime_idx >= 0:
+ instruction = instruction[:runtime_idx]
+ instruction = instruction.strip()
+ return instruction or None
+
+
+def _extract_bundle_user_instruction(message: str) -> Optional[str]:
+ # Bundle format puts the user instruction before the loaded skills, so the
+ # first occurrence is the user-provided one.
+ marker_idx = message.find(_BUNDLE_USER_INSTRUCTION)
+ if marker_idx < 0:
+ return None
+
+ instruction = message[marker_idx + len(_BUNDLE_USER_INSTRUCTION):]
+ first_skill_idx = instruction.find(_BUNDLE_FIRST_SKILL_BLOCK)
+ if first_skill_idx >= 0:
+ instruction = instruction[:first_skill_idx]
+ instruction = instruction.strip()
+ return instruction or None
+
def _resolve_skill_commands_platform() -> Optional[str]:
"""Return the current platform scope used for disabled-skill filtering.
Enter your reviewer name above to record a gate decision.