Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
d573e7c
fix(dashboard): use DS Button prefix/size API instead of inline icons
jquesnelle Jun 18, 2026
81ff916
fix(agent): flush un-persisted messages before session rotation (#47202)
kyssta-exe Jun 16, 2026
0879d5c
fix(gateway): preserve original transcript when /compress rotation is…
teknium1 Jun 18, 2026
4ed2f33
fix(thread): allow scrolling long user messages in chat history (#48619)
alelpoan Jun 18, 2026
9705e79
fix(picker): remove max_models=50 cap in interactive model pickers
Jun 18, 2026
3042045
fix(picker): keep max_models=0 distinct from unlimited; lock cap sema…
teknium1 Jun 18, 2026
49596b7
fix(gateway): resume follows the compression tip so post-compression …
OutThisLife Jun 18, 2026
c23c370
test: narrow db._conn before raw SQL so ty stops flagging None-union …
OutThisLife Jun 18, 2026
1ea2b27
Merge pull request #48633 from NousResearch/fix/resume-follows-compre…
OutThisLife Jun 18, 2026
f8d8f04
feat(kanban): auto-subscribe calling session on kanban_create
flooryyyy Jun 15, 2026
2944b3c
fix(desktop): make session delete idempotent and id-resolving (#48641)
OutThisLife Jun 18, 2026
3ead2bd
feat(prompt): configurable per-platform system-prompt hint overrides
victor-kyriazakos Jun 18, 2026
f1ff845
docs(prompt): document platform_hints config override
teknium1 Jun 18, 2026
769f307
fix(npm): lock react-simple-icons to 13.11.1
ethernet8023 Jun 18, 2026
cbe44bf
Merge pull request #48657 from NousResearch/hermes-icons
ethernet8023 Jun 18, 2026
03d9a95
fix(desktop): show Hindsight memory provider (#37546)
benfrank241 Jun 18, 2026
d2c53ff
feat(relay): WS-only inbound on the gateway adapter (Phase 3) (#48294)
benbarclay Jun 18, 2026
36851fa
fix(docker): support WebUI installs from read-only sources (#48541)
r266-tech Jun 19, 2026
2c6e266
fix(relay): trigger self-provision on relay-config + NAS token, not i…
benbarclay Jun 19, 2026
4493c6a
Merge upstream updates with desktop and security hardening
zapabob Jun 19, 2026
e8855d4
Fix CI lock and desktop session test fixture
zapabob Jun 19, 2026
0403f41
fix(agent): handle missing trigram tokenizer without disabling FTS5
liuhao1024 Jun 16, 2026
c10aa5d
fix(agent): address review feedback on trigram tokenizer fallback
liuhao1024 Jun 16, 2026
9ae98e0
fix(agent): rebuild base fts without trigram
channkim Jun 16, 2026
1d2e359
fix(cli): surface a visible warning when the session store is unavail…
teknium1 Jun 18, 2026
62c71eb
chore(release): map chanyoung.kim@nota.ai -> channkim for #47049 salvage
teknium1 Jun 18, 2026
e48554a
feat(cli): lock hermes worktrees so concurrent processes can't clobbe…
JoaoMarcos44 Jun 18, 2026
8568988
chore: add JoaoMarcos44 to AUTHOR_MAP
teknium1 Jun 18, 2026
d06104a
fix(dashboard): resolve chat TUI argv off event loop (#48561)
kshitijk4poor Jun 19, 2026
637c674
Fix CI dependency and Windows README contracts
zapabob Jun 19, 2026
28d887c
Merge pull request #48615 from NousResearch/fix/dashboard-ds-button-api
jquesnelle Jun 19, 2026
d40757c
Merge latest upstream fixes
zapabob Jun 19, 2026
96cf63b
Split install-hook packaging change out of PR
zapabob Jun 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,3 @@ acp_registry/
.gitattributes
.hadolint.yaml
.mailmap

# Top-level LICENSE (not matched by *.md); not needed inside the container
LICENSE
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,10 @@ This fork follows the same core engineering constraints as upstream Hermes:

## Quick Start

On Windows, the supported bootstrap path is the PowerShell installer in
`scripts/install.ps1`. Clone-based development is still available when you want
to work directly from source.

```powershell
git clone https://github.com/zapabob/hermes-agent.git
cd hermes-agent
Expand Down
17 changes: 17 additions & 0 deletions agent/agent_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -1385,6 +1385,23 @@ def init_agent(
# are noisy.
agent._environment_probe = bool(_agent_section.get("environment_probe", True))

# Per-platform prompt-hint overrides (config.yaml → platform_hints).
# Lets an enterprise admin append to or replace Hermes' built-in
# platform hint for a single messaging platform (e.g. WhatsApp) without
# affecting other platforms. Shape:
# platform_hints:
# whatsapp:
# append: "When tabular output would help, invoke the ... skill."
# slack:
# replace: "Custom Slack hint that fully replaces the default."
# Stored verbatim; resolution happens in agent/system_prompt.py against
# the active platform. Invalid shapes are ignored defensively so a bad
# config entry can never break prompt assembly.
_platform_hints_cfg = _agent_cfg.get("platform_hints", {})
if not isinstance(_platform_hints_cfg, dict):
_platform_hints_cfg = {}
agent._platform_hint_overrides = _platform_hints_cfg

# App-level API retry count (wraps each model API call). Default 3,
# overridable via agent.api_max_retries in config.yaml. See #11616.
try:
Expand Down
10 changes: 10 additions & 0 deletions agent/conversation_compression.py
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,16 @@ def _release_lock() -> None:
old_title = agent._session_db.get_session_title(agent.session_id)
# Trigger memory extraction on the old session before it rotates.
agent.commit_memory_session(messages)
# Flush any un-persisted messages from the current turn to the
# old session *before* rotating. compress_context() can be
# called mid-turn (auto-compress when context exceeds threshold)
# at a point when _flush_messages_to_session_db() has not yet
# run. Without this, messages generated during the current turn
# are silently lost on session rotation (#47202).
try:
agent._flush_messages_to_session_db(messages)
except Exception:
pass # best-effort — don't block compression on a flush error
agent._session_db.end_session(agent.session_id, "compression")
old_session_id = agent.session_id
agent.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
Expand Down
41 changes: 39 additions & 2 deletions agent/system_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,36 @@ def _ra():
return run_agent


def _resolve_platform_hint(agent: Any, platform_key: str, default_hint: str) -> str:
"""Apply a per-platform prompt-hint override to the default hint."""
if not platform_key:
return default_hint
overrides = getattr(agent, "_platform_hint_overrides", None)
if not isinstance(overrides, dict) or not overrides:
return default_hint
spec = overrides.get(platform_key)
if spec is None:
return default_hint

if isinstance(spec, str):
extra = spec.strip()
return f"{default_hint}\n\n{extra}".strip() if extra else default_hint

if not isinstance(spec, dict):
return default_hint

base = default_hint
replace = spec.get("replace")
if isinstance(replace, str) and replace.strip():
base = replace.strip()

append = spec.get("append")
if isinstance(append, str) and append.strip():
return f"{base}\n\n{append.strip()}".strip()

return base


def _model_needs_portable_memory_packet(agent: Any) -> bool:
"""Return True when the model family benefits from a compact memory packet.

Expand Down Expand Up @@ -376,18 +406,25 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
)

platform_key = (agent.platform or "").lower().strip()
# Resolve the built-in/plugin default hint for this platform, then apply
# any per-platform override from config (platform_hints.<platform>).
_default_hint = ""
if platform_key in PLATFORM_HINTS:
stable_parts.append(PLATFORM_HINTS[platform_key])
_default_hint = PLATFORM_HINTS[platform_key]
elif platform_key:
# Check plugin registry for platform-specific LLM guidance
try:
from gateway.platform_registry import platform_registry
_entry = platform_registry.get(platform_key)
if _entry and _entry.platform_hint:
stable_parts.append(_entry.platform_hint)
_default_hint = _entry.platform_hint
except Exception:
pass

_effective_hint = _resolve_platform_hint(agent, platform_key, _default_hint)
if _effective_hint:
stable_parts.append(_effective_hint)

# ── Context tier (cwd-dependent, may change between sessions) ─
context_parts: List[str] = []

Expand Down
108 changes: 100 additions & 8 deletions apps/desktop/electron/main.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ const {
systemPreferences
} = require('electron')
const crypto = require('node:crypto')
const dns = require('node:dns').promises
const fs = require('node:fs')
const http = require('node:http')
const https = require('node:https')
const nodeNet = require('node:net')
const path = require('node:path')
const { pathToFileURL } = require('node:url')
const { execFileSync, spawn } = require('node:child_process')
Expand Down Expand Up @@ -82,6 +84,9 @@ const {

let nodePty = null
let nodePtyDir = null
const IMAGE_URL_MAX_BYTES = DATA_URL_READ_MAX_BYTES
const IMAGE_URL_MAX_REDIRECTS = 3
const IMAGE_MIME_RE = /^image\/(?:avif|bmp|gif|jpeg|jpg|png|svg\+xml|webp)$/i

try {
nodePty = require('node-pty')
Expand Down Expand Up @@ -3046,42 +3051,129 @@ function fetchLinkTitle(rawUrl) {
return pending
}

async function resourceBufferFromUrl(rawUrl) {
function assertImageBufferLimit(buffer) {
if (buffer.length > IMAGE_URL_MAX_BYTES) throw new Error('Image exceeds the size limit')
}

function isPrivateAddress(address) {
const family = nodeNet.isIP(address)
if (family === 4) {
const parts = address.split('.').map(part => Number(part))
const [a, b] = parts

return (
a === 10 ||
a === 127 ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 100 && b >= 64 && b <= 127) ||
a === 0
)
}
if (family === 6) {
const lower = address.toLowerCase()

return lower === '::1' || lower.startsWith('fc') || lower.startsWith('fd') || lower.startsWith('fe80:')
}

return false
}

async function assertPublicHttpTarget(parsed) {
if (parsed.username || parsed.password) throw new Error('Image URL credentials are not allowed')
if (isPrivateAddress(parsed.hostname)) throw new Error('Private network image URLs are not allowed')
const records = await dns.lookup(parsed.hostname, { all: true })
if (records.some(record => isPrivateAddress(record.address))) {
throw new Error('Private network image URLs are not allowed')
}
}

async function resourceBufferFromUrl(rawUrl, redirectsLeft = IMAGE_URL_MAX_REDIRECTS) {
if (!rawUrl) throw new Error('Missing URL')
if (rawUrl.startsWith('data:')) {
const match = rawUrl.match(/^data:([^;,]+)?(;base64)?,(.*)$/s)
if (!match) throw new Error('Invalid data URL')
const mimeType = match[1] || 'application/octet-stream'
if (!IMAGE_MIME_RE.test(mimeType)) throw new Error('Only image data URLs are supported')
const encoded = match[3] || ''
if (encoded.length > IMAGE_URL_MAX_BYTES * 2) throw new Error('Image exceeds the size limit')
const buffer = match[2] ? Buffer.from(encoded, 'base64') : Buffer.from(decodeURIComponent(encoded), 'utf8')
assertImageBufferLimit(buffer)
return { buffer, mimeType }
}
if (/^file:/i.test(rawUrl)) {
const { resolvedPath } = await resolveReadableFileForIpc(rawUrl, { purpose: 'Image file' })
const { resolvedPath } = await resolveReadableFileForIpc(rawUrl, {
maxBytes: IMAGE_URL_MAX_BYTES,
purpose: 'Image file'
})
const buffer = await fs.promises.readFile(resolvedPath)
return { buffer, mimeType: mimeTypeForPath(resolvedPath) }
assertImageBufferLimit(buffer)
const mimeType = mimeTypeForPath(resolvedPath)
if (!IMAGE_MIME_RE.test(mimeType)) throw new Error('Only image files are supported')
return { buffer, mimeType }
}

const parsed = new URL(rawUrl)
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error('Only http, https, file, and data image URLs are supported')
}
await assertPublicHttpTarget(parsed)
const client = parsed.protocol === 'https:' ? https : http
return new Promise((resolve, reject) => {
let settled = false
const fail = error => {
if (settled) return
settled = true
reject(error)
}
const req = client.get(parsed, res => {
const location = res.headers.location
if ((res.statusCode || 0) >= 300 && (res.statusCode || 0) < 400 && location) {
res.resume()
if (redirectsLeft <= 0) {
fail(new Error('Too many image URL redirects'))
return
}
const nextUrl = new URL(location, parsed).toString()
resourceBufferFromUrl(nextUrl, redirectsLeft - 1).then(resolve, fail)
return
}
if ((res.statusCode || 500) >= 400) {
reject(new Error(`Failed to fetch ${rawUrl}: ${res.statusCode}`))
fail(new Error(`Failed to fetch ${rawUrl}: ${res.statusCode}`))
res.resume()
return
}
const mimeType = String(res.headers['content-type'] || 'application/octet-stream').split(';')[0].trim()
if (!IMAGE_MIME_RE.test(mimeType)) {
fail(new Error('Remote URL did not return an image'))
res.resume()
return
}
const chunks = []
res.on('error', reject)
res.on('data', chunk => chunks.push(chunk))
let total = 0
res.on('error', fail)
res.on('data', chunk => {
total += chunk.length
if (total > IMAGE_URL_MAX_BYTES) {
req.destroy(new Error('Image exceeds the size limit'))
return
}
chunks.push(chunk)
})
res.on('end', () => {
if (settled) return
settled = true
resolve({
buffer: Buffer.concat(chunks),
mimeType: res.headers['content-type'] || 'application/octet-stream'
mimeType
})
})
})
req.on('error', reject)
req.setTimeout(resolveTimeoutMs(undefined, DEFAULT_FETCH_TIMEOUT_MS), () => {
req.destroy(new Error('Image fetch timed out'))
})
req.on('error', fail)
})
}

Expand Down
30 changes: 26 additions & 4 deletions apps/desktop/electron/vscode-marketplace.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ const VSIX_ASSET_TYPE = 'Microsoft.VisualStudio.Services.VSIXPackage'
const MAX_VSIX_BYTES = 40 * 1024 * 1024 // 40 MB — themes are tiny; this is paranoia.
const MAX_REDIRECTS = 5
const REQUEST_TIMEOUT_MS = 20_000
const MAX_THEME_ENTRY_BYTES = 2 * 1024 * 1024
const MAX_THEME_TOTAL_BYTES = 8 * 1024 * 1024
const MAX_THEME_COUNT = 25

const ID_RE = /^[\w-]+\.[\w-]+$/

Expand Down Expand Up @@ -224,13 +227,14 @@ function readCentralDirectory(buf) {

const method = buf.readUInt16LE(offset + 10)
const compressedSize = buf.readUInt32LE(offset + 20)
const uncompressedSize = buf.readUInt32LE(offset + 24)
const nameLen = buf.readUInt16LE(offset + 28)
const extraLen = buf.readUInt16LE(offset + 30)
const commentLen = buf.readUInt16LE(offset + 32)
const localOffset = buf.readUInt32LE(offset + 42)
const name = buf.toString('utf8', offset + 46, offset + 46 + nameLen)

records.set(name, { method, compressedSize, localOffset })
records.set(name, { method, compressedSize, uncompressedSize, localOffset })
offset += 46 + nameLen + extraLen + commentLen
}

Expand All @@ -239,6 +243,12 @@ function readCentralDirectory(buf) {

/** Inflate a single entry to a string. */
function extractEntry(buf, record) {
if (record.method !== 0 && record.method !== 8) {
throw new Error('Unsupported zip compression method.')
}
if (record.uncompressedSize > MAX_THEME_ENTRY_BYTES) {
throw new Error('Zip entry exceeds the uncompressed size limit.')
}
// The local header's name/extra lengths can differ from the central record,
// so re-read them here to locate the compressed payload.
if (buf.readUInt32LE(record.localOffset) !== 0x04034b50) {
Expand All @@ -248,10 +258,15 @@ function extractEntry(buf, record) {
const nameLen = buf.readUInt16LE(record.localOffset + 26)
const extraLen = buf.readUInt16LE(record.localOffset + 28)
const dataStart = record.localOffset + 30 + nameLen + extraLen
if (dataStart + record.compressedSize > buf.length) {
throw new Error('Corrupt zip: entry exceeds archive bounds.')
}
const data = buf.subarray(dataStart, dataStart + record.compressedSize)

// 0 = stored, 8 = deflate. Theme files are one or the other.
return record.method === 0 ? data.toString('utf8') : zlib.inflateRawSync(data).toString('utf8')
return record.method === 0
? data.toString('utf8')
: zlib.inflateRawSync(data, { maxOutputLength: MAX_THEME_ENTRY_BYTES }).toString('utf8')
}

/** Normalize a package.json theme path to its zip entry name. */
Expand Down Expand Up @@ -279,7 +294,9 @@ function extractThemes(vsixBuffer) {

const themes = []

for (const entry of contributed) {
let totalBytes = 0

for (const entry of contributed.slice(0, MAX_THEME_COUNT)) {
if (!entry?.path) {
continue
}
Expand All @@ -291,10 +308,15 @@ function extractThemes(vsixBuffer) {
}

try {
const contents = extractEntry(vsixBuffer, record)
totalBytes += Buffer.byteLength(contents, 'utf8')
if (totalBytes > MAX_THEME_TOTAL_BYTES) {
break
}
themes.push({
label: entry.label || entry.id || pkg.displayName || pkg.name || 'VS Code Theme',
uiTheme: entry.uiTheme,
contents: extractEntry(vsixBuffer, record)
contents
})
} catch {
// Skip an entry we can't inflate rather than failing the whole install.
Expand Down
Loading
Loading