mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
Merge origin/main (#31: 94 commits to d2639c0dc8) into feat/unified-gateway-runtime
No textual conflicts. ecf9655b5a moved the plugin-contract re-export in the Desktop SDK barrel;
the branch's profile-request type re-export now follows it so perfectionist/sort-exports holds
(CI lints the merge ref and failed on that order).
This commit is contained in:
commit
30b63bda20
61 files changed
+1546
-159
No files matched your search
@@ -9,6 +9,7 @@ import { Mail } from '@/lib/icons'
|
||||
import { isSubmitEnter } from '@/lib/ime'
|
||||
import { $petActivity, $petInfo, setPetInfo } from '@/store/pet'
|
||||
import { overlayWindowSize } from '@/store/pet-overlay'
|
||||
import { mirrorPetPluginMessages } from '@/store/pet-plugin-messages'
|
||||
import { setAwaitingResponse, setBusy } from '@/store/session'
|
||||
|
||||
// Fallbacks mirror pet-sprite's defaults; the gateway normally sends real values.
|
||||
@@ -96,6 +97,7 @@ export function PetOverlayApp() {
|
||||
setBusy(Boolean(payload.busy))
|
||||
setAwaitingResponse(Boolean(payload.awaiting))
|
||||
setUnread(Boolean(payload.unread))
|
||||
mirrorPetPluginMessages(payload.pluginMessages)
|
||||
|
||||
// Play a reaction on a new id (ignore the first sync, which just primes it).
|
||||
const reaction = payload.reaction ?? null
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import { useOnProfileSwitch } from '@/app/hooks/use-on-profile-switch'
|
||||
import { useRouteOverlayActive } from '@/app/hooks/use-route-overlay-active'
|
||||
import { PetHeartField } from '@/components/chat/vibe-hearts'
|
||||
import { PetBubble } from '@/components/pet/pet-bubble'
|
||||
import { persistString, storedString } from '@/lib/storage'
|
||||
import { $changeEventsAvailable, $petChange } from '@/store/live-sync'
|
||||
import {
|
||||
@@ -508,6 +509,22 @@ export function FloatingPet() {
|
||||
{/* Hearts puff off the pet; its celebrate ("yay"/jump) pose is driven by
|
||||
burstVibeHearts's router. */}
|
||||
<PetHeartField petH={petH} petW={petW} />
|
||||
{/* Plugin lines (ctx.pet.say) float above the pet, upright (outside the
|
||||
mirrored sprite wrapper) and grown toward the window's middle so a pet
|
||||
parked at an edge never pushes its bubble off-screen. Core status
|
||||
lines stay in the pop-out overlay; in-window the app is the surface. */}
|
||||
<div
|
||||
style={{
|
||||
bottom: '100%',
|
||||
marginBottom: 4,
|
||||
pointerEvents: 'none',
|
||||
position: 'absolute',
|
||||
zIndex: 2,
|
||||
...(position.x + petW / 2 < (window.innerWidth || 800) / 2 ? { left: 0 } : { right: 0 })
|
||||
}}
|
||||
>
|
||||
<PetBubble showStatus={false} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,13 +3,18 @@ import { useEffect, useState } from 'react'
|
||||
|
||||
import { AlertCircle, Clock, type IconComponent } from '@/lib/icons'
|
||||
import { $petActivity, $petState, type PetState } from '@/store/pet'
|
||||
import { $petPluginMessage, type PetMessageTone } from '@/store/pet-plugin-messages'
|
||||
|
||||
/**
|
||||
* Speech bubble + status glyph for the popped-out pet overlay — the
|
||||
* "notification" half of the mascot. It externalizes what the agent is doing
|
||||
* (Codex-style) so a glance at the desktop pet replaces switching back to the
|
||||
* window. The in-window pet doesn't show it (the app itself is the surface);
|
||||
* only the overlay renders it.
|
||||
* Speech bubble + status glyph for the pet — the "notification" half of the
|
||||
* mascot. It externalizes what the agent is doing (Codex-style) so a glance at
|
||||
* the desktop pet replaces switching back to the window. The core status lines
|
||||
* show only in the popped-out overlay (`showStatus`); in-window the app itself
|
||||
* is the surface, so the in-window pet renders the bubble for plugin lines only.
|
||||
*
|
||||
* Plugin lines (`ctx.pet.say`, see store/pet-plugin-messages) share the bubble:
|
||||
* the newest live line shows with its plugin's name as a small label, unless
|
||||
* the agent is in an error or waiting-on-you state — those keep the bubble.
|
||||
*
|
||||
* Text is derived purely from the same `$petState` / `$petActivity` the sprite
|
||||
* already reacts to, so it never drifts from the animation. The bubble is shown
|
||||
@@ -17,7 +22,7 @@ import { $petActivity, $petState, type PetState } from '@/store/pet'
|
||||
* done/error beat / waiting on the user) and is hidden at plain idle.
|
||||
*/
|
||||
|
||||
type Tone = 'error' | 'wait'
|
||||
type Tone = PetMessageTone
|
||||
|
||||
interface Spec {
|
||||
lines: string[]
|
||||
@@ -70,9 +75,28 @@ const SPECS: Partial<Record<PetState, Spec>> = {
|
||||
|
||||
const TONE_COLOR: Record<Tone, string> = {
|
||||
error: 'var(--ui-red)',
|
||||
info: 'currentColor',
|
||||
wait: 'var(--ui-yellow)'
|
||||
}
|
||||
|
||||
const TONE_GLYPH: Partial<Record<Tone, IconComponent>> = { error: AlertCircle, wait: Clock }
|
||||
|
||||
// Core states that outrank a plugin line: the pet is flagging trouble or the
|
||||
// turn is paused on the user, and a plugin must not talk over that.
|
||||
const PRIORITY_TONES: ReadonlySet<Tone | undefined> = new Set(['error', 'wait'])
|
||||
|
||||
const BUBBLE_SURFACE = {
|
||||
// Solid, theme-driven surface (the prior --ui-bg-card mixes in
|
||||
// `transparent`, so the bubble was see-through).
|
||||
background: 'var(--ui-bg-elevated)',
|
||||
border: '1px solid var(--ui-stroke-secondary)',
|
||||
boxShadow: '0 4px 14px rgba(0,0,0,0.22)',
|
||||
color: 'var(--foreground)',
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
pointerEvents: 'none'
|
||||
} as const
|
||||
|
||||
// Random pick that avoids repeating the line we're already showing.
|
||||
function pick(lines: string[], prev: string): string {
|
||||
if (lines.length <= 1) {
|
||||
@@ -88,9 +112,16 @@ function pick(lines: string[], prev: string): string {
|
||||
return next
|
||||
}
|
||||
|
||||
export function PetBubble() {
|
||||
export interface PetBubbleProps {
|
||||
/** Render the core status lines (working…, your turn). The overlay passes
|
||||
* true; the in-window pet leaves them to the app and shows plugin lines only. */
|
||||
showStatus?: boolean
|
||||
}
|
||||
|
||||
export function PetBubble({ showStatus = true }: PetBubbleProps = {}) {
|
||||
const state = useStore($petState)
|
||||
const activity = useStore($petActivity)
|
||||
const pluginLine = useStore($petPluginMessage)
|
||||
const [line, setLine] = useState('')
|
||||
|
||||
// Finish beats are carried by the sprite/mail icon; idle only speaks up when
|
||||
@@ -124,7 +155,52 @@ export function PetBubble() {
|
||||
|
||||
const spec = specKey ? SPECS[specKey] : null
|
||||
|
||||
if (!spec) {
|
||||
if (pluginLine && !PRIORITY_TONES.has(spec?.tone)) {
|
||||
const Glyph = TONE_GLYPH[pluginLine.tone]
|
||||
|
||||
return (
|
||||
<div
|
||||
data-pet-plugin={pluginLine.pluginId}
|
||||
data-slot="pet-plugin-bubble"
|
||||
style={{
|
||||
...BUBBLE_SURFACE,
|
||||
borderRadius: 10,
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'column',
|
||||
gap: 3,
|
||||
lineHeight: 1.3,
|
||||
maxWidth: 220,
|
||||
padding: '5px 8px',
|
||||
width: 'max-content'
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
color: 'var(--ui-text-secondary, var(--muted-foreground))',
|
||||
fontSize: 9,
|
||||
fontWeight: 600,
|
||||
letterSpacing: 0.2,
|
||||
lineHeight: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{pluginLine.pluginName}
|
||||
</span>
|
||||
<span style={{ alignItems: 'flex-start', display: 'inline-flex', gap: 5, overflowWrap: 'anywhere' }}>
|
||||
{Glyph && (
|
||||
<span style={{ display: 'inline-flex', flex: 'none', paddingTop: 1 }}>
|
||||
<Glyph style={{ color: TONE_COLOR[pluginLine.tone], height: 12, width: 12 }} />
|
||||
</span>
|
||||
)}
|
||||
{pluginLine.text}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!spec || !showStatus) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -135,22 +211,14 @@ export function PetBubble() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...BUBBLE_SURFACE,
|
||||
alignItems: 'center',
|
||||
// Solid, theme-driven surface (the prior --ui-bg-card mixes in
|
||||
// `transparent`, so the bubble was see-through).
|
||||
background: 'var(--ui-bg-elevated)',
|
||||
border: '1px solid var(--ui-stroke-secondary)',
|
||||
borderRadius: hasText ? 10 : 999,
|
||||
boxShadow: '0 4px 14px rgba(0,0,0,0.22)',
|
||||
color: 'var(--foreground)',
|
||||
display: 'inline-flex',
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
gap: hasText ? 5 : 0,
|
||||
lineHeight: 1,
|
||||
// Glyph-only bubbles collapse to a tight, symmetric badge.
|
||||
padding: hasText ? '5px 8px' : 5,
|
||||
pointerEvents: 'none',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
import { act, cleanup, render, screen } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { FloatingPet } from '@/components/pet/floating-pet'
|
||||
import { PetBubble } from '@/components/pet/pet-bubble'
|
||||
import { $petActivity, setPetInfo } from '@/store/pet'
|
||||
import { $petOverlayActive, popInPet, popOutPet } from '@/store/pet-overlay'
|
||||
import { $petPluginMessages, mirrorPetPluginMessages, resetPetPluginMessages } from '@/store/pet-plugin-messages'
|
||||
|
||||
import { createPluginContext } from './plugin'
|
||||
import { publishPlugin } from './plugins-store'
|
||||
|
||||
// The in-window pet's gateway/theme plumbing is unrelated to the bubble; the
|
||||
// sprite is a canvas jsdom can't paint, so stand it in with the same a11y
|
||||
// label the real one carries.
|
||||
vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({
|
||||
useGatewayRequest: () => ({ requestGateway: vi.fn(async () => null) })
|
||||
}))
|
||||
vi.mock('@/themes/context', () => ({ useTheme: () => ({ resolvedMode: 'dark' }) }))
|
||||
vi.mock('@/components/pet/pet-sprite', () => ({
|
||||
PetSprite: () => <canvas aria-label="Boba pet" />,
|
||||
roamWalkRow: () => ({ mirror: false, row: undefined })
|
||||
}))
|
||||
|
||||
const PET = {
|
||||
displayName: 'Boba',
|
||||
enabled: true,
|
||||
frameH: 208,
|
||||
frameW: 192,
|
||||
scale: 0.33,
|
||||
spritesheetBase64: 'stub'
|
||||
}
|
||||
|
||||
// Each test gets its own plugin id, so per-plugin rate windows never leak
|
||||
// between tests; afterEach resets the store so a live line cannot leak.
|
||||
let n = 0
|
||||
|
||||
function plugin(name = 'Pet Wallet') {
|
||||
const id = `pet-wallet-${++n}`
|
||||
const disposers: Array<() => void> = []
|
||||
publishPlugin({ id, kind: 'disk', name, status: 'loaded' })
|
||||
const ctx = createPluginContext(id, dispose => disposers.push(dispose))
|
||||
|
||||
return { ctx, id, unload: () => act(() => disposers.splice(0).forEach(dispose => dispose())) }
|
||||
}
|
||||
|
||||
function mountPet() {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<FloatingPet />
|
||||
</MemoryRouter>
|
||||
)
|
||||
}
|
||||
|
||||
const bubble = () => document.querySelector('[data-slot="pet-plugin-bubble"]')
|
||||
|
||||
describe('ctx.pet — plugin lines in the core pet bubble', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
$petActivity.set({})
|
||||
$petOverlayActive.set(false)
|
||||
setPetInfo(PET)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
resetPetPluginMessages()
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
setPetInfo({ enabled: false })
|
||||
})
|
||||
|
||||
it('shows the line above the in-window pet, labelled with the plugin name', () => {
|
||||
const { ctx, id } = plugin()
|
||||
mountPet()
|
||||
expect(bubble()).toBeNull()
|
||||
|
||||
act(() => void ctx.pet.say('DeepSeek ¥12.40 left'))
|
||||
|
||||
expect(screen.getByText('DeepSeek ¥12.40 left')).toBeTruthy()
|
||||
expect(bubble()?.getAttribute('data-pet-plugin')).toBe(id)
|
||||
expect(bubble()?.textContent).toContain('Pet Wallet')
|
||||
expect(ctx.pet.visible.get()).toBe(true)
|
||||
})
|
||||
|
||||
it('clears the line when its TTL runs out (default 6 s, clamped)', () => {
|
||||
const { ctx } = plugin()
|
||||
mountPet()
|
||||
|
||||
act(() => void ctx.pet.say('short', { ttlMs: 2_000 }))
|
||||
expect(screen.queryByText('short')).toBeTruthy()
|
||||
act(() => void vi.advanceTimersByTime(1_999))
|
||||
expect(screen.queryByText('short')).toBeTruthy()
|
||||
act(() => void vi.advanceTimersByTime(1))
|
||||
expect(screen.queryByText('short')).toBeNull()
|
||||
|
||||
act(() => void ctx.pet.say('default'))
|
||||
act(() => void vi.advanceTimersByTime(5_999))
|
||||
expect(screen.queryByText('default')).toBeTruthy()
|
||||
act(() => void vi.advanceTimersByTime(1))
|
||||
expect(screen.queryByText('default')).toBeNull()
|
||||
|
||||
// A huge TTL is capped at 30 s — a plugin can't pin the bubble forever.
|
||||
act(() => void ctx.pet.say('forever', { ttlMs: 10 * 60_000 }))
|
||||
act(() => void vi.advanceTimersByTime(30_000))
|
||||
expect(screen.queryByText('forever')).toBeNull()
|
||||
})
|
||||
|
||||
it('clears the line through the returned disposer and through clear(id)', () => {
|
||||
const { ctx } = plugin()
|
||||
mountPet()
|
||||
|
||||
let dispose = () => {}
|
||||
act(() => void (dispose = ctx.pet.say('bye soon')))
|
||||
act(() => dispose())
|
||||
expect(screen.queryByText('bye soon')).toBeNull()
|
||||
|
||||
act(() => void ctx.pet.say('keyed', { id: 'balance' }))
|
||||
act(() => ctx.pet.clear('balance'))
|
||||
expect(bubble()).toBeNull()
|
||||
})
|
||||
|
||||
it('replaces a line in place when the same id is said again', () => {
|
||||
const { ctx } = plugin()
|
||||
mountPet()
|
||||
|
||||
let first = () => {}
|
||||
act(() => void (first = ctx.pet.say('balance ¥12', { id: 'balance' })))
|
||||
act(() => void ctx.pet.say('balance ¥11', { id: 'balance' }))
|
||||
expect(screen.queryByText('balance ¥12')).toBeNull()
|
||||
expect(screen.getByText('balance ¥11')).toBeTruthy()
|
||||
|
||||
// The first line's disposer must not retire its replacement.
|
||||
act(() => first())
|
||||
expect(screen.getByText('balance ¥11')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('clears every line a plugin has up when the plugin unloads or is disabled', () => {
|
||||
const a = plugin('Pet Wallet')
|
||||
const b = plugin('Other')
|
||||
mountPet()
|
||||
|
||||
act(() => void b.ctx.pet.say('from other', { ttlMs: 30_000 }))
|
||||
act(() => void a.ctx.pet.say('from wallet', { ttlMs: 30_000 }))
|
||||
expect(screen.getByText('from wallet')).toBeTruthy()
|
||||
|
||||
a.unload()
|
||||
|
||||
expect(screen.queryByText('from wallet')).toBeNull()
|
||||
// Only the unloaded plugin's lines go; another plugin's line resurfaces.
|
||||
expect(screen.getByText('from other')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('renders plain text only: markup stays literal, control chars go, length is capped', () => {
|
||||
const { ctx } = plugin('<b>Evil</b> Wallet')
|
||||
mountPet()
|
||||
|
||||
act(() => void ctx.pet.say('<img src=x onerror="alert(1)"> hi\u202e\n\tthere'))
|
||||
expect(bubble()?.querySelector('img, b')).toBeNull()
|
||||
expect(screen.getByText('<img src=x onerror="alert(1)"> hi there')).toBeTruthy()
|
||||
expect(bubble()?.textContent).toContain('<b>Evil</b> Wallet')
|
||||
|
||||
act(() => void ctx.pet.say('x'.repeat(500), { id: 'long' }))
|
||||
const shown = screen.getByText(/^x+…$/).textContent ?? ''
|
||||
expect(shown.length).toBe(120)
|
||||
|
||||
// Nothing to say → nothing shown, and a no-op disposer.
|
||||
act(() => void ctx.pet.say(' \n ', { id: 'blank' }))
|
||||
expect(screen.queryByText(' ')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows nothing while the pet is hidden or turned off', () => {
|
||||
const { ctx } = plugin()
|
||||
setPetInfo({ enabled: false })
|
||||
mountPet()
|
||||
|
||||
act(() => void ctx.pet.say('anyone there?', { ttlMs: 30_000 }))
|
||||
expect(screen.queryByText('anyone there?')).toBeNull()
|
||||
expect(ctx.pet.visible.get()).toBe(false)
|
||||
|
||||
// Turning the pet on while the line is live shows it — the host gates on
|
||||
// the user's pet setting, not the plugin.
|
||||
act(() => setPetInfo(PET))
|
||||
expect(screen.getByText('anyone there?')).toBeTruthy()
|
||||
act(() => setPetInfo({ enabled: false }))
|
||||
expect(screen.queryByText('anyone there?')).toBeNull()
|
||||
})
|
||||
|
||||
it('rate-limits a chatty plugin', () => {
|
||||
const { ctx } = plugin()
|
||||
mountPet()
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
act(() => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
ctx.pet.say(`line ${i}`, { id: `l${i % 3}` })
|
||||
}
|
||||
})
|
||||
expect(screen.getByText('line 9')).toBeTruthy()
|
||||
|
||||
act(() => void ctx.pet.say('one too many'))
|
||||
expect(screen.queryByText('one too many')).toBeNull()
|
||||
|
||||
act(() => void vi.advanceTimersByTime(10_000))
|
||||
act(() => void ctx.pet.say('window reopened'))
|
||||
expect(screen.getByText('window reopened')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PetBubble — core status keeps priority', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
$petActivity.set({})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
resetPetPluginMessages()
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
$petActivity.set({})
|
||||
})
|
||||
|
||||
it('lets error / waiting-on-you states win over a plugin line, then yields back', () => {
|
||||
const { ctx } = plugin()
|
||||
render(<PetBubble />)
|
||||
|
||||
act(() => void ctx.pet.say('balance ¥12', { ttlMs: 30_000 }))
|
||||
expect(screen.getByText('balance ¥12')).toBeTruthy()
|
||||
|
||||
act(() => $petActivity.set({ error: true }))
|
||||
expect(screen.queryByText('balance ¥12')).toBeNull()
|
||||
|
||||
act(() => $petActivity.set({ awaitingInput: true }))
|
||||
expect(screen.queryByText('balance ¥12')).toBeNull()
|
||||
|
||||
act(() => $petActivity.set({}))
|
||||
expect(screen.getByText('balance ¥12')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows a plugin tone glyph and leaves the core status line to the overlay', () => {
|
||||
const { ctx } = plugin()
|
||||
render(<PetBubble showStatus={false} />)
|
||||
|
||||
// In-window (showStatus=false) a busy turn says nothing on its own…
|
||||
act(() => $petActivity.set({ busy: true }))
|
||||
expect(document.body.textContent).toBe('')
|
||||
|
||||
// …but a plugin line still shows, with its tone glyph.
|
||||
act(() => void ctx.pet.say('quota low', { tone: 'wait' }))
|
||||
expect(screen.getByText('quota low')).toBeTruthy()
|
||||
expect(bubble()?.querySelector('svg')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pop-out overlay', () => {
|
||||
afterEach(() => {
|
||||
popInPet()
|
||||
cleanup()
|
||||
resetPetPluginMessages()
|
||||
delete (window as { hermesDesktop?: unknown }).hermesDesktop
|
||||
setPetInfo({ enabled: false })
|
||||
})
|
||||
|
||||
it('pushes live plugin lines to the overlay window, which renders them in its bubble', async () => {
|
||||
// The overlay is a separate gateway-less window: the main renderer pushes
|
||||
// its pet state over IPC. Capture that push and replay it the way the
|
||||
// overlay's onState handler does.
|
||||
const pushState = vi.fn()
|
||||
|
||||
;(window as { hermesDesktop?: unknown }).hermesDesktop = {
|
||||
petOverlay: { close: vi.fn(async () => ({ ok: true })), open: vi.fn(async () => ({ ok: true })), pushState }
|
||||
}
|
||||
setPetInfo(PET)
|
||||
popOutPet({ height: 70, width: 64, x: 10, y: 10 })
|
||||
|
||||
const { ctx, id } = plugin()
|
||||
act(() => void ctx.pet.say('popped-out hello', { ttlMs: 30_000 }))
|
||||
|
||||
const last = pushState.mock.calls.at(-1)?.[0] as { pluginMessages?: unknown[] }
|
||||
expect(last.pluginMessages).toEqual([expect.objectContaining({ pluginId: id, text: 'popped-out hello' })])
|
||||
|
||||
// Overlay side: adopt the pushed list verbatim and render the core bubble.
|
||||
const pushed = structuredClone(last.pluginMessages)
|
||||
resetPetPluginMessages()
|
||||
act(() => mirrorPetPluginMessages(pushed))
|
||||
render(<PetBubble />)
|
||||
expect(screen.getByText('popped-out hello')).toBeTruthy()
|
||||
|
||||
// A malformed push clears rather than throws.
|
||||
act(() => mirrorPetPluginMessages(undefined))
|
||||
expect($petPluginMessages.get()).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -12,12 +12,17 @@
|
||||
* through the plugin host loader (next phase); this is that seam.
|
||||
*/
|
||||
|
||||
import type { ReadableAtom } from 'nanostores'
|
||||
|
||||
import { pluginRest, type PluginRestOptions, pluginSocket } from '@/hermes'
|
||||
import { createPluginI18n, type PluginI18n } from '@/i18n'
|
||||
import { readKey, writeKey } from '@/lib/storage'
|
||||
import { dispatchPluginNativeNotification, type PluginNativeNotificationInput } from '@/store/native-notifications'
|
||||
import { $petActive } from '@/store/pet'
|
||||
import { clearPetMessages, type PetSayOptions, sayPetMessage } from '@/store/pet-plugin-messages'
|
||||
|
||||
import { type GatewayEventListener, onGatewayEvent } from './events'
|
||||
import { $pluginRecords } from './plugins-store'
|
||||
import { registry } from './registry'
|
||||
import { type PluginSettingsPage, settingsPageContribution } from './settings-pages'
|
||||
|
||||
@@ -27,6 +32,7 @@ import type { Contribution } from './types'
|
||||
export type { PluginRestOptions } from '@/hermes'
|
||||
export type { HermesOpenTarget } from '@/lib/hermes-open-target'
|
||||
export type { PluginNativeNotificationInput, PluginNotificationAction } from '@/store/native-notifications'
|
||||
export type { PetMessageTone, PetSayOptions } from '@/store/pet-plugin-messages'
|
||||
|
||||
/** A contribution as a plugin author writes it — provenance + id scoping are
|
||||
* the host's job, so those fields are off-limits here. */
|
||||
@@ -70,6 +76,24 @@ export interface PluginOs {
|
||||
writeClipboard: (text: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
/** The pet door: short lines in the core pet's speech bubble, attributed to
|
||||
* this plugin, instead of locating the pet in the app DOM and drawing over
|
||||
* it. The host renders them in the in-window pet and the pop-out overlay;
|
||||
* they show only while the user has a pet visible. */
|
||||
export interface PluginPet {
|
||||
/** Show one plain-text line (no HTML; control characters stripped, capped
|
||||
* at 120 chars) with this plugin's name as a small label. Lasts `ttlMs`
|
||||
* (default 6 s, clamped 1–30 s); the same `id` replaces in place. Core
|
||||
* error / waiting-on-you states keep priority over it. Rate-limited per
|
||||
* plugin. Returns a disposer that removes the line early. */
|
||||
say: (text: string, options?: PetSayOptions) => () => void
|
||||
/** Remove one line by `id`, or every line this plugin has up. */
|
||||
clear: (id?: string) => void
|
||||
/** True while a pet is installed and shown (in-window or popped out). A
|
||||
* plugin can fall back to its own chip or pane while this is false. */
|
||||
visible: ReadableAtom<boolean>
|
||||
}
|
||||
|
||||
export interface PluginFileDialogOptions {
|
||||
defaultPath?: string
|
||||
filters?: Array<{ extensions: string[]; name: string }>
|
||||
@@ -125,6 +149,10 @@ export interface PluginContext {
|
||||
* manager, clipboard — attributed to this plugin, result-shaped (never
|
||||
* throws for a missing capability). */
|
||||
os: PluginOs
|
||||
/** The pet door: lines in the core pet's speech bubble, attributed to this
|
||||
* plugin and cleared when it unloads. Feature-detect on older hosts:
|
||||
* `ctx.pet?.say(...)`. */
|
||||
pet: PluginPet
|
||||
/** Plugin-scoped persistence. */
|
||||
storage: PluginStorage
|
||||
/** Plugin-scoped i18n: ship + register locale bundles under this plugin,
|
||||
@@ -222,6 +250,26 @@ function createPluginOs(pluginId: string): PluginOs {
|
||||
}
|
||||
}
|
||||
|
||||
// Lines are attributed by the inventory name the loader published before
|
||||
// register() ran; the id is the fallback. Every line this plugin still has up
|
||||
// is cleared with it on unload/disable (tracked on first use).
|
||||
function createPluginPet(pluginId: string, track: (dispose: () => void) => () => void): PluginPet {
|
||||
let tracked = false
|
||||
|
||||
return {
|
||||
clear: id => clearPetMessages(pluginId, id),
|
||||
say: (text, options) => {
|
||||
if (!tracked) {
|
||||
tracked = true
|
||||
track(() => clearPetMessages(pluginId))
|
||||
}
|
||||
|
||||
return sayPetMessage(pluginId, $pluginRecords.get()[pluginId]?.name ?? pluginId, text, options)
|
||||
},
|
||||
visible: $petActive
|
||||
}
|
||||
}
|
||||
|
||||
/** Timers and DOM listeners a plugin takes out through `ctx`, retired as ONE
|
||||
* tracked disposer. A fired timeout drops out of the set on its own, so a
|
||||
* long-lived plugin firing many one-shots does not accumulate cleanups. */
|
||||
@@ -300,6 +348,7 @@ export function createPluginContext(pluginId: string, onDispose?: (dispose: () =
|
||||
rest: <T>(path: string, opts?: PluginRestOptions) => pluginRest<T>(pluginId, path, opts),
|
||||
socket: (path, onMessage) => track(pluginSocket(pluginId, path, onMessage)),
|
||||
os: createPluginOs(pluginId),
|
||||
pet: createPluginPet(pluginId, track),
|
||||
storage: createPluginStorage(pluginId),
|
||||
i18n: createPluginI18n(pluginId, track)
|
||||
}
|
||||
|
||||
@@ -123,7 +123,8 @@ import { desktopSettings } from './settings'
|
||||
|
||||
/** Pane, status bar and titlebar slots; see `./areas` for the mount rules. */
|
||||
export { PANES_AREA, STATUSBAR_AREAS, TITLEBAR_AREAS } from './areas'
|
||||
export type { PluginProfileRequestOptions, PluginProfileRoute } from './profile-request'
|
||||
/** The plugin authoring contract (`HermesPlugin`, `PluginContext`, `ctx.*` door types). */
|
||||
export type * from './plugin-contract'
|
||||
|
||||
// -- state: readonly views over the app's live atoms -------------------------
|
||||
|
||||
@@ -1571,10 +1572,11 @@ export const host = {
|
||||
|
||||
// -- react bridge -------------------------------------------------------------
|
||||
|
||||
export type { DesktopSettingKey, DesktopSettingValues } from './settings'
|
||||
export type { PluginProfileRequestOptions, PluginProfileRoute } from './profile-request'
|
||||
|
||||
// -- ui: the design language --------------------------------------------------
|
||||
|
||||
export type { DesktopSettingKey, DesktopSettingValues } from './settings'
|
||||
/** THE whole Capabilities surface (Skills / Tools / MCP tabs, installed
|
||||
* lists, full-skill detail pane, embedded hub picker with one-click
|
||||
* installs). For plugin dialogs pass `embedded` (tab state stays local —
|
||||
@@ -1659,6 +1661,7 @@ export {
|
||||
PanelRowMenu,
|
||||
PanelSectionLabel
|
||||
} from '@/app/overlays/panel'
|
||||
|
||||
export {
|
||||
type ProfileGroupHeaderContribution,
|
||||
type ProfileGroupRoute,
|
||||
@@ -1669,7 +1672,6 @@ export {
|
||||
type SidebarNavContribution,
|
||||
WORKSPACE_PAGE_HEADER_AREA
|
||||
} from '@/app/routes'
|
||||
|
||||
/** Appearance settings' plugin seam: register a render contribution at
|
||||
* `APPEARANCE_AREAS.extra` to add controls at the end of the Appearance page.
|
||||
* `ColorSwatches` is the app's own swatch grid (profile rail / project dialog
|
||||
@@ -1800,18 +1802,6 @@ export { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
export { Textarea } from '@/components/ui/textarea'
|
||||
export { Tip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
export type { GatewayEventListener } from '@/contrib/events'
|
||||
export type {
|
||||
HermesPlugin,
|
||||
PluginContext,
|
||||
PluginContribution,
|
||||
PluginNativeNotificationInput,
|
||||
PluginNotificationAction,
|
||||
PluginOs,
|
||||
PluginRestOptions,
|
||||
PluginSettingsPage,
|
||||
PluginSettingsSubpage,
|
||||
PluginStorage
|
||||
} from '@/contrib/plugin'
|
||||
/** Mount-scoped contribution: while the rendering component is mounted, its
|
||||
* children render in the target area's slot; unmount disposes it. Use for
|
||||
* page-owned chrome (a page's titlebar control leaves with the page) —
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// The plugin authoring contract types; the SDK barrel re-exports them.
|
||||
|
||||
export type {
|
||||
HermesPlugin,
|
||||
PetMessageTone,
|
||||
PetSayOptions,
|
||||
PluginContext,
|
||||
PluginContribution,
|
||||
PluginNativeNotificationInput,
|
||||
PluginNotificationAction,
|
||||
PluginOs,
|
||||
PluginPet,
|
||||
PluginRestOptions,
|
||||
PluginSettingsPage,
|
||||
PluginSettingsSubpage,
|
||||
PluginStorage
|
||||
} from '@/contrib/plugin'
|
||||
@@ -3,6 +3,7 @@ import { atom } from 'nanostores'
|
||||
import { PRIMARY_SESSION_VIEW } from '@/app/chat/session-view'
|
||||
import { persistBoolean, persistString, storedBoolean, storedString } from '@/lib/storage'
|
||||
import { $petActivity, $petInfo, $petUnread, clearPetUnread, type PetActivity, type PetInfo } from '@/store/pet'
|
||||
import { $petPluginMessages, type PetPluginMessage } from '@/store/pet-plugin-messages'
|
||||
|
||||
/**
|
||||
* Controller for the pop-out pet overlay (main-renderer side).
|
||||
@@ -48,6 +49,8 @@ export interface PetOverlayStatePayload {
|
||||
unread: boolean
|
||||
/** Latest reaction — bumping its id forwards a burst to the overlay. */
|
||||
reaction: PetReaction | null
|
||||
/** Live plugin bubble lines (`ctx.pet.say`); this renderer owns their expiry. */
|
||||
pluginMessages?: PetPluginMessage[]
|
||||
}
|
||||
|
||||
export type PetOverlayControl =
|
||||
@@ -147,7 +150,8 @@ function currentPayload(): PetOverlayStatePayload {
|
||||
busy: PRIMARY_SESSION_VIEW.$busy.get(),
|
||||
awaiting: PRIMARY_SESSION_VIEW.$awaitingResponse.get(),
|
||||
unread: $petUnread.get(),
|
||||
reaction: $petReaction.get()
|
||||
reaction: $petReaction.get(),
|
||||
pluginMessages: $petPluginMessages.get()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +188,8 @@ function openOverlay(request: PetOverlayOpenRequest): void {
|
||||
PRIMARY_SESSION_VIEW.$busy.subscribe(pushNow),
|
||||
PRIMARY_SESSION_VIEW.$awaitingResponse.subscribe(pushNow),
|
||||
$petUnread.subscribe(pushNow),
|
||||
$petReaction.subscribe(pushNow)
|
||||
$petReaction.subscribe(pushNow),
|
||||
$petPluginMessages.subscribe(pushNow)
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { atom, computed } from 'nanostores'
|
||||
|
||||
/**
|
||||
* Plugin lines for the pet's speech bubble — the store behind `ctx.pet.say`.
|
||||
*
|
||||
* A plugin hands the host a short plain-text line; the host owns where and
|
||||
* when it shows (the core `PetBubble`, in-window and in the pop-out overlay),
|
||||
* so no plugin ever has to locate the pet in the app DOM or float its own
|
||||
* overlay over it. Every line is:
|
||||
*
|
||||
* - plain text: control characters stripped, whitespace collapsed, capped at
|
||||
* `PET_MESSAGE_MAX_CHARS` (rendered as a React text node, never HTML);
|
||||
* - attributed: it carries the plugin id and its display name, which the
|
||||
* bubble prints as a small label;
|
||||
* - short-lived: a TTL (default `PET_MESSAGE_DEFAULT_TTL_MS`, clamped) retires
|
||||
* it, and the disposer `say` returns retires it early;
|
||||
* - bounded: each plugin holds at most `PET_MESSAGE_MAX_PER_PLUGIN` live lines
|
||||
* and gets `PET_MESSAGE_RATE_LIMIT` says per `PET_MESSAGE_RATE_WINDOW_MS`.
|
||||
*
|
||||
* The bubble shows the newest live line. The owning plugin's context clears
|
||||
* all of its lines when it unloads or is disabled.
|
||||
*/
|
||||
|
||||
export type PetMessageTone = 'error' | 'info' | 'wait'
|
||||
|
||||
export interface PetSayOptions {
|
||||
/** Stable key within your plugin. Saying again with the same id replaces
|
||||
* that line in place (a ticking counter, a refreshed balance). */
|
||||
id?: string
|
||||
/** Visual tone: `info` (default, no glyph), `wait` (clock), `error` (alert). */
|
||||
tone?: PetMessageTone
|
||||
/** How long the line stays up, in ms. Clamped to 1 s – 30 s; default 6 s. */
|
||||
ttlMs?: number
|
||||
}
|
||||
|
||||
/** One live plugin line. Plain data so it can cross to the overlay window. */
|
||||
export interface PetPluginMessage {
|
||||
/** `<pluginId>:<id>` — unique across plugins. */
|
||||
key: string
|
||||
pluginId: string
|
||||
/** Display label (the plugin's name, else its id). */
|
||||
pluginName: string
|
||||
text: string
|
||||
tone: PetMessageTone
|
||||
/** Monotonic; the bubble shows the highest. */
|
||||
seq: number
|
||||
}
|
||||
|
||||
export const PET_MESSAGE_MAX_CHARS = 120
|
||||
export const PET_MESSAGE_LABEL_MAX_CHARS = 32
|
||||
export const PET_MESSAGE_DEFAULT_TTL_MS = 6_000
|
||||
export const PET_MESSAGE_MIN_TTL_MS = 1_000
|
||||
export const PET_MESSAGE_MAX_TTL_MS = 30_000
|
||||
export const PET_MESSAGE_MAX_PER_PLUGIN = 3
|
||||
export const PET_MESSAGE_RATE_LIMIT = 10
|
||||
export const PET_MESSAGE_RATE_WINDOW_MS = 10_000
|
||||
|
||||
const TONES: ReadonlySet<PetMessageTone> = new Set(['error', 'info', 'wait'])
|
||||
|
||||
export const $petPluginMessages = atom<PetPluginMessage[]>([])
|
||||
|
||||
/** The line the bubble shows: the newest live one, or null. */
|
||||
export const $petPluginMessage = computed($petPluginMessages, list =>
|
||||
list.reduce<null | PetPluginMessage>((top, m) => (!top || m.seq > top.seq ? m : top), null)
|
||||
)
|
||||
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
const sayLog = new Map<string, number[]>()
|
||||
let seq = 0
|
||||
|
||||
// C0/C1 control characters and the bidi overrides/isolates, which could make
|
||||
// a line render as something other than what the plugin passed.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const UNSAFE_CHARS = /[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g
|
||||
|
||||
/** Normalise arbitrary input to one capped line of plain text ('' = nothing to say). */
|
||||
export function sanitizePetText(value: unknown, max = PET_MESSAGE_MAX_CHARS): string {
|
||||
const text = String(value ?? '')
|
||||
.replace(UNSAFE_CHARS, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
|
||||
return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text
|
||||
}
|
||||
|
||||
function clampTtl(ttlMs: unknown): number {
|
||||
const n = typeof ttlMs === 'number' && Number.isFinite(ttlMs) ? ttlMs : PET_MESSAGE_DEFAULT_TTL_MS
|
||||
|
||||
return Math.min(PET_MESSAGE_MAX_TTL_MS, Math.max(PET_MESSAGE_MIN_TTL_MS, Math.round(n)))
|
||||
}
|
||||
|
||||
function allowSay(pluginId: string, now: number): boolean {
|
||||
const recent = (sayLog.get(pluginId) ?? []).filter(at => now - at < PET_MESSAGE_RATE_WINDOW_MS)
|
||||
|
||||
if (recent.length >= PET_MESSAGE_RATE_LIMIT) {
|
||||
sayLog.set(pluginId, recent)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
recent.push(now)
|
||||
sayLog.set(pluginId, recent)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function remove(key: string, expectedSeq?: number): void {
|
||||
const list = $petPluginMessages.get()
|
||||
const hit = list.find(m => m.key === key)
|
||||
|
||||
// A disposer from a replaced line must not retire its replacement.
|
||||
if (!hit || (expectedSeq !== undefined && hit.seq !== expectedSeq)) {
|
||||
return
|
||||
}
|
||||
|
||||
clearTimeout(timers.get(key))
|
||||
timers.delete(key)
|
||||
$petPluginMessages.set(list.filter(m => m.key !== key))
|
||||
}
|
||||
|
||||
const noop = () => {}
|
||||
|
||||
/**
|
||||
* Show `text` in the pet bubble on behalf of `pluginId`. Returns a disposer
|
||||
* that removes the line early (idempotent; a no-op once replaced or expired).
|
||||
* Empty text, or a say over the plugin's rate limit, shows nothing.
|
||||
*/
|
||||
export function sayPetMessage(
|
||||
pluginId: string,
|
||||
pluginName: string,
|
||||
text: unknown,
|
||||
options: PetSayOptions = {}
|
||||
): () => void {
|
||||
const clean = sanitizePetText(text)
|
||||
|
||||
if (!clean) {
|
||||
return noop
|
||||
}
|
||||
|
||||
if (!allowSay(pluginId, Date.now())) {
|
||||
console.warn(`[plugins] ${pluginId}: pet.say rate limit hit — line dropped`)
|
||||
|
||||
return noop
|
||||
}
|
||||
|
||||
const localId = sanitizePetText(options.id ?? '', 64) || `m${seq + 1}`
|
||||
const key = `${pluginId}:${localId}`
|
||||
const tone: PetMessageTone = options.tone && TONES.has(options.tone) ? options.tone : 'info'
|
||||
|
||||
const message: PetPluginMessage = {
|
||||
key,
|
||||
pluginId,
|
||||
pluginName: sanitizePetText(pluginName, PET_MESSAGE_LABEL_MAX_CHARS) || pluginId,
|
||||
seq: ++seq,
|
||||
text: clean,
|
||||
tone
|
||||
}
|
||||
|
||||
clearTimeout(timers.get(key))
|
||||
|
||||
let next = [...$petPluginMessages.get().filter(m => m.key !== key), message]
|
||||
const own = next.filter(m => m.pluginId === pluginId)
|
||||
|
||||
// Keep each plugin to its newest few lines; evict the oldest.
|
||||
for (const stale of own.slice(0, Math.max(0, own.length - PET_MESSAGE_MAX_PER_PLUGIN))) {
|
||||
clearTimeout(timers.get(stale.key))
|
||||
timers.delete(stale.key)
|
||||
next = next.filter(m => m.key !== stale.key)
|
||||
}
|
||||
|
||||
$petPluginMessages.set(next)
|
||||
timers.set(
|
||||
key,
|
||||
setTimeout(() => remove(key, message.seq), clampTtl(options.ttlMs))
|
||||
)
|
||||
|
||||
return () => remove(key, message.seq)
|
||||
}
|
||||
|
||||
/** Remove one of a plugin's lines by its `id`, or every line it owns. */
|
||||
export function clearPetMessages(pluginId: string, id?: string): void {
|
||||
const prefix = `${pluginId}:`
|
||||
const key = id === undefined ? null : `${prefix}${sanitizePetText(id, 64)}`
|
||||
|
||||
for (const m of $petPluginMessages.get()) {
|
||||
if (m.pluginId === pluginId && (key === null || m.key === key)) {
|
||||
remove(m.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Overlay window: adopt the main renderer's list verbatim (it owns expiry). */
|
||||
export function mirrorPetPluginMessages(list: unknown): void {
|
||||
$petPluginMessages.set(Array.isArray(list) ? (list as PetPluginMessage[]) : [])
|
||||
}
|
||||
|
||||
/** Test seam: drop every line, timer, and rate-limit window. */
|
||||
export function resetPetPluginMessages(): void {
|
||||
timers.forEach(clearTimeout)
|
||||
timers.clear()
|
||||
sayLog.clear()
|
||||
$petPluginMessages.set([])
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
hellohanchen
|
||||
# PR #134699 catalog sweep 1009
|
||||
@@ -0,0 +1,2 @@
|
||||
raaaas
|
||||
# PR #134512 catalog sweep 1009
|
||||
@@ -0,0 +1,2 @@
|
||||
silentpr0
|
||||
# PR #134716 catalog sweep 1009
|
||||
@@ -0,0 +1,2 @@
|
||||
tuancookiez-hub
|
||||
# PR #135259 catalog sweep 1009
|
||||
@@ -0,0 +1,2 @@
|
||||
vladzablotski-adaption
|
||||
# PR #135205 catalog sweep 1009
|
||||
@@ -802,10 +802,12 @@ def probe_failure_class(exc: BaseException) -> str:
|
||||
|
||||
if tagged := tagged_failure_class(exc):
|
||||
return tagged
|
||||
from tools.mcp_tool_errors import _is_auth_error, _iter_exception_nodes, _unwrap_exception_group
|
||||
from tools.mcp_tool_errors import InvalidMcpUrlError, _is_auth_error, _iter_exception_nodes, _unwrap_exception_group
|
||||
from tools.mcp_tool_node_abi import NodeAbiMismatchError
|
||||
try:
|
||||
root = _unwrap_exception_group(exc)
|
||||
if isinstance(root, InvalidMcpUrlError): # the configured URL is not http(s)://host
|
||||
return "config_invalid"
|
||||
if _is_auth_error(root) or getattr(getattr(root, "response", None), "status_code", None) in (401, 403):
|
||||
return "auth_required"
|
||||
# A missing stdio command (FileNotFoundError) or a native module built for another Node.
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
name: adaption
|
||||
repo: https://github.com/adaptionlabs/adaption-hermes-plugin
|
||||
sha: 958ad2e9077107be1919a5365541caa4e0acdcc8
|
||||
description: "Dataset import, adaptation, augmentation, translation, synthetic generation, and AutoScientist fine-tuning through the hosted Adaption MCP. Portable Agent Plugins v1 package with three workflow skills; the MCP server is added separately with OAuth (or an API key for headless use). Disclosure — skill-only, no code; the MCP tools call api.prod.adaptionlabs.ai, and launches spend Adaption credits (the skills request a cost estimate first, Hermes does not enforce it). Hermes does not prompt before those tools unless the server is configured with trust: untrusted."
|
||||
maintainer: adaptionlabs
|
||||
tier: community
|
||||
category: tools
|
||||
requires_hermes: ">=0.21.5"
|
||||
docs_url: https://github.com/adaptionlabs/adaption-hermes-plugin#readme
|
||||
version: "0.1.0"
|
||||
readme: true
|
||||
platforms: []
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
provides_hooks: []
|
||||
provides_middleware: []
|
||||
requires_env: []
|
||||
@@ -0,0 +1,25 @@
|
||||
# Community catalog entry — agent-batch
|
||||
#
|
||||
# Owner-submitted: the PR author (`raaaas`) owns and maintains the repo below.
|
||||
# Exact-SHA pin (40-hex). No self-updating code: the desktop half is a static
|
||||
# `desktop/plugin.js` that only calls its own local backend router; it never
|
||||
# downloads, replaces, or reloads itself.
|
||||
name: agent-batch
|
||||
repo: https://github.com/raaaas/hermes-agent-batch
|
||||
sha: "064afc5dba8869df703ba055aa2760730c0d30dc"
|
||||
subdir: plugin
|
||||
description: "Parallel AI task orchestrator. Paste a task list into the desktop panel; Hermes phases it into dependency-ordered groups through the agent_batch_plan / agent_batch_phase tools (or you group it yourself in one click), then each phase dispatches one independent coding agent per task to a GitHub Actions workflow in a repo you name — own branch, own PR — and the panel tracks the runs and the PRs. GITHUB_TOKEN is the only required credential; AGENT_BATCH_REPO, AGENT_BATCH_WORKFLOW, AGENT_BATCH_MODEL and AGENT_BATCH_STATE are optional (the last is a directory override for the plan file, not a secret) and each can be set per plan in the panel instead. Disclosure — Hermes can read and rewrite the stored phase plan through the agent_batch_plan / agent_batch_phase tools, but dispatch is always a confirmed click in the panel. Each dispatch fires a GitHub workflow_dispatch with your GITHUB_TOKEN (Actions + pull-requests write) on the repo you name. The workflow you copy into that repo installs third-party coding-agent CLIs (cline by default; the others are unpinned npm packages) and runs them unattended, with auto-approve / skip-permissions, repo write access and your provider secrets. They push real branches and open real PRs, and your task text and repository code go to that runner's model vendor. LLM cost belongs to the runner: cline spends Cline Credits or a BYOK key, opencode free models need its zen relay, kilo needs an active Kilo Go subscription (keyless CI runs fail), and claude-code / codex / dsh need their own keys."
|
||||
maintainer: raaaas
|
||||
tier: community
|
||||
category: automation
|
||||
docs_url: https://github.com/raaaas/hermes-agent-batch
|
||||
capabilities:
|
||||
provides_tools:
|
||||
- agent_batch_plan
|
||||
- agent_batch_phase
|
||||
provides_hooks: []
|
||||
provides_middleware: []
|
||||
requires_env:
|
||||
- GITHUB_TOKEN
|
||||
requires_hermes: ">=0.21.3"
|
||||
version: 1.0.0
|
||||
@@ -1,14 +1,8 @@
|
||||
name: blender
|
||||
repo: https://github.com/NousResearch/hermes-plugin-blender
|
||||
sha: d1929b593bddd2241a8939fb355e0a69b8e077af
|
||||
version: "0.1.0"
|
||||
description: >-
|
||||
Official Blender Lab MCP server over stdio, pinned to upstream v1.0.3,
|
||||
with a Blender workflow skill. Requires Blender 5.1+, its separately
|
||||
installed MCP add-on, Git and uv. First startup downloads Python dependencies;
|
||||
prepare the uv environment before connecting. Disclosure: tools execute
|
||||
arbitrary Python in Blender with the user's privileges. No automatic
|
||||
application/version/liveness gate; server discovery does not prove scene readiness.
|
||||
sha: 8aab816ce6577eb792a256b51777cbb4cd6523f0
|
||||
version: "0.2.0"
|
||||
description: "Official Blender Lab MCP server over stdio, pinned to upstream v1.0.3, with a Blender workflow skill. Requires Blender 5.1+, its separately installed MCP add-on, Git and uv. First startup downloads Python dependencies; prepare the uv environment before connecting. Disclosure — tools execute arbitrary Python in Blender with your privileges; first start downloads the pinned Blender Lab MCP server and its Python dependencies with uv; at install Hermes looks for Blender in the standard install locations (Windows uninstall registry and Program Files, /Applications and the Steam library on macOS, PATH, flatpak, snap and Steam on Linux) and refuses the install when it is missing. Server discovery does not prove scene readiness."
|
||||
title: Blender
|
||||
maintainer: NousResearch
|
||||
tier: official
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
name: bunny_space-platform
|
||||
title: Bunny Space
|
||||
repo: https://github.com/jasm001/hermes-plugin-bunny-space
|
||||
sha: 8c647a53b7d7514e9163e51e5aedc0179e4934c5
|
||||
description: "Bunny Space gateway adapter for Hermes Agent. Connects a Hermes profile (the bot brain) to the Bunny Space relay so the bot joins private rooms, answers its owner, and handles @bot:<slug> mentions in posts and comments. Outbound pull transport; no external dependencies. Disclosure — replies are prompted to be in Spanish; @bot mention tasks and assistance tasks are dispatched to Hermes under the fixed sender id \"owner\" (BUNNY_SPACE_ALLOWED_USERS must contain \"owner\"), and the plugin refuses to dispatch third-party content unless platform_toolsets.bunny_space is restricted to read-only tools (the platform's effective toolset, including MCP and plugin toolsets; add no_mcp when MCP servers are enabled); the private assistance worker is opt-in via BUNNY_SPACE_ASSISTANCE=1 (the relay cannot enable it); setting BUNNY_SPACE_ALLOW_ALL_USERS makes the connector refuse to connect."
|
||||
sha: fcea5421ed9418b6bfc4f292933310e43aa076c5
|
||||
description: "Bunny Space gateway adapter for Hermes Agent. Connects a Hermes profile (the bot brain) to the Bunny Space relay so the bot joins private rooms, answers its owner, and handles @bot:<slug> mentions in posts and comments. Outbound pull transport; no external dependencies. Disclosure — replies are prompted to be in Spanish; @bot mention tasks and assistance tasks are dispatched to Hermes under the fixed sender id \"owner\" (BUNNY_SPACE_ALLOWED_USERS must contain \"owner\"), and the plugin refuses to dispatch third-party content unless platform_toolsets.bunny_space is restricted to read-only tools (the platform's effective toolset, including MCP and plugin toolsets; add no_mcp when MCP servers are enabled); the private assistance worker is opt-in via BUNNY_SPACE_ASSISTANCE=1 (the relay cannot enable it); setting BUNNY_SPACE_ALLOW_ALL_USERS makes the connector refuse to connect; the relay sets the poll interval and may hand the connector a WebSocket \"wake\" URL it then connects to (wake signals only, no message content)."
|
||||
maintainer: jasm001
|
||||
tier: community
|
||||
category: platform
|
||||
docs_url: https://github.com/jasm001/hermes-plugin-bunny-space#readme
|
||||
version: "1.0.5"
|
||||
version: "1.0.6"
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
provides_hooks: []
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
name: codex-account-switch
|
||||
repo: https://github.com/SmelterLabs/hermes-account-switcher
|
||||
sha: 1d42d4cb7f2e4f27e244574c2e9c27548b96fca6
|
||||
subdir: ""
|
||||
description: "Windows desktop plugin that switches which Codex login and which Claude subscription account every local Hermes profile uses, with one idle-only, verified restart of Hermes Desktop and the gateway. Refuses while any profile is busy, restores the previous selection if a step fails, and writes a receipt that says whether the switch took effect. Disclosure — Windows only. A switch rewrites, through Hermes's own commands, the Codex credential order in every profile's auth.json and the CLAUDE_SUBSCRIPTION_DIRECTSDK_CONFIG_DIR line in every profile's .env, so every profile that has the plugin enabled spends the same selected Codex login and the same Claude login (one Hermes-owned claude-auth folder; no token is copied between profiles). It reads Codex tokens from each profile's auth.json and the Desktop backends' dashboard session token from their process environment, then closes Hermes Desktop and stops and restarts the gateway once both report idle (work started in the seconds after that check is cut off). It sends the target Codex token to OpenAI's usage endpoint and Claude login tokens to Anthropic's OAuth profile endpoint (also when the setup screen opens); if the target Claude login has expired it first runs the claude CLI once so the CLI renews its own token, which rotates that login's refresh token. No telemetry and no automatic account rotation."
|
||||
maintainer: SmelterLabs
|
||||
tier: community
|
||||
category: desktop
|
||||
requires_hermes: ">=0.21.6"
|
||||
docs_url: https://github.com/SmelterLabs/hermes-account-switcher#readme
|
||||
version: "1.2.2"
|
||||
platforms: [windows]
|
||||
readme: true
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
provides_hooks: []
|
||||
provides_middleware: []
|
||||
requires_env: []
|
||||
@@ -0,0 +1,19 @@
|
||||
name: command-ledger
|
||||
repo: https://github.com/olric3015/hermes-plugins
|
||||
sha: f2aa337e6c730d6efd93331940c6d3d6a0fd481b
|
||||
subdir: command-ledger
|
||||
description: "Local ledger of the slash commands you run in Hermes (built-in commands in the CLI, built-in and plugin commands on messaging platforms): /command-log shows how often each command was used, its share, in how many sessions and when last, per command, platform or surface, for the last 24 hours, any number of hours or days, the latest session or everything recorded; /command-log recent lists the newest commands with the alias typed. Disclosure — writes command metadata (command name, alias typed, surface, platform, a 12-digit hash of the session key; no arguments, session key or user id) to plugin-data/command-ledger/commands.jsonl, trimmed to 5,000 rows past 2 MB; observer only, cannot block or change a command; no network"
|
||||
maintainer: olric3015
|
||||
tier: community
|
||||
category: tools
|
||||
requires_hermes: ">=0.21.5"
|
||||
docs_url: https://github.com/olric3015/hermes-plugins/tree/main/command-ledger
|
||||
version: "1.0.0"
|
||||
image: https://raw.githubusercontent.com/olric3015/hermes-plugins/f2aa337e6c730d6efd93331940c6d3d6a0fd481b/docs/command-ledger-card.png
|
||||
platforms: []
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
provides_hooks:
|
||||
- pre_command
|
||||
provides_middleware: []
|
||||
requires_env: []
|
||||
@@ -1,6 +1,6 @@
|
||||
name: cursor-provider
|
||||
repo: https://github.com/anpicasso/hermes-cursor-provider
|
||||
sha: 36eed8c2e37b19fd64b647b3f6611f0e017ad59e
|
||||
sha: 833122c23a8e1d1570446a358cdb8e120d7ce42c
|
||||
subdir: provider
|
||||
description: "Cursor model provider using the official proprietary Cursor SDK. Disclosure — uses Cursor's proprietary cursor-sdk (PyPI, bundled binary bridge on loopback) with the user's CURSOR_API_KEY; sends the full conversation, tool schemas/results and images (HTTPS URLs or base64) to Cursor; keeps private per-session SDK workspaces under HERMES_HOME/cache/cursor-sdk; Cursor built-in tools and MCP are requested off and Hermes keeps tool execution and approvals."
|
||||
maintainer: anpicasso
|
||||
@@ -8,7 +8,7 @@ tier: community
|
||||
category: models
|
||||
requires_hermes: ">=0.21.4"
|
||||
docs_url: https://github.com/anpicasso/hermes-cursor-provider#readme
|
||||
version: "0.3.5"
|
||||
version: "0.3.6"
|
||||
platforms: []
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
name: dashboard-auth-feishu
|
||||
repo: https://github.com/xielevi/hermes-dashboard-auth-feishu
|
||||
sha: 8d69b43dcbe63d9925eb5724c046a72ac6e1260a
|
||||
description: "Feishu / Lark OAuth sign-in for the web dashboard, admitted by tenant + open_id allow-list. Registers the `feishu` dashboard auth provider once plugins.entries.dashboard-auth-feishu.settings (app_id, tenant_key, owner_open_ids) and an app secret are configured. Disclosure — calls only the fixed Feishu or Lark authorize, token and user_info endpoints; reads HERMES_DASHBOARD_FEISHU_APP_SECRET or, as a fallback, the Feishu gateway's FEISHU_APP_SECRET, plus HERMES_DASHBOARD_FEISHU_SESSION_KEY; stores tenant, open_id and display name per sign-in in a 0600 SQLite file under plugin-data (no upstream tokens); every admitted identity gets full dashboard access; upstream PKCE is not used (confidential client with single-use cookie-bound state); pending logins are in memory, so one dashboard worker only; WebSockets opened before logout may stay open until the session key is rotated and the dashboard restarted."
|
||||
maintainer: xielevi
|
||||
tier: community
|
||||
category: general
|
||||
title: Feishu / Lark Dashboard Login
|
||||
requires_hermes: ">=0.21.5"
|
||||
docs_url: https://github.com/xielevi/hermes-dashboard-auth-feishu#readme
|
||||
version: "1.0.0"
|
||||
known_issues:
|
||||
- "Lark endpoints are implemented but not live-tested; Feishu is tested end to end."
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
provides_hooks: []
|
||||
provides_middleware: []
|
||||
requires_env: []
|
||||
@@ -0,0 +1,21 @@
|
||||
name: discord-tps
|
||||
repo: https://github.com/tuancookiez-hub/hermes-discord-tokenpersec
|
||||
sha: 6f84f9eb5d3d8cae01bf95ea02f628a818ad39ad
|
||||
subdir: discord-tps
|
||||
version: "1.0.2"
|
||||
requires_hermes: ">=0.21"
|
||||
description: "Show live tokens-per-second and the active model on your Discord bot's presence. Disclosure — Replaces the Discord bot's public presence with the live tok/s figure and the model name of the most recent API call in this gateway (any platform or chat), visible to anyone who can see the bot; updates at most every interval seconds (minimum 10) over the gateway's existing Discord connection. Reads no credentials and makes no network calls of its own."
|
||||
maintainer: TunaCookie
|
||||
tier: community
|
||||
category: platform
|
||||
docs_url: https://github.com/tuancookiez-hub/hermes-discord-tokenpersec/blob/6f84f9eb5d3d8cae01bf95ea02f628a818ad39ad/discord-tps/README.md
|
||||
readme: true
|
||||
image: https://raw.githubusercontent.com/tuancookiez-hub/hermes-discord-tokenpersec/6f84f9eb5d3d8cae01bf95ea02f628a818ad39ad/assets/banner.png
|
||||
screenshots:
|
||||
- https://raw.githubusercontent.com/tuancookiez-hub/hermes-discord-tokenpersec/6f84f9eb5d3d8cae01bf95ea02f628a818ad39ad/assets/presence-screenshot.png
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
provides_hooks:
|
||||
- post_api_request
|
||||
provides_middleware: []
|
||||
requires_env: []
|
||||
@@ -0,0 +1,26 @@
|
||||
name: enchanted-composer
|
||||
repo: https://github.com/noxius-studium/enchanted-composer
|
||||
sha: 63cb51633a68eb1c2739b6ab8d17a383361d20c4
|
||||
title: Enchanted Composer
|
||||
description: "Draft enhancement, reusable prompt libraries, undo/redo, and owner-bound full-duplex voice for Hermes Desktop. Disclosure — Derived from hermes-live-voice by Synero (itself derived from hermes-talk) and prompt-enhance by apoapostolov (both MIT; notices preserved). Starting voice captures microphone audio. The Codex-subscription lane spawns the official codex app-server CLI under its own login with a minimal environment; through it the captured profile's SOUL.md (up to 4,000 chars), the list of installed skills and enabled tools, and Hermes task results go to OpenAI. The API lane runs only when COMPOSER_REALTIME_OFFER_URL is set: it posts the SDP offer to that https URL with COMPOSER_OPENAI_API_KEY, or the profile's OPENAI_API_KEY, as a Bearer token (redirects refused). A user-configured wss:// bridge receives microphone audio, chat/profile identifiers and task results. Spoken requests the voice model delegates run as Hermes agent turns with tools in the chat where voice started; runs that need approval are stopped, not approved. Settings and usage totals are stored under HERMES_HOME/plugin-data/enchanted-composer/. No telemetry, no self-updater, no Codex OAuth-file reads."
|
||||
maintainer: Deadtrix21
|
||||
tier: community
|
||||
category: voice
|
||||
requires_hermes: ">=0.21.5"
|
||||
docs_url: https://github.com/noxius-studium/enchanted-composer/blob/63cb51633a68eb1c2739b6ab8d17a383361d20c4/README.md
|
||||
version: "0.2.0"
|
||||
image: https://raw.githubusercontent.com/noxius-studium/enchanted-composer/63cb51633a68eb1c2739b6ab8d17a383361d20c4/docs/media/banner.png
|
||||
screenshots:
|
||||
- https://raw.githubusercontent.com/noxius-studium/enchanted-composer/63cb51633a68eb1c2739b6ab8d17a383361d20c4/docs/media/enhancement-desktop.png
|
||||
- https://raw.githubusercontent.com/noxius-studium/enchanted-composer/63cb51633a68eb1c2739b6ab8d17a383361d20c4/docs/media/voice-desktop.png
|
||||
- https://raw.githubusercontent.com/noxius-studium/enchanted-composer/63cb51633a68eb1c2739b6ab8d17a383361d20c4/docs/media/prompts-mobile.png
|
||||
readme: true
|
||||
platforms: []
|
||||
known_issues:
|
||||
- Codex subscription authentication is managed with `codex login` outside Hermes; the plugin does not read or write Codex OAuth files.
|
||||
- Full two-sided persistent voice history and a phone voice client are not included in version 0.2.0.
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
provides_hooks: []
|
||||
provides_middleware: []
|
||||
requires_env: []
|
||||
@@ -1,15 +1,15 @@
|
||||
name: error-ledger
|
||||
repo: https://github.com/olric3015/hermes-plugins
|
||||
sha: d8031eacf95a014334e719afbf1eb9c0c28e57c1
|
||||
sha: 732cf2268b43b8d99c385912f9a50af149cad0ee
|
||||
subdir: error-ledger
|
||||
description: "Local ledger of failed provider calls (rate limits, overloads, timeouts, context overflows): /errors shows how many attempts failed per model, provider or cause, how many Hermes could retry, and the most common HTTP status, for the last 24 hours, any number of hours or days, the latest session or everything recorded; /errors recent lists the newest failures and how long ago each happened. Disclosure — writes failed-attempt metadata (ids, provider, model, HTTP status, Hermes's failure class, exception class name, timings; no error message, request or prompt text) to plugin-data/error-ledger/errors.jsonl, trimmed to 5,000 rows past 2 MB; no network"
|
||||
description: "Local ledger of failed provider calls (rate limits, overloads, timeouts, context overflows): /errors shows how many attempts failed per model, provider or cause, how many Hermes could retry, and the most common HTTP status, for the last 24 hours, any number of hours or days, the latest session or everything recorded; /errors days puts one day on each line, newest first; /errors recent lists the newest failures and how long ago each happened. Disclosure — writes failed-attempt metadata (ids, provider, model, HTTP status, Hermes's failure class, exception class name, timings; no error message, request or prompt text) to plugin-data/error-ledger/errors.jsonl, trimmed to 5,000 rows past 2 MB; no network"
|
||||
maintainer: olric3015
|
||||
tier: community
|
||||
category: tools
|
||||
requires_hermes: ">=0.21.5"
|
||||
docs_url: https://github.com/olric3015/hermes-plugins/tree/main/error-ledger
|
||||
version: "1.1.0"
|
||||
image: https://raw.githubusercontent.com/olric3015/hermes-plugins/d8031eacf95a014334e719afbf1eb9c0c28e57c1/docs/error-ledger-card.png
|
||||
version: "1.2.0"
|
||||
image: https://raw.githubusercontent.com/olric3015/hermes-plugins/732cf2268b43b8d99c385912f9a50af149cad0ee/docs/error-ledger-card.png
|
||||
platforms: []
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: excel_line
|
||||
repo: https://github.com/kalice-vi/hermes-excel-line
|
||||
sha: 45dcf84f59545e83e7e4545c2802f5dfac05390d
|
||||
sha: a48cc5a9d1763e7bbc21b28fb12ed1289870150c
|
||||
description: Excel-backed hierarchical long-term memory tree (10-row cap per workbook, dynamic branching, leaf pointers). Classifier defaults to the host model; third-party keyless rotation is opt-in.
|
||||
maintainer: kalice-vi
|
||||
tier: community
|
||||
|
||||
@@ -14,12 +14,14 @@
|
||||
name: frihet
|
||||
repo: https://github.com/Frihet-io/hermes-frihet
|
||||
# Pinned SHA — Frihet-io/hermes-frihet @ v0.1.0
|
||||
# (fix(security): canonical Frihet externalSideEffects drives classification).
|
||||
# (fix(deps): declare PyYAML as runtime dep with <7 cap, merged via PR #1.)
|
||||
#
|
||||
# This SHA supersedes ``f331208`` (the older doctor that read the OAuth
|
||||
# token cache), ``24a9612`` (first privacy fix), ``3c8325a`` (CI rename
|
||||
# + first default_enabled), AND ``b9a8067`` (slash-command + redact
|
||||
# per-line + first connector safety net). It pins:
|
||||
# + first default_enabled), ``b9a8067`` (slash-command + redact
|
||||
# per-line + first connector safety net), ``b77f5df4`` (canonical Frihet
|
||||
# externalSideEffects drives classification), AND ``e805bf2`` (source
|
||||
# head of the PyYAML branch before squash). It pins all of the above AND:
|
||||
# * Hook dispatcher that passes the FULL tool name to the gate (no
|
||||
# more silent bypass of the approval gate).
|
||||
# * Doctor that derives auth evidence from ``ctx.call_mcp`` — the
|
||||
@@ -45,10 +47,20 @@ repo: https://github.com/Frihet-io/hermes-frihet
|
||||
# for tools the contract has not yet named.
|
||||
# * CI renamed to manifest-validate (schema-only) plus a manual
|
||||
# live-hermes-validate (workflow_dispatch) for maintainers.
|
||||
# * Runtime PyYAML dependency declared in pyproject.toml at >=6.0,<7
|
||||
# (catalog security recommendation). Closes the silent "unknown"
|
||||
# report from `frihet.py` mcp_server_block_present() in clean venvs
|
||||
# without PyYAML preinstalled (regression: 7 tests in tests/test_frihet.py).
|
||||
# * New tests/test_pyyaml_dependency.py asserts that yaml is importable
|
||||
# in the test environment, gating the runtime-dependency contract.
|
||||
# * No code-path changes: frihet.py, hook dispatcher, doctor and
|
||||
# /frihet slash command remain byte-identical to the b77f5df4 SHA;
|
||||
# this bump only makes the existing tests pass on a clean venv.
|
||||
|
||||
#
|
||||
# Tests: 84 pass + 3 pre-existing skips. hermes plugins validate
|
||||
# Tests: 87 pass + 3 pre-existing skips. hermes plugins validate
|
||||
# --install-deps 15/15 with security scan "safe".
|
||||
sha: b77f5df4b269ada5296ee07fc9c03d118933cbc0
|
||||
sha: 4f092d58398ab252ea1fa6b07bfefacec717abd9
|
||||
description: "Official Hermes Agent integration for Frihet — discoverability, setup, diagnostics, and operating guidance for the Frihet MCP. Zero duplicated tools; the canonical 158-operation Frihet surface stays in @frihet/mcp-server and the remote endpoint https://mcp.frihet.io/mcp. Disclosure — /frihet setup and /frihet doctor send an unauthenticated MCP initialize to mcp.frihet.io (or FRIHET_MCP_URL); /frihet doctor also makes one read-only list_invoices call through Hermes's MCP client once you grant plugins.entries.frihet.mcp_allowlist; reads ~/.hermes/config.yaml only to check that an mcp_servers.frihet block exists; the pre_tool_call hook prompts before Frihet MCP calls it classifies as irreversible or unknown, but reads and draft-first creates pass without a prompt unless the server sets trust: untrusted."
|
||||
maintainer: Frihet-io
|
||||
tier: community
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
name: hermes-cron
|
||||
repo: https://github.com/itpartypattaya/hermes-cron
|
||||
sha: b57e15fe990ba0be14c8eb4ec04baa7e86cd725e
|
||||
description: "Operating skill for Hermes cron: lifecycle rules (pause vs disable vs remove, `in 30m` vs `30m`), four ways to avoid needless LLM runs (no_agent, wakeAgent pre-check, monitor, [SILENT]), verify-after-change, a layered runbook and a read-only doctor that complements `hermes cron doctor` and reports one error across several jobs as one scheduler fault. Portable Agent Plugins v1 package (plugin.json + skills/); no tools, hooks, middleware or env vars. Disclosure — skill only, no network; the bundled doctor reads ~/.hermes/cron/jobs.json, ticker stamps and executions.db read-only and runs the local hermes cron doctor (argument list, no shell), whose job loader may repair malformed jobs.json entries (--no-builtin skips it)."
|
||||
sha: 350ec6edb06c50b88b867d877ede367bf89a3f95
|
||||
description: "Operating skill for Hermes cron: lifecycle rules (pause vs disable vs remove, `in 30m` vs `30m`), four ways to avoid needless LLM runs (no_agent, wakeAgent pre-check, monitor, [SILENT]), verify-after-change, a layered runbook and a read-only doctor that complements `hermes cron doctor` and reports one error across several jobs as one scheduler fault and a cron store that cannot be written. Portable Agent Plugins v1 package (plugin.json + skills/); no tools, hooks, middleware or env vars. Disclosure — skill only, no network; the bundled doctor reads ~/.hermes/cron/jobs.json, ticker stamps and executions.db read-only, checks the cron directory's permissions and free space without writing, and runs the local hermes cron doctor (argument list, no shell), whose job loader may repair malformed jobs.json entries (--no-builtin skips it)."
|
||||
maintainer: itpartypattaya
|
||||
tier: community
|
||||
category: automation
|
||||
requires_hermes: ">=0.21"
|
||||
docs_url: https://github.com/itpartypattaya/hermes-cron/blob/b57e15fe990ba0be14c8eb4ec04baa7e86cd725e/README.md
|
||||
version: "1.2.0"
|
||||
image: "https://raw.githubusercontent.com/itpartypattaya/hermes-cron/b57e15fe990ba0be14c8eb4ec04baa7e86cd725e/docs/banner.png"
|
||||
docs_url: https://github.com/itpartypattaya/hermes-cron/blob/350ec6edb06c50b88b867d877ede367bf89a3f95/README.md
|
||||
version: "1.3.0"
|
||||
image: "https://raw.githubusercontent.com/itpartypattaya/hermes-cron/350ec6edb06c50b88b867d877ede367bf89a3f95/docs/banner.png"
|
||||
screenshots:
|
||||
- "https://raw.githubusercontent.com/itpartypattaya/hermes-cron/b57e15fe990ba0be14c8eb4ec04baa7e86cd725e/docs/modes.png"
|
||||
- "https://raw.githubusercontent.com/itpartypattaya/hermes-cron/b57e15fe990ba0be14c8eb4ec04baa7e86cd725e/docs/doctor.png"
|
||||
- "https://raw.githubusercontent.com/itpartypattaya/hermes-cron/350ec6edb06c50b88b867d877ede367bf89a3f95/docs/modes.png"
|
||||
- "https://raw.githubusercontent.com/itpartypattaya/hermes-cron/350ec6edb06c50b88b867d877ede367bf89a3f95/docs/doctor.png"
|
||||
platforms: []
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
provides_hooks: []
|
||||
provides_middleware: []
|
||||
requires_env: []
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
name: hermes-dreaming
|
||||
repo: https://github.com/itpartypattaya/hermes-dreaming
|
||||
sha: dd43dfb0fa5ff80a82c4b5ec340bfbcd5a0f3a71
|
||||
description: "Nightly deterministic memory consolidation (\"dreaming\"): corroborates candidate facts with what humans actually said on distinct days, proposes the few worth keeping with a verbatim edit anchor, asks whether stale memory entries still hold, and wakes the agent only when there is work — including a memory file close to its char limit. Fact sources: the holographic store, a SQLite or JSONL export, or none. Portable Agent Plugins v1 package (plugin.json + skills/); no tools, hooks, middleware or env vars. Disclosure — scripts make no network or LLM calls and read state.db and the fact store read-only; the user-run installer copies two gate scripts into $HERMES_HOME/scripts, seeds $HERMES_HOME/dreaming.json and creates (or with --rebind re-points) two Hermes cron jobs bound to this plugin's own skill, whose agent runs send recent human messages (up to 200 / 40k chars) and fact snippets to your configured model; state goes to $HERMES_HOME/cache and a diary to $HERMES_HOME/memories/DREAMS.md; memory is changed by the agent through the regular memory tool."
|
||||
sha: e2e4da34f86fa83c7a5f119afded3666315478f9
|
||||
description: "Nightly deterministic memory consolidation (\"dreaming\"): corroborates candidate facts with what humans actually said on distinct days, proposes the few worth keeping with a verbatim edit anchor, asks whether stale memory entries still hold, and wakes the agent only when there is work — including a memory file close to its char limit. Fact sources: the holographic store, a SQLite or JSONL export, or none. Portable Agent Plugins v1 package (plugin.json + skills/); no tools, hooks, middleware or env vars. Disclosure — scripts make no network or LLM calls and read state.db and the fact store read-only (plus Hermes config.yaml's memory/timezone keys and the pending/memory approval queue); the user-run installer copies two gate scripts into $HERMES_HOME/scripts, seeds $HERMES_HOME/dreaming.json and creates (or with --rebind re-points) two Hermes cron jobs, through Hermes' cron API, bound to this plugin's own skill with only the memory toolset; with a chat --deliver target the report is attached as a session you can reply to. Those agent runs send recent human messages (up to 200 / 40k chars) and fact snippets to your configured model; state goes to $HERMES_HOME/cache and a diary to $HERMES_HOME/memories/DREAMS.md (configurable); memory files are changed by the agent through the regular memory tool, and with the holographic provider it also adds candidate facts via fact_store and lowers trust on human-rejected facts via fact_feedback."
|
||||
maintainer: itpartypattaya
|
||||
tier: community
|
||||
category: memory
|
||||
requires_hermes: ">=0.21"
|
||||
docs_url: https://github.com/itpartypattaya/hermes-dreaming/blob/dd43dfb0fa5ff80a82c4b5ec340bfbcd5a0f3a71/README.md
|
||||
version: "2.1.1"
|
||||
image: "https://raw.githubusercontent.com/itpartypattaya/hermes-dreaming/dd43dfb0fa5ff80a82c4b5ec340bfbcd5a0f3a71/docs/banner.png"
|
||||
docs_url: https://github.com/itpartypattaya/hermes-dreaming/blob/e2e4da34f86fa83c7a5f119afded3666315478f9/README.md
|
||||
version: "2.2.1"
|
||||
image: "https://raw.githubusercontent.com/itpartypattaya/hermes-dreaming/e2e4da34f86fa83c7a5f119afded3666315478f9/docs/banner.png"
|
||||
screenshots:
|
||||
- "https://raw.githubusercontent.com/itpartypattaya/hermes-dreaming/dd43dfb0fa5ff80a82c4b5ec340bfbcd5a0f3a71/docs/overview.png"
|
||||
- "https://raw.githubusercontent.com/itpartypattaya/hermes-dreaming/dd43dfb0fa5ff80a82c4b5ec340bfbcd5a0f3a71/docs/lifecycle.png"
|
||||
- "https://raw.githubusercontent.com/itpartypattaya/hermes-dreaming/dd43dfb0fa5ff80a82c4b5ec340bfbcd5a0f3a71/docs/files.png"
|
||||
- "https://raw.githubusercontent.com/itpartypattaya/hermes-dreaming/e2e4da34f86fa83c7a5f119afded3666315478f9/docs/overview.png"
|
||||
- "https://raw.githubusercontent.com/itpartypattaya/hermes-dreaming/e2e4da34f86fa83c7a5f119afded3666315478f9/docs/lifecycle.png"
|
||||
- "https://raw.githubusercontent.com/itpartypattaya/hermes-dreaming/e2e4da34f86fa83c7a5f119afded3666315478f9/docs/files.png"
|
||||
platforms: []
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
name: hermes-field-notes
|
||||
repo: https://github.com/itpartypattaya/hermes-field-notes
|
||||
sha: 36017775f9b2c1a37579c7da3e7878480179d4df
|
||||
description: "Field notes for a Hermes install: search known pitfalls before debugging and record new ones with root cause; register every local core edit as a patch note with a declarative checks file, then verify read-only after each hermes update (OK / MISSING / UPSTREAMED / N/A / UNKNOWN with reason) and list core files changed outside the registry. Ships 41 pitfalls and 10 patch recipes verified against 0.21.5. Optional pinned Telegram dashboard edited in place by an hourly no_agent cron job. Portable Agent Plugins v1 package (plugin.json + skills/); no tools, hooks, middleware or env vars; never edits the core or runs patch scripts. Disclosure — its scripts only read the Hermes core (pyproject.toml, read-only git status/diff) and never edit it or run patch scripts; the skill does walk the agent through hand-editing core files from its patch recipes and re-running the owner's re-apply scripts, each only after the user says yes. Network: Telegram Bot API only when the dashboard is enabled (reuses the agent's TELEGRAM_BOT_TOKEN from env or ~/.hermes/.env), and the public GitHub API without credentials (latest release plus compare against the installed commit SHA, cached 12 h; upstream.check=false disables). Writes: the notes store (~/.hermes/field-notes), ~/.hermes/field-notes.json, state and lock files in ~/.hermes/cache, a copy of the cron script in ~/.hermes/scripts when install.py runs (the skill runs it on first use), and one hourly no_agent cron job only when the user runs install_cron.py."
|
||||
sha: 69a76ae9861a3de4fcf92777c1ab593e70cfe650
|
||||
description: "Field notes for a Hermes install: search known pitfalls before debugging and record new ones with root cause; register every local core edit as a patch note with a declarative checks file, then verify read-only after each hermes update (OK / MISSING / UPSTREAMED / N/A / UNKNOWN with reason) and list core files changed outside the registry. Ships 42 pitfalls and 10 patch recipes checked against 0.21.6. Optional pinned Telegram dashboard edited in place by an hourly no_agent cron job. Portable Agent Plugins v1 package (plugin.json + skills/); no tools, hooks, middleware or env vars; never edits the core or runs patch scripts. Disclosure — its scripts only read the Hermes core (pyproject.toml, read-only git status/diff) and never edit it or run patch scripts; the skill does walk the agent through hand-editing core files from its patch recipes and re-running the owner's re-apply scripts, each only after the user says yes. Network: Telegram Bot API only when the dashboard is enabled (reuses the agent's TELEGRAM_BOT_TOKEN from env or ~/.hermes/.env), and the public GitHub API without credentials (latest release plus compare against the installed commit SHA, cached 12 h; upstream.check=false disables). Writes: the notes store (~/.hermes/field-notes), ~/.hermes/field-notes.json, state and lock files in ~/.hermes/cache, a copy of the cron script in ~/.hermes/scripts when install.py runs (the skill runs it on first use), and one hourly no_agent cron job only when the user runs install_cron.py."
|
||||
maintainer: itpartypattaya
|
||||
tier: community
|
||||
category: tools
|
||||
requires_hermes: ">=0.21"
|
||||
docs_url: https://github.com/itpartypattaya/hermes-field-notes/blob/36017775f9b2c1a37579c7da3e7878480179d4df/README.md
|
||||
version: "1.2.1"
|
||||
image: "https://raw.githubusercontent.com/itpartypattaya/hermes-field-notes/36017775f9b2c1a37579c7da3e7878480179d4df/docs/banner.png"
|
||||
docs_url: https://github.com/itpartypattaya/hermes-field-notes/blob/69a76ae9861a3de4fcf92777c1ab593e70cfe650/README.md
|
||||
version: "1.2.3"
|
||||
image: "https://raw.githubusercontent.com/itpartypattaya/hermes-field-notes/69a76ae9861a3de4fcf92777c1ab593e70cfe650/docs/banner.png"
|
||||
screenshots:
|
||||
- "https://raw.githubusercontent.com/itpartypattaya/hermes-field-notes/36017775f9b2c1a37579c7da3e7878480179d4df/docs/how-it-works.png"
|
||||
- "https://raw.githubusercontent.com/itpartypattaya/hermes-field-notes/36017775f9b2c1a37579c7da3e7878480179d4df/docs/patch-lifecycle.png"
|
||||
- "https://raw.githubusercontent.com/itpartypattaya/hermes-field-notes/69a76ae9861a3de4fcf92777c1ab593e70cfe650/docs/how-it-works.png"
|
||||
- "https://raw.githubusercontent.com/itpartypattaya/hermes-field-notes/69a76ae9861a3de4fcf92777c1ab593e70cfe650/docs/patch-lifecycle.png"
|
||||
platforms: []
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
name: hermes-honcho-plugin
|
||||
title: Honcho Memory
|
||||
repo: https://github.com/outpoints/hermes-honcho-plugin
|
||||
sha: 4fb2a175981534f38e9356957212350087546d6e
|
||||
sha: 0323f5d6d0e1790aa801a0801cab8896f719b72b
|
||||
description: "Desktop workbench for the Honcho memory behind the focused chat: read the newest conclusions, trace their premises, search messages, ask scoped questions, and add confirmed corrections or source files. Uses each profile's existing Honcho provider configuration. Disclosure — talks only to the Honcho server each profile is already configured for, through Hermes's own Honcho client and credentials, and polls it about every 10 seconds for the focused chat while Desktop is open. Ask runs a Honcho reasoning call that may use credits. Add fact/correction and Add to session write to Honcho only after you confirm the target in a dialog; Add to session uploads the text or PDF/JSON/text file you choose. A chat from another local profile is read with that profile's own Honcho configuration, only where this plugin is enabled. It reads the focused chat's title and folder from the profile's session database, read-only. No telemetry."
|
||||
maintainer: outpoints
|
||||
tier: community
|
||||
category: desktop
|
||||
version: "0.4.0"
|
||||
version: "0.4.1"
|
||||
requires_hermes: ">=0.21.4"
|
||||
docs_url: https://github.com/outpoints/hermes-honcho-plugin
|
||||
image: https://raw.githubusercontent.com/outpoints/hermes-honcho-plugin/4fb2a175981534f38e9356957212350087546d6e/docs/catalog/banner.png
|
||||
image: https://raw.githubusercontent.com/outpoints/hermes-honcho-plugin/0323f5d6d0e1790aa801a0801cab8896f719b72b/docs/catalog/banner.png
|
||||
screenshots:
|
||||
- https://raw.githubusercontent.com/outpoints/hermes-honcho-plugin/4fb2a175981534f38e9356957212350087546d6e/docs/catalog/gallery-memory.png
|
||||
- https://raw.githubusercontent.com/outpoints/hermes-honcho-plugin/4fb2a175981534f38e9356957212350087546d6e/docs/catalog/gallery-ask.png
|
||||
- https://raw.githubusercontent.com/outpoints/hermes-honcho-plugin/4fb2a175981534f38e9356957212350087546d6e/docs/catalog/gallery-correct.png
|
||||
- https://raw.githubusercontent.com/outpoints/hermes-honcho-plugin/4fb2a175981534f38e9356957212350087546d6e/docs/catalog/gallery-pane.png
|
||||
- https://raw.githubusercontent.com/outpoints/hermes-honcho-plugin/4fb2a175981534f38e9356957212350087546d6e/docs/catalog/gallery-messages.png
|
||||
- https://raw.githubusercontent.com/outpoints/hermes-honcho-plugin/4fb2a175981534f38e9356957212350087546d6e/docs/catalog/gallery-status.png
|
||||
- https://raw.githubusercontent.com/outpoints/hermes-honcho-plugin/0323f5d6d0e1790aa801a0801cab8896f719b72b/docs/catalog/gallery-memory.png
|
||||
- https://raw.githubusercontent.com/outpoints/hermes-honcho-plugin/0323f5d6d0e1790aa801a0801cab8896f719b72b/docs/catalog/gallery-ask.png
|
||||
- https://raw.githubusercontent.com/outpoints/hermes-honcho-plugin/0323f5d6d0e1790aa801a0801cab8896f719b72b/docs/catalog/gallery-correct.png
|
||||
- https://raw.githubusercontent.com/outpoints/hermes-honcho-plugin/0323f5d6d0e1790aa801a0801cab8896f719b72b/docs/catalog/gallery-pane.png
|
||||
- https://raw.githubusercontent.com/outpoints/hermes-honcho-plugin/0323f5d6d0e1790aa801a0801cab8896f719b72b/docs/catalog/gallery-messages.png
|
||||
- https://raw.githubusercontent.com/outpoints/hermes-honcho-plugin/0323f5d6d0e1790aa801a0801cab8896f719b72b/docs/catalog/gallery-status.png
|
||||
readme: true
|
||||
platforms: [linux, macos, windows]
|
||||
capabilities:
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
name: hermes-openwhispr
|
||||
repo: https://github.com/mooserini/hermes-openwhispr
|
||||
sha: 636314f33376616d3a39815eb63b8a0f06900f3b
|
||||
description: "Local-first OpenWhispr dictation history and shared notes for Hermes agents: find what the user said, search notes, transcribe local audio. Free local desktop bridge by default, paid cloud opt-in only. Skill-carrier plugin, no tools or hooks registered. Disclosure — skill only: the agent runs the external OpenWhispr CLI (installed with an unpinned `npm install -g @openwhispr/cli`) through the terminal tool to read your dictation history and notes over the OpenWhispr desktop app's authenticated loopback bridge, and, with your consent each time, to create, update or delete notes, transcriptions and dictionary entries and to transcribe local audio files; OpenWhispr Cloud (api.openwhispr.com, including an email-code sign-in that mints an API key kept in the CLI's own config) is used only when you opt in; the agent never opens credential files itself."
|
||||
maintainer: mooserini
|
||||
tier: community
|
||||
category: voice
|
||||
docs_url: https://github.com/mooserini/hermes-openwhispr#readme
|
||||
version: "0.1.0"
|
||||
platforms: []
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
provides_hooks: []
|
||||
provides_middleware: []
|
||||
requires_env: []
|
||||
@@ -1,15 +1,15 @@
|
||||
name: hermes-pickup
|
||||
repo: https://github.com/murenovich/hermes-pickup
|
||||
sha: cb3d49383b0121fc7c66f6d79387e2ea0a05a7dd
|
||||
description: "Pick up where you left off: up to 3 cards of unfinished work from your recent Hermes chats, project folders and files, each with where you stopped and a one-click next step. Adapted from Herald OS by Luke (iamlukethedev), MIT. Disclosure — after explicit in-app consent (stored per profile), each Refresh reads recent chats from ALL profiles' session stores (read-only) and sends chat excerpts and metadata, project/file names and paths, git branch/changed-file names and recent commit subjects to the current profile's Hermes LLM route (the auxiliary title-generation model, main provider unless configured); runs read-only git and, on macOS, Spotlight `mdfind`; never reads document contents; writes only $HERMES_HOME/pickup/. The \"Next step\" button can submit a model-written prompt straight into the card's chat in one click. The optional install.sh, when asked, adds the plugin to every profile's plugins.enabled (undone exactly by uninstall.sh)."
|
||||
sha: dc9b2b9b24b194f6d9b2f17f8bd4c7fa73105a5d
|
||||
description: "Pick up where you left off: up to 10 cards (5 by default) of unfinished work from your recent Hermes chats, project folders and files, each with where you stopped and a one-click next step. Adapted from Herald OS by Luke (iamlukethedev), MIT. Disclosure — after explicit in-app consent (stored per profile), each Refresh reads recent chats from every profile's session store (read-only; profiles the user skips in Settings are never opened) and sends chat excerpts and metadata, project/file names and paths, git branch/changed-file names and recent commit subjects to the current profile's Hermes LLM route (the auxiliary title-generation model, main provider unless configured); runs read-only git and, on macOS, Spotlight `mdfind`; never reads document contents; writes only $HERMES_HOME/pickup/. The \"Next step\" button can submit a model-written prompt straight into the card's chat in one click. The optional install.sh, when asked, adds the plugin to every profile's plugins.enabled (undone exactly by uninstall.sh)."
|
||||
maintainer: murenovich
|
||||
tier: community
|
||||
category: desktop
|
||||
requires_hermes: ">=0.20.0"
|
||||
docs_url: https://github.com/murenovich/hermes-pickup#readme
|
||||
version: "0.1.1"
|
||||
version: "0.2.0"
|
||||
screenshots:
|
||||
- https://raw.githubusercontent.com/murenovich/hermes-pickup/cb3d49383b0121fc7c66f6d79387e2ea0a05a7dd/docs/screenshot.png
|
||||
- https://raw.githubusercontent.com/murenovich/hermes-pickup/dc9b2b9b24b194f6d9b2f17f8bd4c7fa73105a5d/docs/screenshot.png
|
||||
known_issues:
|
||||
- "On installs with several profiles, Hermes issue #134712 can make the Desktop backend answer Plugin not found; the page explains the fix (enable hermes-pickup in every profile, then restart Desktop)."
|
||||
platforms: []
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
name: hermes-pokemon
|
||||
repo: https://github.com/sxuff/hermes-pokemon
|
||||
sha: 5c461d9e87ed06a41c2f1694108426767e785c2d
|
||||
version: "0.6.2"
|
||||
sha: a3d9f57f345a744825b59432c343d07d4f508a24
|
||||
version: "0.7.0"
|
||||
title: Hermes Pokémon
|
||||
description: "A cozy pixel-art companion garden for Hermes Desktop with three starters, nicknames, fetch, berries, XP, and optional evolution through all nine starter forms. The companion keeps you company on long turns, reacts quietly to failures and to what Hermes is doing, and lives through seasons, offline rainy days, wild visitors, keepsakes, and milestones. Independent fan project bundling CHUNSOFT Pokémon sprites and SpriteCollab community contributions; artwork is excluded from the source-code MIT license. Uses local plugin storage and read-only focused-session activity, with no runtime network requests, credentials, backend, or model calls. Disclosure — independent fan project bundling CHUNSOFT Pokémon sprites and SpriteCollab contributions that are excluded from the MIT code license; reads focused-session busy state and tool-start events read-only and keeps companion state in plugin storage; no network or model calls."
|
||||
description: "A cozy pixel-art companion garden for Hermes Desktop with three starters, nicknames, fetch, berries, XP, and optional evolution through all nine starter forms. The companion keeps you company on long turns, reacts quietly to failures, to what Hermes is doing and to turns finishing in other chats, and lives through seasons with real day length and moon phases, offline showers, wild visitors it greets in character, old-friend cameos, keepsakes, milestones that leave a bench, a lantern and bunting in the garden, and a sapling that grows with the days. Reachable from the keyboard, the status bar and the command palette. Independent fan project bundling CHUNSOFT Pokémon sprites and SpriteCollab community contributions; artwork is excluded from the source-code MIT license. Uses local plugin storage and read-only session activity, with no runtime network requests, credentials, backend, or model calls. Disclosure — independent fan project bundling CHUNSOFT Pokémon sprites and SpriteCollab contributions that are excluded from the MIT code license; reads session busy state and message-complete/tool-start/tool-complete events read-only (other chats contribute only \"a turn finished\"), focuses the composer (caret only, no text) or reveals its own pane only on a user click or command, saves a PNG snapshot to your downloads only when you click Save a snapshot, and keeps companion state in plugin storage; no network or model calls."
|
||||
maintainer: sxuff
|
||||
tier: community
|
||||
category: desktop
|
||||
requires_hermes: ">=0.21.5"
|
||||
docs_url: https://github.com/sxuff/hermes-pokemon#readme
|
||||
screenshots:
|
||||
- https://raw.githubusercontent.com/sxuff/hermes-pokemon/5c461d9e87ed06a41c2f1694108426767e785c2d/docs/images/habitat.png
|
||||
- https://raw.githubusercontent.com/sxuff/hermes-pokemon/5c461d9e87ed06a41c2f1694108426767e785c2d/docs/images/starters.png
|
||||
- https://raw.githubusercontent.com/sxuff/hermes-pokemon/5c461d9e87ed06a41c2f1694108426767e785c2d/docs/images/narrow.png
|
||||
- https://raw.githubusercontent.com/sxuff/hermes-pokemon/5c461d9e87ed06a41c2f1694108426767e785c2d/docs/images/evolution.png
|
||||
- https://raw.githubusercontent.com/sxuff/hermes-pokemon/5c461d9e87ed06a41c2f1694108426767e785c2d/docs/images/rain.png
|
||||
- https://raw.githubusercontent.com/sxuff/hermes-pokemon/5c461d9e87ed06a41c2f1694108426767e785c2d/docs/images/visitor.png
|
||||
- https://raw.githubusercontent.com/sxuff/hermes-pokemon/a3d9f57f345a744825b59432c343d07d4f508a24/docs/images/habitat.png
|
||||
- https://raw.githubusercontent.com/sxuff/hermes-pokemon/a3d9f57f345a744825b59432c343d07d4f508a24/docs/images/starters.png
|
||||
- https://raw.githubusercontent.com/sxuff/hermes-pokemon/a3d9f57f345a744825b59432c343d07d4f508a24/docs/images/rewards-night.png
|
||||
- https://raw.githubusercontent.com/sxuff/hermes-pokemon/a3d9f57f345a744825b59432c343d07d4f508a24/docs/images/cameo.png
|
||||
- https://raw.githubusercontent.com/sxuff/hermes-pokemon/a3d9f57f345a744825b59432c343d07d4f508a24/docs/images/evolution.png
|
||||
- https://raw.githubusercontent.com/sxuff/hermes-pokemon/a3d9f57f345a744825b59432c343d07d4f508a24/docs/images/visitor.png
|
||||
readme: true
|
||||
platforms: []
|
||||
capabilities:
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
name: hermes-telemetry
|
||||
repo: https://github.com/nujovich/hermes-telemetry
|
||||
sha: 146f6744fc4117ade784eefd7aa231c9b3f36281
|
||||
description: Observability + budget guardrails for Hermes Agent — tokens, cost, latency, tool calls per
|
||||
session and cron job. Persists to local SQLite. Exposes /stats and /budget slash commands.
|
||||
sha: 11166c45cd3f636578ecf6c8caeb8780dae4c304
|
||||
description: "Observability + budget guardrails for Hermes Agent — tokens, cost, latency, tool calls per session and cron job. Persists to local SQLite. Exposes /stats and /budget slash commands. Disclosure — records per-session tokens, cost, latency, tool names and sender/profile ids (no message text) in ~/.hermes/telemetry/telemetry.db; fetches public model pricing from openrouter.ai/api/v1/models on first load and at most once a day; in the default enforce mode first load writes a $5/day, $100/month global budget to telemetry/budget.yaml, and a hard breach blocks every tool call and pauses the over-budget cron job until the window resets (set plugins.entries.hermes-telemetry.settings.mode: observe to record without enforcing); the optional standalone dashboard server binds 127.0.0.1 with no auth."
|
||||
maintainer: nujovich
|
||||
tier: community
|
||||
category: general
|
||||
@@ -24,3 +23,4 @@ capabilities:
|
||||
- subagent_stop
|
||||
provides_middleware: []
|
||||
requires_env: []
|
||||
version: "0.9.0"
|
||||
@@ -0,0 +1,71 @@
|
||||
name: hermes-video-editor
|
||||
title: Video Editor (local FFmpeg)
|
||||
repo: https://github.com/oliverhees/hermes-video-editor
|
||||
sha: 66035165c71e09b7f77a08aecc36e35c0b2dce14
|
||||
version: "0.3.0"
|
||||
description: "Local video editor for your own footage: 45 FFmpeg tools for the agent (cut, silence removal, 9:16 reframe, captions, music with ducking, loudness, platform export and checks) plus a visual editor in Hermes Desktop with canvas, any number of tracks for text, shapes, video overlays and music, scenes and an English/German help. 100% local, no API key. Licence: PolyForm Noncommercial 1.0.0. Disclosure — the Desktop page starts a local web server on 127.0.0.1 (random port, per-run token) that can read and write media and image files anywhere under your home folder (and any VE_EDITOR_ROOTS); preview caches go to the system temp folder. The agent tools run ffmpeg/ffprobe on the input paths the model passes and write media, image or .srt files at the output paths it passes (replacing an existing file only with overwrite: true)."
|
||||
maintainer: oliverhees
|
||||
category: tools
|
||||
tier: community
|
||||
requires_hermes: ">=0.21.5"
|
||||
docs_url: https://github.com/oliverhees/hermes-video-editor/blob/66035165c71e09b7f77a08aecc36e35c0b2dce14/README.md
|
||||
image: https://raw.githubusercontent.com/oliverhees/hermes-video-editor/66035165c71e09b7f77a08aecc36e35c0b2dce14/docs/banner.png
|
||||
screenshots:
|
||||
- https://raw.githubusercontent.com/oliverhees/hermes-video-editor/66035165c71e09b7f77a08aecc36e35c0b2dce14/docs/editor.png
|
||||
readme: true
|
||||
capabilities:
|
||||
provides_tools:
|
||||
- lk_media_doctor
|
||||
- lk_media_probe
|
||||
- lk_detect_silence
|
||||
- lk_detect_scenes
|
||||
- lk_extract_frame
|
||||
- lk_loudness_report
|
||||
- lk_trim
|
||||
- lk_split
|
||||
- lk_join
|
||||
- lk_remove_silence
|
||||
- lk_remove_segments
|
||||
- lk_change_speed
|
||||
- lk_reverse
|
||||
- lk_loop
|
||||
- lk_crossfade_join
|
||||
- lk_crop
|
||||
- lk_crop_to_aspect
|
||||
- lk_resize
|
||||
- lk_rotate_flip
|
||||
- lk_pad_blur_background
|
||||
- lk_color_adjust
|
||||
- lk_denoise_video
|
||||
- lk_fade_video
|
||||
- lk_stabilize
|
||||
- lk_add_text
|
||||
- lk_burn_captions
|
||||
- lk_add_image_overlay
|
||||
- lk_picture_in_picture
|
||||
- lk_stack_videos
|
||||
- lk_blur_region
|
||||
- lk_extract_audio
|
||||
- lk_replace_audio
|
||||
- lk_mix_music
|
||||
- lk_normalize_loudness
|
||||
- lk_sync_audio_offset
|
||||
- lk_adjust_volume
|
||||
- lk_fade_audio
|
||||
- lk_denoise_audio
|
||||
- lk_mute_video
|
||||
- lk_export_preset
|
||||
- lk_platform_check
|
||||
- lk_compress_to_size
|
||||
- lk_to_gif
|
||||
- lk_contact_sheet
|
||||
- lk_transcribe_captions
|
||||
provides_hooks: []
|
||||
provides_middleware: []
|
||||
requires_env: []
|
||||
known_issues:
|
||||
- Requires FFmpeg and ffprobe on PATH (apt install ffmpeg / brew install ffmpeg / winget install Gyan.FFmpeg).
|
||||
- lk_transcribe_captions is optional and needs 'pip install faster-whisper' plus a model already on disk.
|
||||
- The visual editor has one main video track plus any number of tracks for text, shapes, video overlays, audio and scenes; these items sit at fixed times and do not move when the main clips are cut (bundle them in a scene to move them together).
|
||||
- Licence is PolyForm Noncommercial 1.0.0 (source-available): free for non-commercial use, commercial use needs a separate licence from Lokyy.de.
|
||||
- The editor preview needs H.264 or VP8 playback in the app's browser engine; editing and export work without it.
|
||||
@@ -0,0 +1,16 @@
|
||||
name: huddo
|
||||
repo: https://github.com/puffo-ai/huddo-mcp
|
||||
sha: a86d3aae9da2dd921cee27c8f220fbe2c4f491c6
|
||||
subdir: integrations/hermes
|
||||
description: "Huddo — group chat where people and AI agents talk in the same room. Join by invite link, read and reply, whisper to one member, pair with your human, wait for new messages. Portable Agent Plugins v1 package (mcp.json + skills/). Disclosure — runs the Huddo MCP server locally over stdio (npx -y huddoai@0.7.1, pinned, npm provenance to this commit; Node 20+); sends and receives room messages via api.huddo.ai, readable by every room member except one-to-one encrypted whispers; huddo_send can attach any local file the agent names and upload it to the room, and Hermes does not ask before huddo_send/huddo_whisper/huddo_download run (the package does not set trust: untrusted); the server tells the agent to keep calling huddo_wait (up to 50 s per call) for as long as it is in a room, which keeps spending model tokens until you stop it; downloads go to ~/.huddo/downloads without overwriting; stores a guest identity (signing keys) in ~/.huddo; no background process (HUDDO_DAEMON=0), no self-update, no telemetry. Room messages from others are untrusted chat content; Huddo marks the paired human with a header tag that message text cannot imitate, and the bundled skill tells the agent to get confirmation in your own Hermes chat before anything sensitive."
|
||||
maintainer: puffo-ai
|
||||
tier: community
|
||||
category: tools
|
||||
version: "0.7.1"
|
||||
docs_url: https://github.com/puffo-ai/huddo-mcp/tree/main/integrations/hermes#readme
|
||||
platforms: []
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
provides_hooks: []
|
||||
provides_middleware: []
|
||||
requires_env: []
|
||||
@@ -1,19 +1,19 @@
|
||||
name: hyatlas
|
||||
repo: https://github.com/tuancookiez-hub/HyAtlas-Memory
|
||||
sha: f92bfe0f461c1dda0c80e3d0a0edb9266902ac9f
|
||||
sha: f61727a84971dcf3b5bdd2a9a2a12e74b47695ba
|
||||
subdir: plugins/hyatlas
|
||||
version: "4.3.2"
|
||||
image: https://raw.githubusercontent.com/tuancookiez-hub/HyAtlas-Memory/f92bfe0f461c1dda0c80e3d0a0edb9266902ac9f/docs/images/banner.png
|
||||
version: "4.5.0"
|
||||
image: https://raw.githubusercontent.com/tuancookiez-hub/HyAtlas-Memory/f61727a84971dcf3b5bdd2a9a2a12e74b47695ba/docs/images/banner.png
|
||||
screenshots:
|
||||
- https://raw.githubusercontent.com/tuancookiez-hub/HyAtlas-Memory/f92bfe0f461c1dda0c80e3d0a0edb9266902ac9f/docs/images/desktop-overview.png
|
||||
- https://raw.githubusercontent.com/tuancookiez-hub/HyAtlas-Memory/f92bfe0f461c1dda0c80e3d0a0edb9266902ac9f/docs/images/desktop-graph.png
|
||||
- https://raw.githubusercontent.com/tuancookiez-hub/HyAtlas-Memory/f61727a84971dcf3b5bdd2a9a2a12e74b47695ba/docs/images/desktop-overview.png
|
||||
- https://raw.githubusercontent.com/tuancookiez-hub/HyAtlas-Memory/f61727a84971dcf3b5bdd2a9a2a12e74b47695ba/docs/images/desktop-graph.png
|
||||
title: HyAtlas Memory
|
||||
description: "HyAtlas v4 — self-hosted 7-layer memory provider backed by a pure-Go single-binary server you run yourself (chromem-go vectors, in-process BGE-small embeddings, bitemporal knowledge graph with source citations). Embeddings, the vector index and stored memories stay local. Ships agent tools (hyatlas_status/search/recent/add), a `hermes hyatlas` CLI, a Desktop pane with a starmap graph view, and a web dashboard. Disclosure — requires the separate hyatlas-go server, installed by the user. The plugin never downloads or bundles it, and it talks only to the configured server (default 127.0.0.1:19528). Embeddings and stored memories stay local. Extraction is opt-in: the server has no default LLM endpoint and makes no LLM call until HYATLAS_LLM_BASE, HYATLAS_LLM_MODEL and HYATLAS_LLM_KEY are all set. The repo installer pre-fills a free Nous Portal endpoint that Enter accepts, but nothing is sent until you also enter a key. Once configured, each turn, including that turn's tool output, goes to that endpoint. In the default ultra mode the server also sends batches of up to 200 stored facts to it every 6 hours and deletes the facts the model marks merged or stale. HYATLAS_MODE=lite makes no LLM call. The spawned server gets an allowlisted environment (OS essentials plus HYATLAS_*), not the agent's keys. auto_start is off by default. On Windows, if a hyatlas-go.ps1 launcher is configured or sits beside the binary, `hermes hyatlas start/stop` runs it; the repo's sample launcher reads your Nous token from Hermes auth.json and points extraction at the Nous Portal. No telemetry."
|
||||
description: "HyAtlas v4 — self-hosted 7-layer memory provider backed by a pure-Go single-binary server you run yourself (chromem-go vectors, in-process BGE-small embeddings, hybrid vector + keyword search, bitemporal knowledge graph with source citations). Embeddings, the vector index and stored memories stay local. Ships agent tools (hyatlas_status/search/recent/add), a `hermes hyatlas` CLI, a Desktop pane with a starmap graph view, and a web dashboard. Disclosure — requires the separate hyatlas-go server, installed by the user. The plugin never downloads or bundles it, and it talks only to the configured server (default 127.0.0.1:19528). Embeddings and stored memories stay local. Extraction is opt-in: the server has no default LLM endpoint and makes no LLM call until HYATLAS_LLM_BASE, HYATLAS_LLM_MODEL and HYATLAS_LLM_KEY are all set. The repo installer pre-fills a free Nous Portal endpoint that Enter accepts, but nothing is sent until you also enter a key. Once configured, each turn's user and assistant text goes to that endpoint (not tool output or context summaries; capped at 12,000 characters). In the default ultra mode the server also sends batches of up to 50 stored facts to it every 6 hours and supersedes (hides but keeps) the facts the model marks merged or stale, recording a reason for each drop. It also records relations corroborated across turns in the local knowledge graph (HYATLAS_CONSOLIDATE_GRAPH=off disables that). HYATLAS_MODE=lite makes no LLM call. Server endpoints that rewrite stored data are off unless the server runs with HYATLAS_ADMIN=on. The spawned server gets an allowlisted environment (OS essentials plus HYATLAS_*), not the agent's keys. auto_start is off by default; a server started by auto_start or `hermes hyatlas start` keeps running after Hermes exits until `hermes hyatlas stop`. On Windows, if a hyatlas-go.ps1 launcher is configured, `hermes hyatlas start/stop` runs it; the repo's sample launcher reads your Nous token from Hermes auth.json and points extraction at the Nous Portal. No telemetry."
|
||||
maintainer: tuancookiez-hub
|
||||
tier: community
|
||||
category: memory
|
||||
requires_hermes: ">=0.21.4"
|
||||
docs_url: https://github.com/tuancookiez-hub/HyAtlas-Memory/tree/f92bfe0f461c1dda0c80e3d0a0edb9266902ac9f/plugins/hyatlas
|
||||
docs_url: https://github.com/tuancookiez-hub/HyAtlas-Memory/tree/f61727a84971dcf3b5bdd2a9a2a12e74b47695ba/plugins/hyatlas
|
||||
platforms:
|
||||
- linux
|
||||
- macos
|
||||
@@ -27,3 +27,4 @@ known_issues:
|
||||
- "After enabling, select the provider with `hermes memory setup` (memory.provider: hyatlas) — installing and enabling alone does not activate memory."
|
||||
- "The agent tools and hooks arrive through the MemoryProvider interface, not ctx.register_tool, so they do not appear in the plugin tool list."
|
||||
- "Requires the separate hyatlas-go server binary (installed by the user); without a reachable server the provider reports unavailable and memory features degrade rather than fail."
|
||||
- "Updating the plugin does not update the server: install hyatlas-go v4.5.0 too. A store written by an earlier version can be cleaned with the server's maintenance endpoints (see after-install.md, 'Upgrading a store written before 4.5.0')."
|
||||
@@ -1,17 +1,19 @@
|
||||
name: invinoveritas-receipts
|
||||
repo: https://github.com/babyblueviper1/hermes-invinoveritas
|
||||
sha: 4def742e3c991539769c5f7328399a6542b7f147
|
||||
description: "Signed, independently checkable receipts for Hermes approval verdicts. Observe-only post_approval_response hook: for each Guardian LLM verdict (or every approve/deny with scope=all) it requests an invinoveritas decision receipt, a signed set of salted commitments that anyone can check for free at /verify-proof, and that the holder can later open with the salt kept locally. Never approves, denies or delays an approval. Disclosure — one HTTPS POST per recorded approval decision to api.babyblueviper.com/decision-receipt (or the configured api_url) under INVINOVERITAS_API_KEY, paid automatically at 100 sats per receipt from your invinoveritas balance (default: Guardian LLM verdicts only; scope: all adds every human approve/deny); sends a fixed question, the options, the choice, a nonce-salted SHA-256 of the decision context and the tool_call_id/turn_id, never the command text; stores receipts, salts and the full decision context (including the command as Hermes passed it) in plugin-data/invinoveritas-receipts/receipts.jsonl."
|
||||
sha: 6a4f9c7b49beac69899f18f3bcf1d0b340297624
|
||||
description: "Signed, independently checkable receipts for Hermes approval verdicts and, since 0.2.0, for turns that stop on iteration exhaustion. Observe-only: a post_approval_response hook requests an invinoveritas decision receipt for each Guardian LLM verdict (or every approve/deny with scope=all); post_llm_call + on_session_end record, when a turn ends with max_iterations_reached, whether the turn's todo ledger (the last todo result or the list re-injected after compaction) was complete (turn_scope: exhausted by default, all, or off). Each receipt is a signed set of salted commitments that anyone can check for free at /verify-proof and that the holder can later open with the salt kept locally. Never approves, denies, delays, continues or blocks anything. Disclosure — one HTTPS POST per recorded approval decision or recorded turn to api.babyblueviper.com/decision-receipt (or the configured api_url) under INVINOVERITAS_API_KEY, paid automatically at 100 sats per receipt from your invinoveritas balance with no per-call confirmation (defaults: Guardian LLM verdicts, and only turns that stop on max_iterations_reached; scope: all adds every human approve/deny, turn_scope: all pays on every turn); sends a fixed question, the options, the choice, a nonce-salted SHA-256 of the context and the tool_call_id/turn_id, never the command text, todo contents or reply text; stores receipts, salts and the full contexts (including the command as Hermes passed it, and the todo ledger) in plugin-data/invinoveritas-receipts/receipts.jsonl."
|
||||
maintainer: babyblueviper1
|
||||
tier: community
|
||||
category: general
|
||||
docs_url: https://github.com/babyblueviper1/hermes-invinoveritas#readme
|
||||
version: "0.1.0"
|
||||
version: "0.2.1"
|
||||
platforms: []
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
provides_hooks:
|
||||
- post_approval_response
|
||||
- post_llm_call
|
||||
- on_session_end
|
||||
provides_middleware: []
|
||||
requires_env:
|
||||
- INVINOVERITAS_API_KEY
|
||||
@@ -1,14 +1,14 @@
|
||||
name: memtomem
|
||||
repo: https://github.com/memtomem/memtomem
|
||||
sha: 5b7034126574a2956b6a328622006a46b2a0e367
|
||||
sha: 592865456cf7670311648fbc619bbb876d002a1e
|
||||
subdir: packages/memtomem-hermes-plugin
|
||||
version: "0.1.2"
|
||||
description: "Markdown-first long-term memory shared across your coding agents: a local memtomem MCP server plus six memory skills (index, recall, remember, search, setup, status). Notes live in ~/.memtomem and are searchable from Hermes, Claude Code, Codex CLI and Kimi Code. Runs at user scope. Disclosure — the server is launched with `uvx --python 3.12` and downloaded from PyPI (memtomem[onnx]==0.6.7) on first start, with Python 3.12 if uv cannot find one; it runs as a stdio MCP server for the session and reads the directories you ask it to index. With a fresh BM25-only configuration nothing else leaves the machine; an existing memtomem configuration may enable Hugging Face model downloads, a remote embedding or LLM provider, Cohere reranking or search webhooks."
|
||||
version: "0.1.4"
|
||||
description: "Markdown-first long-term memory shared across your coding agents: a local memtomem MCP server plus six memory skills (index, recall, remember, search, setup, status). Notes live in ~/.memtomem and are searchable from Hermes, Claude Code, Codex CLI and Kimi Code. Runs at user scope. Disclosure — the server is launched with `uvx --python 3.12` and downloaded from PyPI (memtomem[onnx]==0.6.8) on first start, with Python 3.12 if uv cannot find one; it runs as a stdio MCP server for the session and reads the directories you ask it to index. With a fresh BM25-only configuration nothing else leaves the machine; an existing memtomem configuration may enable Hugging Face model downloads, a remote embedding or LLM provider, Cohere reranking or search webhooks. At startup the server also reads the nearest .env found upward from its installed location inside uv's cache (normally ~/.env), setting variables not already present, outside Hermes's MCP environment filtering."
|
||||
maintainer: memtomem
|
||||
tier: community
|
||||
category: general
|
||||
requires_hermes: ">=0.21.5"
|
||||
docs_url: https://github.com/memtomem/memtomem/tree/5b7034126574a2956b6a328622006a46b2a0e367/packages/memtomem-hermes-plugin
|
||||
docs_url: https://github.com/memtomem/memtomem/tree/592865456cf7670311648fbc619bbb876d002a1e/packages/memtomem-hermes-plugin
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
provides_hooks: []
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
name: openviking
|
||||
title: OpenViking
|
||||
repo: https://github.com/volcengine/OpenViking
|
||||
sha: 3b65218eef43aba7a680cf143278ad4c153b0e9f
|
||||
sha: 9f150ece247ad683494ab172d738023bd6dc6170
|
||||
subdir: examples/hermes-plugin
|
||||
version: "2.0.1"
|
||||
description: OpenViking context-database memory provider — session-managed memory with automatic
|
||||
extraction, tiered retrieval and filesystem-style knowledge browsing. Maintained by Volcengine; the
|
||||
same provider that shipped in-tree. Disclosure — conversation turns, tool results and MEMORY.md /
|
||||
USER.md edits are sent to the configured OpenViking server (local 127.0.0.1 by default; setup offers
|
||||
a Volcengine cloud endpoint); when the local server is down it starts `openviking-server` from PATH
|
||||
in the background, and at this pin that child inherits the full Hermes environment.
|
||||
version: "2.1.1"
|
||||
image: https://raw.githubusercontent.com/volcengine/OpenViking/9f150ece247ad683494ab172d738023bd6dc6170/docs/images/openviking-social.jpg
|
||||
description: "Long-term memory for Hermes. Capture conversations, recall useful facts across sessions and search your knowledge. Choose Quick Local, OpenViking Service (managed cloud) or your own server. Disclosure — conversation turns, tool results and MEMORY.md / USER.md edits are sent to the configured OpenViking server. Quick Local setup installs the newest OpenViking 0.4.x server release from PyPI at setup time (checked against PyPI's hash, not the version reviewed here; re-running setup upgrades it) plus a hash-pinned llama-cpp-python wheel and a local embedding model. It copies the active Hermes static LLM API key into a private file under the profile for extraction, and its localhost server keeps running after Hermes exits and after plugin removal (stop it with `hermes openviking local stop`). Extraction can use a remote LLM. Custom localhost connections can start openviking-server from PATH with the profile environment minus Hermes bot, gateway, GitHub and dashboard secrets; model and tool API keys still pass."
|
||||
maintainer: volcengine
|
||||
tier: community
|
||||
category: memory
|
||||
docs_url: https://github.com/volcengine/OpenViking/tree/main/examples/hermes-plugin#readme
|
||||
docs_url: https://github.com/volcengine/OpenViking/tree/9f150ece247ad683494ab172d738023bd6dc6170/examples/hermes-plugin#readme
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
provides_hooks:
|
||||
- on_session_end
|
||||
provides_hooks: []
|
||||
provides_middleware: []
|
||||
requires_env: []
|
||||
@@ -0,0 +1,21 @@
|
||||
name: print-job-watch
|
||||
repo: https://github.com/TakeshiTGAL/hermes-plugin-print-job-watch
|
||||
sha: d5c8ec85ecfd48e970a274368ff32ec75c7575df
|
||||
description: "Reads one OctoPrint printer. A cron watch reports printer_disconnected (not a pause); error; complete after Printing, Printing from SD, Pausing, Paused, Resuming, or Finishing; paused after Printing, Printing from SD, Pausing, or Resuming; cancelled; and stalled. This plugin can be used with OctoPrint. OctoPrint is a registered trademark owned by Gina Häußge (https://octoprint.org). Disclosure — Talks to the one OctoPrint origin you set in OCTOPRINT_URL with your OCTOPRINT_API_KEY, plus an optional webcam still from the same host, fetched without the key when it is on another port. Over plain http, that key is sent unencrypted. Pause, resume, and cancel ask for Hermes approval every time and are refused under cron, yolo, approvals off, single-query, unattended sessions and plugins.isolation: host, and when those checks cannot run. The plugin has no user list of its own: anyone you allow on your Hermes gateway can ask for a move and approve it. Running hermes tools does not remove the slash command. Answering \"always\" adds one unused line to config.yaml, and that line does not approve the next move. The optional watch is a cron job you create from the CLI (a model turn every 5 minutes by default) that stays after uninstall until you run unschedule. At that default it is 288 runs a day with no daily cap. The watch needs plugins.isolation: in_process; under host it only posts a notice that it cannot run, on every run. Not tested on a physical printer, with access control off, or with the stock webcam."
|
||||
maintainer: TakeshiTGAL
|
||||
tier: community
|
||||
category: tools
|
||||
requires_hermes: ">=0.21.4"
|
||||
docs_url: https://github.com/TakeshiTGAL/hermes-plugin-print-job-watch#readme
|
||||
version: "0.1.0"
|
||||
platforms: []
|
||||
capabilities:
|
||||
provides_tools:
|
||||
- print_job_status
|
||||
- print_job_control
|
||||
- print_job_watch
|
||||
provides_hooks: []
|
||||
provides_middleware: []
|
||||
requires_env:
|
||||
- OCTOPRINT_URL
|
||||
- OCTOPRINT_API_KEY
|
||||
@@ -1,9 +1,9 @@
|
||||
name: qdrant
|
||||
repo: https://github.com/kuehnberger/hermes-qdrant-memory
|
||||
sha: b2475f7ca020bd6f847a3e74a1e611b1b99a16a1
|
||||
version: "0.1.7"
|
||||
image: https://raw.githubusercontent.com/kuehnberger/hermes-qdrant-memory/b2475f7ca020bd6f847a3e74a1e611b1b99a16a1/docs/banner.jpg
|
||||
description: "Dense semantic memory for Hermes, stored in a Qdrant collection you host. Each turn becomes one point — user and assistant text verbatim, no LLM summarization step — embedded on-device at 384 dimensions with fastembed (ONNX; the default install is 206 MB of runtime dependencies with no torch, versus ~1.2 GB when sentence-transformers was a hard dependency) and recalled by cosine search with an optional session_id payload filter, so a result quotes what was actually said. Registers 7 tools: qdrant_search, qdrant_upsert, qdrant_recall, qdrant_collect, qdrant_prepare, qdrant_forget (point-targeted deletion, dry-run by default, no delete-all), and md_search (local markdown knowledge index). An optional progress display (off/minimal/verbose) reports stored and recalled counts in the CLI. sentence-transformers is selectable on GPU hosts; both backends produce identical vectors for all-MiniLM-L6-v2, so switching backends needs no re-embedding. A circuit breaker (5 consecutive I/O failures, 120 s cooldown) degrades to no new memories instead of crashing, and is_available()/check_backend()/unavailable_reason() distinguish a misconfiguration from a dead server. Hybrid dense+sparse RRF search and INT8 quantization exist in _backend.py but are not on the live path. Local embedded Qdrant mode is not supported: the provider is REST-only and needs a reachable server. Disclosure — all memory content (verbatim user and assistant turns) is stored in and read back from the Qdrant server at the configured url (default http://localhost:6333); QDRANT_API_KEY is only needed for Qdrant Cloud, is kept in .env and read through the Hermes secret scope, and is never written to config.yaml or qdrant.json. Disclosure — the only other outbound traffic is a one-time download of the embedding model weights from huggingface.co (fastembed falls back to storage.googleapis.com/qdrant-fastembed if that fails); embedding runs on-device and no memory content goes to any other party. Disclosure — settings are stored in <HERMES_HOME>/qdrant.json (created 0600, no credentials) and last-operation status in <HERMES_HOME>/qdrant-status.json. Disclosure — md_search returns snippets of local markdown to the model from ~/.hermes/skills, the Obsidian vault at ~/Documents/hermes_default, ~/.hermes/hermes-agent/website and any path in the md_docs_roots key of qdrant.json; its index is built only when you run scripts/md_ingest.py and lives in <base Hermes home>/state/md-search/ (shared by all profiles). Disclosure — the optional semantic tier (md_ingest.py --semantic, and md_search queries with few lexical hits) sends embedding vectors plus file-path labels and section headings, never chunk text, to a separate hermes_md_docs collection on the Qdrant server named by the QDRANT_URL env var (default http://localhost:6333, no API key), and downloads paraphrase-multilingual-MiniLM-L12-v2 from huggingface.co on first use. Disclosure — the plugin is not a server and registers no MCP server, but it is loaded in-process into every Hermes process that embeds, so each one pays its own model copy (measured ~194-211 MB per process)."
|
||||
sha: f65e62d1ab100bfad04fc47c78200f327f8b2ba1
|
||||
version: "0.1.8"
|
||||
image: https://raw.githubusercontent.com/kuehnberger/hermes-qdrant-memory/f65e62d1ab100bfad04fc47c78200f327f8b2ba1/docs/banner.jpg
|
||||
description: "Dense semantic memory for Hermes, stored in a Qdrant collection you host. Each turn becomes one point — user and assistant text verbatim, no LLM summarization step — embedded on-device at 384 dimensions with fastembed (ONNX; the default install is 206 MB of runtime dependencies with no torch, versus ~1.2 GB when sentence-transformers was a hard dependency) and recalled by cosine search with an optional session_id payload filter, so a result quotes what was actually said. Registers 7 tools: qdrant_search, qdrant_upsert, qdrant_recall, qdrant_collect, qdrant_prepare, qdrant_forget (point-targeted deletion, dry-run by default, no delete-all), and md_search (local markdown knowledge index). An optional recall-indicator display, display.level (off | summary | verbose, default off), adds latency and collection to the chat indicator at summary and one numeric line per operation to the log at verbose — numbers and identifiers only, never message text; it is separate from the older top-level progress knob (off | minimal | verbose), which gates status events. sentence-transformers is selectable on GPU hosts; both backends produce identical vectors for all-MiniLM-L6-v2, so switching backends needs no re-embedding. A circuit breaker (5 consecutive I/O failures, 120 s cooldown) degrades to no new memories instead of crashing, and is_available()/check_backend()/unavailable_reason() distinguish a misconfiguration from a dead server. Hybrid dense+sparse RRF search and INT8 quantization exist in _backend.py but are not on the live path. Local embedded Qdrant mode is not supported: the provider is REST-only and needs a reachable server. Disclosure — all memory content (verbatim user and assistant turns) is stored in and read back from the Qdrant server at the configured url (default http://localhost:6333); QDRANT_API_KEY is only needed for Qdrant Cloud, is kept in .env and read through the Hermes secret scope, and is never written to config.yaml or qdrant.json. Disclosure — the only other outbound traffic is a one-time download of the embedding model weights from huggingface.co (fastembed falls back to storage.googleapis.com/qdrant-fastembed if that fails); embedding runs on-device and no memory content goes to any other party. Disclosure — settings are stored in <HERMES_HOME>/qdrant.json (created 0600, no credentials) and last-operation status in <HERMES_HOME>/qdrant-status.json. Disclosure — md_search returns snippets of local markdown to the model from ~/.hermes/skills, the Obsidian vault at ~/Documents/hermes_default, ~/.hermes/hermes-agent/website and any path in the md_docs_roots key of qdrant.json; its index is built only when you run scripts/md_ingest.py and lives in <base Hermes home>/state/md-search/ (shared by all profiles). Disclosure — the optional semantic tier (md_ingest.py --semantic, and md_search queries with few lexical hits) sends embedding vectors plus file-path labels and section headings, never chunk text, to a separate hermes_md_docs collection on the Qdrant server named by the QDRANT_URL env var (default http://localhost:6333, no API key), and downloads paraphrase-multilingual-MiniLM-L12-v2 from huggingface.co on first use. Disclosure — the plugin is not a server and registers no MCP server, but it is loaded in-process into every Hermes process that embeds, so each one pays its own model copy (measured ~194-211 MB per process)."
|
||||
maintainer: kuehnberger
|
||||
tier: community
|
||||
category: memory
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
name: quota
|
||||
repo: https://github.com/rarf/hermes-quota-plugin
|
||||
sha: 46c047b40fafea8e1baa0f367ad939a1ccd1e731
|
||||
version: "2.8.1"
|
||||
description: "Per-provider quota / rate-limit status via /quota command and desktop widget. Reads precomputed quota_cache.json so the widget never does network I/O. Disclosure — refreshes your Gemini OAuth token using the Gemini CLI's public client identity, in memory only; queries Copilot's internal usage endpoint with VS Code client headers and Anthropic's OAuth usage endpoint with a Claude Code User-Agent; the cursor check reads the Cursor CLI token from the macOS Keychain or Cursor's auth.json (read-only, never refreshed or rewritten; on expiry it asks you to run cursor-agent login) and queries api2.cursor.sh; the opt-in (default off) antigravity check reads Antigravity's OAuth token from Windows Credential Manager, the macOS Keychain or ~/.gemini/antigravity-cli (read-only), refreshes it in memory with Antigravity's public client identity and sends Antigravity's User-Agent to Google's cloudcode endpoint; the opt-in (default off) grok.com check reads local browser cookies, using secret-tool on Linux or the macOS Keychain for the browser's Safe Storage key and decrypting only grok.com cookies from a temporary copy of the cookie DB; the opencode-go check reads the OpenCode CLI's local auth.json (read-only); the commandcode check reads ~/.commandcode/auth.json (read-only) or Hermes' credential store and uses the Command Code CLI's User-Agent; zai, kimi, minimax, deepseek and openrouter send keys from Hermes' credential store or env only to their own vendor's usage or balance endpoints."
|
||||
sha: ce8593a25ff61c32dd1e929fe2f87502cb6408cc
|
||||
version: "2.10.0"
|
||||
description: "Per-provider quota / rate-limit status via /quota command and desktop widget. Reads precomputed quota_cache.json so the widget never does network I/O. Disclosure — refreshes your Gemini OAuth token using the Gemini CLI's public client identity, in memory only; queries Copilot's internal usage endpoint with VS Code client headers and Anthropic's OAuth usage endpoint using the hermes-quota-plugin User-Agent; optional additional Claude accounts read access tokens only from explicitly configured Claude Code credentials files and send them only to Anthropic's fixed OAuth usage endpoint (read-only, no refresh-token exchange or credential write-back; tokens and paths excluded from the quota cache). Linux extra accounts verified; Windows experimental, extra macOS Keychain accounts unsupported. The openai-codex check reads every saved Codex account from Hermes' own auth.json credential pool (read-only, never refreshed; expired accounts show reauth-required) and queries the Codex usage endpoint with the codex-cli User-Agent; the opencode-go check reads the OpenCode CLI's local auth.json, Hermes' auth.json pool and OPENCODE_GO_API_KEY_N variables (read-only). The cursor check reads the Cursor CLI token from the macOS Keychain or Cursor's auth.json (read-only, never refreshed or rewritten; on expiry it asks you to run cursor-agent login) and queries api2.cursor.sh; the opt-in (default off) antigravity check reads Antigravity's OAuth token from Windows Credential Manager, the macOS Keychain or ~/.gemini/antigravity-cli (read-only), refreshes it in memory with Antigravity's public client identity and sends Antigravity's User-Agent to Google's cloudcode endpoint; the opt-in (default off) grok.com check reads local browser cookies, using secret-tool on Linux or the macOS Keychain for the browser's Safe Storage key and decrypting only grok.com cookies from a temporary copy of the cookie DB; the commandcode check reads ~/.commandcode/auth.json (read-only) or Hermes' credential store and uses the Command Code CLI's User-Agent; zai, kimi, minimax, deepseek, openrouter, ollama (which may read OLLAMA_API_KEY from ~/.hermes/.env and calls ollama.com/api/usage and /api/me) and experientiallabs send keys from Hermes' credential store or env only to their own vendor's usage or balance endpoints."
|
||||
maintainer: rarf
|
||||
tier: community
|
||||
category: desktop
|
||||
docs_url: https://github.com/rarf/hermes-quota-plugin#readme
|
||||
readme: true
|
||||
image: https://raw.githubusercontent.com/rarf/hermes-quota-plugin/46c047b40fafea8e1baa0f367ad939a1ccd1e731/docs/images/quota-banner.png
|
||||
image: https://raw.githubusercontent.com/rarf/hermes-quota-plugin/ce8593a25ff61c32dd1e929fe2f87502cb6408cc/docs/images/quota-banner.png
|
||||
screenshots:
|
||||
- https://raw.githubusercontent.com/rarf/hermes-quota-plugin/46c047b40fafea8e1baa0f367ad939a1ccd1e731/docs/images/quota-pane.png
|
||||
- https://raw.githubusercontent.com/rarf/hermes-quota-plugin/46c047b40fafea8e1baa0f367ad939a1ccd1e731/docs/images/quota-app.png
|
||||
- https://raw.githubusercontent.com/rarf/hermes-quota-plugin/ce8593a25ff61c32dd1e929fe2f87502cb6408cc/docs/images/quota-pane.png
|
||||
- https://raw.githubusercontent.com/rarf/hermes-quota-plugin/ce8593a25ff61c32dd1e929fe2f87502cb6408cc/docs/images/quota-app.png
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
provides_hooks:
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
name: redline
|
||||
repo: https://github.com/silentpr0/hermes-redline
|
||||
sha: aa0c6638e81e682bc027e5fe17f8561ad8ce1131
|
||||
subdir: ""
|
||||
description: "Guided Markdown review in Hermes Desktop — annotate passages in a docked pane and send comment/question/change-request batches back to the agent; cards are offered automatically on delivered Markdown. Disclosure — reads any user-selected local .md/.markdown path up to 1 MB (symlink-resolved), writes only the <document>.md.redline.json sidecar beside it, and in Hermes Desktop chats the transform_llm_output hook appends a ::redline directive line to qualifying final agent responses; no network beyond the local gateway, no credentials, no telemetry, no subprocesses; the pane polls the local backend every 3 s while open."
|
||||
maintainer: silentpr0
|
||||
tier: community
|
||||
category: desktop
|
||||
requires_hermes: ">=0.21.5"
|
||||
version: "0.1.0"
|
||||
image: "https://raw.githubusercontent.com/silentpr0/hermes-redline/5fc46459d7e52a41856a9775501d38a12f27fa06/docs/banner.png"
|
||||
screenshots:
|
||||
- "https://raw.githubusercontent.com/silentpr0/hermes-redline/5fc46459d7e52a41856a9775501d38a12f27fa06/docs/screenshot.png"
|
||||
readme: true
|
||||
capabilities:
|
||||
provides_tools: []
|
||||
provides_hooks:
|
||||
- post_tool_call
|
||||
- transform_llm_output
|
||||
- on_session_end
|
||||
provides_middleware: []
|
||||
requires_env: []
|
||||
@@ -1,12 +1,12 @@
|
||||
name: web-search-plus
|
||||
repo: https://github.com/robbyczgw-cla/hermes-web-search-plus
|
||||
sha: c8a67fd7ed7fb649355d918053069ce785e0b341
|
||||
sha: e718eb439199238a2c218b02881bb5c83eea5faf
|
||||
description: "Multi-provider web search, URL extraction, quality reports, and opt-in research mode. Disclosure — reads the operator's HERMES_HOME/config.yaml (read-only) for its own Desktop settings block (country, language, max_results, auto_routing, searxng_url); may spawn its own search.py and an operator-configured local donsetch CLI as subprocesses (argv, no shell)."
|
||||
maintainer: robbyczgw-cla
|
||||
tier: community
|
||||
category: web
|
||||
docs_url: https://github.com/robbyczgw-cla/hermes-web-search-plus#readme
|
||||
version: "4.3.3"
|
||||
version: "4.3.5"
|
||||
capabilities:
|
||||
provides_tools:
|
||||
- web_extract_plus
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
name: wyoming-voice-bridge
|
||||
repo: https://github.com/TakeshiTGAL/hermes-plugin-wyoming-voice-bridge
|
||||
sha: 7be4db8548a82a8745981d47e79d1623a173a54b
|
||||
description: "Wyoming speech-to-text and text-to-speech for a Wyoming server you run (stt/tts provider `wyoming`), plus a wyoming_voice_status tool that lists the server's models and voices. Disclosure — Audio and text to speak go to the WYOMING_* host:port you set over cleartext, unauthenticated TCP (public addresses accepted). Once selected, the providers send speech in every session, including cron and webhooks. Non-WAV audio is converted with your local ffmpeg. wyoming_voice_status asks for approval on each call and refuses under cron, yolo, approvals off, -q and unattended platforms; answering \"always\" leaves a command_allowlist line in config.yaml. It calls internal Hermes approval, config and write-guard helpers, so a Hermes update can disable the status tool or speech file writes (both fail closed)."
|
||||
maintainer: TakeshiTGAL
|
||||
tier: community
|
||||
category: voice
|
||||
docs_url: https://github.com/TakeshiTGAL/hermes-plugin-wyoming-voice-bridge#readme
|
||||
version: "0.1.0"
|
||||
requires_hermes: ">=0.21.4"
|
||||
platforms: []
|
||||
capabilities:
|
||||
provides_tools:
|
||||
- wyoming_voice_status
|
||||
provides_hooks: []
|
||||
provides_middleware: []
|
||||
requires_env: []
|
||||
@@ -63,13 +63,14 @@ def _get_project_plugins_dir() -> Optional[Path]:
|
||||
|
||||
|
||||
def _is_memory_provider_dir(path: Path) -> bool:
|
||||
"""Cheap text heuristic (no import): ``__init__.py`` mentions the memory provider contract."""
|
||||
"""Cheap text heuristic (no import): ``__init__.py`` mentions the memory provider contract.
|
||||
A miss in the first 8 KB falls back to parsing, so a long module docstring cannot hide a provider."""
|
||||
init_file = path / "__init__.py"
|
||||
try:
|
||||
if not init_file.exists():
|
||||
return False
|
||||
source = init_file.read_text(errors="replace", encoding="utf-8-sig")[:8192]
|
||||
return "register_memory_provider" in source or "MemoryProvider" in source
|
||||
return "register_memory_provider" in source or "MemoryProvider" in source or _defines_memory_provider(path)
|
||||
except OSError as exc: # one mode-000 / ACL-denied child must not abort discovery
|
||||
logger.warning("Skipping unreadable plugin directory %s: %s", path, exc)
|
||||
return False
|
||||
|
||||
@@ -127,6 +127,13 @@ The ONLY import surface is `@hermes/plugin-sdk` (plus `react` /
|
||||
`ctx.os.openExternal(url)`, `ctx.os.revealPath(path)`, and
|
||||
`ctx.os.writeClipboard(text)` resolve `false` (never throw) when the
|
||||
capability isn't available.
|
||||
- `ctx.pet.say(text, { id?, tone?: 'info'|'wait'|'error', ttlMs? })` — a short
|
||||
plain-text line in the core pet's speech bubble (in-window and popped out),
|
||||
labelled with your plugin name; returns a disposer. 120-char cap, TTL default
|
||||
6 s (1–30 s), same `id` replaces, rate-limited, cleared on unload; core
|
||||
error/waiting states win; nothing shows without a visible pet
|
||||
(`ctx.pet.visible`). `ctx.pet.clear(id?)`. Never locate the pet canvas in
|
||||
the DOM or overlay it yourself.
|
||||
- `ctx.i18n.register({ en, ja, ... })` — ship your OWN locale bundles, scoped
|
||||
to your plugin (never edit core `en.ts`). Values are literal strings or
|
||||
interpolator functions; nested trees are addressed by dot-path. Read them
|
||||
|
||||
@@ -225,3 +225,20 @@ def test_an_unserved_identifier_names_the_registry_its_adapter_accepts(identifie
|
||||
from hermes_cli.skills_hub import _registry_from_identifier
|
||||
|
||||
assert _registry_from_identifier(identifier) == registry
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("url", "expected"), [
|
||||
("n8n.example.com/mcp-server/http", "config_invalid"), # a pasted URL with no scheme
|
||||
("${HERMES_TEST_UNSET_MCP_URL}", "missing_credentials"), # the URL's setup value never arrived
|
||||
])
|
||||
def test_an_oauth_card_install_that_fails_before_authorization_keeps_its_class(monkeypatch, url, expected):
|
||||
"""Invariant: the card/agent OAuth install (``mcp_oauth.start``) rebuilds the worker's error from
|
||||
its text, so it must carry the worker's closed class; the row is never the bare ``exception``."""
|
||||
from tools.connectors import mcp_oauth
|
||||
|
||||
monkeypatch.delenv("HERMES_TEST_UNSET_MCP_URL", raising=False)
|
||||
with pytest.raises(Exception) as raised:
|
||||
mcp_oauth.start("n8n-probe", cfg={"url": url, "auth": "oauth"}, url_timeout=60)
|
||||
dims = fields.extension_install_fields(kind="mcp_server", source="catalog", name=None, outcome="failed",
|
||||
error=raised.value)
|
||||
assert dims["failure_class"] == expected
|
||||
@@ -221,6 +221,18 @@ def test_activation_is_not_gated_on_plugins_enabled(tmp_path, monkeypatch):
|
||||
assert memory_plugins.load_memory_provider("gatedmem") is not None
|
||||
|
||||
|
||||
def test_provider_registered_past_the_first_8kb_is_discovered(tmp_path, monkeypatch):
|
||||
"""A long module docstring must not push the registration out of discovery's view."""
|
||||
provider = tmp_path / "plugins" / "longdoc"
|
||||
provider.mkdir(parents=True)
|
||||
docstring = '"""' + "Plain prose about storage.\n" * 400 + '"""\n' # ~10.8 KB, no contract tokens
|
||||
(provider / "__init__.py").write_text(docstring + PROVIDER_SOURCE.format(name="longdoc"), encoding="utf-8")
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
assert "longdoc" in memory_plugins.list_memory_provider_names()
|
||||
assert memory_plugins.find_provider_dir("longdoc") == provider
|
||||
|
||||
|
||||
def test_unreadable_user_plugin_does_not_abort_memory_discovery(tmp_path, monkeypatch):
|
||||
"""One mode-000 / ACL-denied ``$HERMES_HOME/plugins/<x>`` must not hide the bundled
|
||||
providers or its readable siblings from the dashboard / ``hermes memory`` pickers (#111804)."""
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Dispatch/lossless-wait extension exercised through the real public capture API."""
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import pytest
|
||||
from tests.tools.test_browser_supervisor_capture import _FakeCDP, _wait
|
||||
from tools.browser_supervisor import _SupervisorRegistry
|
||||
from tools.browser_supervisor_capture import CapturedCDPInvalid
|
||||
|
||||
class DelayedCDP(_FakeCDP):
|
||||
async def _handle(self, ws):
|
||||
self.connections.append(ws)
|
||||
conn = len(self.connections)
|
||||
async for raw in ws:
|
||||
msg = json.loads(raw)
|
||||
method, sid = msg['method'], msg.get('sessionId')
|
||||
self.frames.append((conn, method, sid))
|
||||
result = {'echo':method}
|
||||
if method == 'Target.getTargets':
|
||||
result = {'targetInfos':[{'targetId':'fixture-parent','type':'page','url':'about:blank'}]}
|
||||
elif method == 'Target.attachToTarget':
|
||||
if msg.get('params',{}).get('targetId') == 'late-child':
|
||||
await asyncio.sleep(.2)
|
||||
result = {'sessionId':'late-owned-session'}
|
||||
else:
|
||||
result = {'sessionId':'default-session'}
|
||||
await ws.send(json.dumps({'id':msg['id'],'result':result}))
|
||||
|
||||
@pytest.fixture
|
||||
def pair():
|
||||
server, registry = DelayedCDP(), _SupervisorRegistry()
|
||||
sup = registry.get_or_start('fixture', server.url, start_timeout=5)
|
||||
try:
|
||||
yield server, registry, sup, registry.capture('fixture')
|
||||
finally:
|
||||
registry.stop_all()
|
||||
|
||||
|
||||
def test_unbounded_reply_retains_late_attachment_for_consumer_cleanup(pair):
|
||||
server, registry, sup, handle = pair
|
||||
abandoned = threading.Event()
|
||||
outcomes = []
|
||||
def worker():
|
||||
result = handle.call('Target.attachToTarget', {'targetId':'late-child','flatten':True}, timeout=None)
|
||||
outcomes.append(result['result']['sessionId'])
|
||||
if abandoned.is_set():
|
||||
handle.call('Target.detachFromTarget', {'sessionId':outcomes[-1]})
|
||||
thread = threading.Thread(target=worker)
|
||||
thread.start()
|
||||
_wait(lambda: len([m for m in server.methods_on(1) if m == 'Target.attachToTarget']) == 2)
|
||||
abandoned.set()
|
||||
thread.join(3)
|
||||
assert not thread.is_alive()
|
||||
assert outcomes == ['late-owned-session']
|
||||
assert server.methods_on(1).count('Target.detachFromTarget') == 1
|
||||
|
||||
|
||||
def test_dispatch_deadline_refuses_queued_command_after_timeout(pair):
|
||||
server, registry, sup, handle = pair
|
||||
entered = threading.Event()
|
||||
def block():
|
||||
# Stall the loop past the caller's deadline but inside its +1s result grace.
|
||||
entered.set()
|
||||
time.sleep(.4)
|
||||
sup._loop.call_soon_threadsafe(block)
|
||||
assert entered.wait(1)
|
||||
with pytest.raises(TimeoutError):
|
||||
handle.call('Probe.expired', timeout=.1)
|
||||
handle.call('Target.getTargets', timeout=2)
|
||||
assert 'Probe.expired' not in server.methods_on(1)
|
||||
|
||||
|
||||
def test_before_send_checks_cancellation_on_supervisor_loop(pair):
|
||||
server, registry, sup, handle = pair
|
||||
entered, release, abandoned = threading.Event(), threading.Event(), threading.Event()
|
||||
failures = []
|
||||
def block():
|
||||
entered.set()
|
||||
release.wait(4)
|
||||
def check():
|
||||
assert asyncio.get_running_loop() is sup._loop
|
||||
if abandoned.is_set():
|
||||
raise ValueError('fixture cancelled')
|
||||
def caller():
|
||||
try:
|
||||
handle.call('Probe.cancelled', timeout=2, before_send=check)
|
||||
except BaseException as exc:
|
||||
failures.append(exc)
|
||||
sup._loop.call_soon_threadsafe(block)
|
||||
assert entered.wait(1)
|
||||
thread = threading.Thread(target=caller)
|
||||
thread.start()
|
||||
abandoned.set()
|
||||
release.set()
|
||||
thread.join(3)
|
||||
assert failures and isinstance(failures[0], ValueError)
|
||||
assert 'Probe.cancelled' not in server.methods_on(1)
|
||||
|
||||
|
||||
def test_unbounded_reply_fails_promptly_on_socket_close(pair):
|
||||
server, registry, sup, handle = pair
|
||||
failures = []
|
||||
# This fixture replies to ordinary methods; block the connection handler on attach instead.
|
||||
def attach():
|
||||
try:
|
||||
handle.call('Target.attachToTarget', {'targetId':'late-child'}, timeout=None)
|
||||
except BaseException as exc:
|
||||
failures.append(exc)
|
||||
thread = threading.Thread(target=attach)
|
||||
thread.start()
|
||||
_wait(lambda: server.methods_on(1).count('Target.attachToTarget') == 2)
|
||||
server.drop_current()
|
||||
thread.join(3)
|
||||
assert not thread.is_alive()
|
||||
assert failures and isinstance(failures[0], CapturedCDPInvalid)
|
||||
|
||||
|
||||
|
||||
def test_unbounded_wait_ends_when_loop_closes_with_dispatch_queued(pair, monkeypatch):
|
||||
server, registry, sup, handle = pair
|
||||
import agent.async_utils as async_utils
|
||||
# A stop racing call() can close the loop before its queued dispatch runs; the
|
||||
# scheduled future then never resolves.
|
||||
real = async_utils.safe_schedule_threadsafe
|
||||
def dropped(coro, loop, **kw):
|
||||
if coro.__qualname__ != 'CapturedCDP._send':
|
||||
return real(coro, loop, **kw)
|
||||
coro.close()
|
||||
return concurrent.futures.Future()
|
||||
monkeypatch.setattr(async_utils, 'safe_schedule_threadsafe', dropped)
|
||||
failures = []
|
||||
def caller():
|
||||
try:
|
||||
handle.call('Probe.dropped', timeout=None)
|
||||
except BaseException as exc:
|
||||
failures.append(exc)
|
||||
thread = threading.Thread(target=caller)
|
||||
thread.start()
|
||||
registry.stop_all()
|
||||
thread.join(4)
|
||||
assert not thread.is_alive()
|
||||
assert failures and isinstance(failures[0], CapturedCDPInvalid)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('timeout', [0, -1, float('inf'), float('nan'), 1e300, True, '5'])
|
||||
def test_invalid_timeout(pair, timeout):
|
||||
server, _, _, cap = pair
|
||||
before = list(server.methods_on(1))
|
||||
with pytest.raises(ValueError):
|
||||
cap.call('Probe.invalid', timeout=timeout)
|
||||
assert server.methods_on(1) == before
|
||||
|
||||
|
||||
def test_async_validator_refuses(pair):
|
||||
server, _, _, cap = pair
|
||||
before = list(server.methods_on(1))
|
||||
async def check():
|
||||
return None
|
||||
with pytest.raises(TypeError, match='synchronously'):
|
||||
cap.call('Probe.async', before_send=check)
|
||||
assert server.methods_on(1) == before
|
||||
@@ -0,0 +1,76 @@
|
||||
"""A failed text_to_speech call must never destroy a file that already lived at output_path.
|
||||
|
||||
The model picks ``output_path``; before this fix a provider failure swept that path as a
|
||||
"partial artifact" even when it was the user's pre-existing file.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.session_context import _UNSET, _VAR_MAP
|
||||
from tools import tts_tool
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _edge_without_session_platform(monkeypatch):
|
||||
for var in _VAR_MAP.values():
|
||||
var.set(_UNSET)
|
||||
monkeypatch.delenv("HERMES_SESSION_PLATFORM", raising=False)
|
||||
monkeypatch.setattr(tts_tool, "_load_tts_config", lambda: {"provider": "edge"})
|
||||
monkeypatch.setattr(tts_tool, "_import_edge_tts", lambda: object())
|
||||
yield
|
||||
for var in _VAR_MAP.values():
|
||||
var.set(_UNSET)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def audio_dir(tmp_path):
|
||||
path = tmp_path / "audio" # tmp_path also holds the conftest's isolated HERMES_HOME
|
||||
path.mkdir()
|
||||
return path
|
||||
|
||||
|
||||
async def _partial_write_then_fail(_text: str, output_path: str, _cfg: dict) -> str:
|
||||
Path(output_path).write_bytes(b"partial")
|
||||
raise RuntimeError("provider unreachable")
|
||||
|
||||
|
||||
async def _write_audio(_text: str, output_path: str, _cfg: dict) -> str:
|
||||
Path(output_path).write_bytes(b"new-audio")
|
||||
return output_path
|
||||
|
||||
|
||||
def test_failed_synthesis_keeps_existing_file_and_cleans_partial(audio_dir, monkeypatch):
|
||||
existing = audio_dir / "notes.mp3"
|
||||
existing.write_bytes(b"user data")
|
||||
monkeypatch.setattr(tts_tool, "_generate_edge_tts", _partial_write_then_fail)
|
||||
|
||||
result = json.loads(tts_tool.text_to_speech_tool("hello", output_path=str(existing)))
|
||||
|
||||
assert result["success"] is False
|
||||
assert existing.read_bytes() == b"user data"
|
||||
assert sorted(p.name for p in audio_dir.iterdir()) == ["notes.mp3"]
|
||||
|
||||
|
||||
def test_failed_synthesis_to_new_path_leaves_nothing_behind(audio_dir, monkeypatch):
|
||||
monkeypatch.setattr(tts_tool, "_generate_edge_tts", _partial_write_then_fail)
|
||||
|
||||
result = json.loads(tts_tool.text_to_speech_tool("hello", output_path=str(audio_dir / "new.mp3")))
|
||||
|
||||
assert result["success"] is False
|
||||
assert list(audio_dir.iterdir()) == []
|
||||
|
||||
|
||||
def test_successful_synthesis_still_overwrites_existing_file(audio_dir, monkeypatch):
|
||||
existing = audio_dir / "notes.mp3"
|
||||
existing.write_bytes(b"user data")
|
||||
monkeypatch.setattr(tts_tool, "_generate_edge_tts", _write_audio)
|
||||
|
||||
result = json.loads(tts_tool.text_to_speech_tool("hello", output_path=str(existing)))
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["file_path"] == str(existing)
|
||||
assert existing.read_bytes() == b"new-audio"
|
||||
assert sorted(p.name for p in audio_dir.iterdir()) == ["notes.mp3"]
|
||||
@@ -449,7 +449,7 @@ class CDPSupervisor(DialogSupervisionMixin, FrameTrackingMixin):
|
||||
await self._install_dialog_bridge(sid)
|
||||
|
||||
async def _cdp(self, method: str, params: Optional[dict[str, Any]] = None, *,
|
||||
session_id: Optional[str] = None, timeout: float = 10.0) -> dict[str, Any]:
|
||||
session_id: Optional[str] = None, timeout: Optional[float] = 10.0) -> dict[str, Any]:
|
||||
"""Send a CDP command and await its response."""
|
||||
if self._ws is None:
|
||||
raise RuntimeError("supervisor WebSocket is not connected")
|
||||
|
||||
@@ -14,7 +14,11 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
from typing import TYPE_CHECKING, Any, Dict, Optional
|
||||
import inspect
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any, Callable, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from tools.browser_supervisor import CDPSupervisor, _SupervisorRegistry
|
||||
@@ -50,9 +54,22 @@ class CapturedCDP:
|
||||
"(supervisor reconnected, stopped or was replaced); capture again")
|
||||
|
||||
async def _send(self, method: str, params: Optional[dict[str, Any]],
|
||||
session_id: Optional[str], timeout: float) -> dict[str, Any]:
|
||||
session_id: Optional[str], timeout: Optional[float],
|
||||
deadline: Optional[float], before_send: Optional[Callable[[], None]]) -> dict[str, Any]:
|
||||
# ``_cdp`` reaches its send without awaiting, so this check and the send run in one
|
||||
# loop step: a reconnect cannot swap the socket in between.
|
||||
if not self.is_valid():
|
||||
raise self._invalid()
|
||||
if deadline is not None and time.monotonic() >= deadline:
|
||||
raise TimeoutError("Captured CDP dispatch deadline expired")
|
||||
if before_send is not None:
|
||||
outcome = before_send()
|
||||
if outcome is not None:
|
||||
if inspect.iscoroutine(outcome):
|
||||
outcome.close()
|
||||
raise TypeError("before_send must return None synchronously")
|
||||
if deadline is not None and time.monotonic() >= deadline:
|
||||
raise TimeoutError("Captured CDP dispatch deadline expired")
|
||||
if not self.is_valid():
|
||||
raise self._invalid()
|
||||
try:
|
||||
@@ -65,7 +82,8 @@ class CapturedCDP:
|
||||
raise
|
||||
|
||||
def call(self, method: str, params: Optional[dict[str, Any]] = None, *,
|
||||
session_id: Optional[str] = None, timeout: float = 10.0) -> dict[str, Any]:
|
||||
session_id: Optional[str] = None, timeout: Optional[float] = 10.0,
|
||||
before_send: Optional[Callable[[], None]] = None) -> dict[str, Any]:
|
||||
"""Send ``method`` on the captured connection and return the raw CDP reply
|
||||
(``{"id", "result"}``). ``session_id=None`` targets the browser endpoint;
|
||||
pass ``page_session_id`` (or a session you attached) for page domains.
|
||||
@@ -73,7 +91,27 @@ class CapturedCDP:
|
||||
Raises ``CapturedCDPInvalid`` when the connection is gone, ``TimeoutError`` when no
|
||||
reply arrives in ``timeout`` seconds, ``RuntimeError`` for a CDP error reply.
|
||||
Blocks the calling thread; never call it from the supervisor's own loop.
|
||||
|
||||
A finite timeout also fences send admission while this call waits in the
|
||||
supervisor queue. ``timeout=None`` deliberately retains a reply until
|
||||
the connection closes: use it only in a consumer-owned worker when a
|
||||
late reply (for example an attachment session ID) must not be lost.
|
||||
Keep the consumer's own wait/issuance deadline finite and retain cleanup
|
||||
responsibility after it expires.
|
||||
|
||||
``before_send`` is a trusted synchronous nonblocking validation callback
|
||||
run on the supervisor loop immediately before CDP dispatch. It may raise
|
||||
to refuse a cancelled/expired operation. It must not call a blocking
|
||||
capture/CDP API. This is not origin or target-ownership authorization.
|
||||
"""
|
||||
if timeout is not None:
|
||||
if (isinstance(timeout, bool) or not isinstance(timeout, (int, float))
|
||||
or not math.isfinite(timeout) or timeout <= 0
|
||||
or timeout > threading.TIMEOUT_MAX - 1.0):
|
||||
raise ValueError("Captured CDP timeout must be positive and finite, or None")
|
||||
if before_send is not None and not callable(before_send):
|
||||
raise TypeError("before_send must be callable")
|
||||
deadline = None if timeout is None else time.monotonic() + timeout
|
||||
try:
|
||||
running = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
@@ -84,9 +122,17 @@ class CapturedCDP:
|
||||
raise self._invalid()
|
||||
from agent.async_utils import safe_schedule_threadsafe
|
||||
|
||||
fut = safe_schedule_threadsafe(self._send(method, params, session_id, timeout), self._loop)
|
||||
fut = safe_schedule_threadsafe(self._send(method, params, session_id, timeout, deadline, before_send), self._loop)
|
||||
if fut is None:
|
||||
raise self._invalid()
|
||||
while timeout is None:
|
||||
# Unbounded wait still ends with the loop: a closed loop drops queued callbacks.
|
||||
try:
|
||||
return fut.result(timeout=1.0)
|
||||
except concurrent.futures.TimeoutError:
|
||||
if self._loop.is_closed():
|
||||
fut.cancel()
|
||||
raise self._invalid() from None
|
||||
try:
|
||||
return fut.result(timeout=timeout + 1.0)
|
||||
except concurrent.futures.TimeoutError:
|
||||
|
||||
@@ -57,9 +57,11 @@ def probe_with_rollback(
|
||||
server_name, cfg, connect_timeout=login_connect_timeout(cfg), details=details)
|
||||
if not _oauth_tokens_present(server_name):
|
||||
details["initialized"] = False
|
||||
raise RuntimeError(
|
||||
no_token = RuntimeError(
|
||||
"The server responded, but no OAuth token was obtained — "
|
||||
"this provider may require a manually-registered OAuth client.")
|
||||
no_token.failure_class = "auth_required" # type: ignore[attr-defined]
|
||||
raise no_token
|
||||
except Exception as exc:
|
||||
if not details.get("initialized"):
|
||||
undo()
|
||||
@@ -178,6 +180,7 @@ def run_worker(
|
||||
if home_token is not None:
|
||||
reset_hermes_home_override(home_token)
|
||||
except Exception as exc:
|
||||
from hermes_cli.mcp_config import probe_failure_class
|
||||
from tools.mcp_dashboard_oauth import exception_message
|
||||
msg = exception_message(exc)
|
||||
with suppress(Exception):
|
||||
@@ -185,7 +188,12 @@ def run_worker(
|
||||
msg = humanize_oauth_registration_error(
|
||||
server_name, exc, server_url=cfg.get("url") if isinstance(cfg, dict) else None
|
||||
) or msg
|
||||
failure_class = probe_failure_class(exc)
|
||||
if failure_class == "connect_failed" and server_name not in msg: # e.g. a bare "Not Found"
|
||||
msg = f"Could not connect to '{server_name}' ({msg}). Check its URL and that this machine can reach it."
|
||||
if flow is not None:
|
||||
# Read by ``start``: its error is rebuilt from text, so the class rides on the flow.
|
||||
flow.failure_class = failure_class
|
||||
flow.mark_error(msg)
|
||||
finally:
|
||||
if flow is not None:
|
||||
@@ -340,7 +348,9 @@ def start(
|
||||
auth_url=snapshot["authorization_url"], flow=flow,
|
||||
detail=_ssh_detail(flow.redirect_uri) if client_redirect_uri is None else "")
|
||||
if snapshot.get("status") == "error":
|
||||
raise RuntimeError(snapshot.get("error") or "the OAuth flow failed before authorization")
|
||||
failed = RuntimeError(snapshot.get("error") or "the OAuth flow failed before authorization")
|
||||
failed.failure_class = getattr(flow, "failure_class", None) # type: ignore[attr-defined]
|
||||
raise failed
|
||||
time.sleep(0.05)
|
||||
flow.mark_error("Timed out waiting for MCP authorization URL")
|
||||
raise TimeoutError(f"timed out waiting for the authorization URL for '{server_name}'")
|
||||
@@ -472,8 +472,10 @@ def _require_rendered_remote(server_name: str, config: dict) -> dict:
|
||||
unresolved = sorted({m.group(1) for value in values for m in _ENV_VAR_PATTERN.finditer(str(value))})
|
||||
if unresolved:
|
||||
refs = ", ".join(f"${{{ref}}}" for ref in unresolved)
|
||||
raise ValueError(f"MCP server '{server_name}': {refs} in url/headers is not set in this profile's "
|
||||
".env or secret source")
|
||||
unset = ValueError(f"MCP server '{server_name}': {refs} in url/headers is not set in this profile's "
|
||||
".env or secret source")
|
||||
unset.failure_class = "missing_credentials" # type: ignore[attr-defined]
|
||||
raise unset
|
||||
return config
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, Any, List, Optional
|
||||
|
||||
@@ -431,6 +432,10 @@ def _synthesize_chunks(chunks: list[str], base_path: Path, generated_artifacts:
|
||||
chunk_path = base_path
|
||||
if len(chunks) > 1:
|
||||
chunk_path = base_path.with_name(f"{base_path.stem}.chunk{index:03d}{base_path.suffix}")
|
||||
if os.path.lexists(chunk_path):
|
||||
# A file we didn't create: synthesize beside it so a failed attempt can neither truncate
|
||||
# nor sweep it; on success the delivery step os.replace()s the audio over it (overwrite).
|
||||
chunk_path = chunk_path.with_name(f".{chunk_path.stem}.{uuid.uuid4().hex}{chunk_path.suffix}")
|
||||
generated_artifacts.add(str(chunk_path))
|
||||
raw_result = _text_to_speech_single(chunk, str(chunk_path), **single_kwargs)
|
||||
try:
|
||||
|
||||
@@ -116,11 +116,14 @@ title = reply["result"]["result"]["value"]
|
||||
- `SUPERVISOR_REGISTRY.capture(task_id, *, timeout=10.0) -> CapturedCDP` pins the
|
||||
supervisor's current WebSocket and the default page session attached on it. It
|
||||
never starts, reconnects or refocuses a supervisor.
|
||||
- `CapturedCDP.call(method, params=None, *, session_id=None, timeout=10.0) -> dict`
|
||||
- `CapturedCDP.call(method, params=None, *, session_id=None, timeout=10.0, before_send=None) -> dict`
|
||||
returns the raw CDP reply (`{"id", "result"}`). `session_id=None` addresses the
|
||||
browser endpoint (`Target.*`); pass `page_session_id` or a session you attached
|
||||
yourself for page domains. A CDP error reply raises `RuntimeError`; no reply
|
||||
within `timeout` raises `TimeoutError`. The call blocks its thread, so call it
|
||||
within a finite `timeout` raises `TimeoutError`. Finite timeouts fence queue
|
||||
admission with a monotonic deadline. `timeout=None` retains the reply until
|
||||
response or connection closure; acquisition owners can keep late attach IDs
|
||||
for cleanup beyond their own outer deadline. The call blocks its thread, so call it
|
||||
from a worker thread, never from the supervisor's own event loop.
|
||||
- `CapturedCDP.is_valid()` stays true until the supervisor reconnects, stops, or
|
||||
is replaced in the registry. After that every `call` raises
|
||||
@@ -131,7 +134,11 @@ title = reply["result"]["result"]["value"]
|
||||
`focus_page` on the supervisor does not change it.
|
||||
- Sessions you attach (`Target.attachToTarget`) are yours to detach. A reply
|
||||
that arrives after `timeout` is dropped, so a timed-out attach can leave a
|
||||
session attached until the connection closes; give attaches a generous timeout.
|
||||
session attached until the connection closes. Use `timeout=None` with a retained
|
||||
acquisition worker to own and detach late replies, rather than guessing a large timeout.
|
||||
- `before_send` is a trusted synchronous, nonblocking validator on the supervisor
|
||||
loop immediately before dispatch. Return `None`, or raise to refuse; do not call
|
||||
capture/CDP APIs from it. It does not revoke commands already dispatched.
|
||||
|
||||
This is a trusted, in-process seam. It grants nothing in-process Python could
|
||||
not already reach, and it enforces no origin, consent or target-ownership
|
||||
|
||||
@@ -198,6 +198,8 @@ interface PluginContext {
|
||||
addEventListener: (target: EventTarget, type: string, listener: EventListener, options?: AddEventListenerOptions | boolean) => () => void
|
||||
/** The curated OS door: native notification, open-external, reveal-in-file-manager, clipboard. */
|
||||
os: PluginOs
|
||||
/** Lines in the core pet's speech bubble, attributed to this plugin (see "Pet bubble"). */
|
||||
pet: PluginPet
|
||||
/** Plugin-scoped JSON persistence (keys live under `hermes.plugin.<id>.`). */
|
||||
storage: PluginStorage
|
||||
}
|
||||
@@ -543,7 +545,8 @@ A multi-session plugin keeps its per-session state on its side (which session
|
||||
its panel is editing) and passes that id here; the bus guarantees one
|
||||
plugin write can never land in another session's composer.
|
||||
|
||||
**Migrating off DOM reach-in** (the held catalog plugins that motivated this API):
|
||||
**Migrating off DOM reach-in** (the held catalog plugins that motivated this API
|
||||
and its siblings; the pet-wallet row uses the [pet bubble](#pet-bubble) door):
|
||||
|
||||
| Plugin | Was | Now |
|
||||
|---|---|---|
|
||||
@@ -552,6 +555,7 @@ plugin write can never land in another session's composer.
|
||||
| prompt-enhancer (#116031) | walks the editor's child nodes to serialize, rebuilds chip DOM, `replaceChildren` + synthetic `InputEvent` | `const draft = await host.composer.getDraft(sid)` → transform → `await host.composer.setDraft(sid, enhanced)` (chips hydrate app-side); revert is another `setDraft` |
|
||||
| memory-review (#115966) | `host.request('slash.exec', { session_id, command })` for `/memory …` — already SDK-only | optional: `host.composer.insertText(sid, '/memory pending', { mode: 'prefix' })` to seat the command for the user instead of executing it |
|
||||
| intelligent-tool-break (#115964) | "Message" button only toasts "type /break" (no composer write) | `host.composer.setDraft(host.state.focusedSessionId.get(), '/break ')` then `host.composer.focus(null)` restores the intended behaviour |
|
||||
| pet-wallet (#135178) | `document.querySelector('canvas[aria-label$=" pet"]')` to find the core pet, a `position:fixed; z-index:9999` overlay on `document.body` that follows it every frame, document-wide capture-phase pointer listeners | `ctx.pet.say(text, { id: 'balance', tone?, ttlMs? })` — the core bubble shows it over the pet, in-window and popped out, labelled with the plugin name; `ctx.pet.visible` tells you when there is no pet so you can fall back to your status-bar chip |
|
||||
|
||||
`sessionId` in the table is the id the plugin's UI is bound to; for a composer
|
||||
slot render it is `host.state.focusedSessionId.get()`.
|
||||
@@ -1092,6 +1096,9 @@ ctx.os.notify({ title, body?, silent?, icon?, activate?, onActivate?, actions? }
|
||||
ctx.os.openExternal(url) // OS default handler (browser, mail, spotify:) → Promise<boolean>
|
||||
ctx.os.revealPath(path) // reveal in Finder / Explorer → Promise<boolean>
|
||||
ctx.os.writeClipboard(text) // system clipboard → Promise<boolean>
|
||||
ctx.pet.say(text, { id?, tone?, ttlMs? }) // line in the core pet's speech bubble → disposer
|
||||
ctx.pet.clear(id?) // drop one line (or all of yours)
|
||||
ctx.pet.visible // ReadableAtom<boolean> — is a pet on screen?
|
||||
host.navigate('/route') // hash-route navigation
|
||||
host.openSession(id, { profile?, intent? }) // open a stored session core-style;
|
||||
// profile: soft-swap to that profile's backend first
|
||||
@@ -1246,6 +1253,70 @@ The other doors (`openExternal`, `revealPath`, `writeClipboard`) resolve
|
||||
`false` instead of throwing when the capability isn't available (older desktop
|
||||
shell, plain browser) — branch on the result rather than sniffing the bridge.
|
||||
|
||||
### Pet bubble — `ctx.pet` {#pet-bubble}
|
||||
|
||||
The core pet (the petdex mascot, in-window or popped out into its own OS
|
||||
window) has a speech bubble. `ctx.pet` lets a plugin put a short line in it,
|
||||
so you never have to find the pet in the app DOM or float your own overlay
|
||||
over it:
|
||||
|
||||
```ts
|
||||
register(ctx) {
|
||||
// Shows "DeepSeek ¥12.40 left" over the pet for 8 s, labelled "Pet Wallet".
|
||||
const dispose = ctx.pet.say('DeepSeek ¥12.40 left', { id: 'balance', ttlMs: 8000 })
|
||||
|
||||
// Same id → replaces the line in place (a refreshed balance, a countdown).
|
||||
ctx.pet.say('DeepSeek ¥11.90 left', { id: 'balance' })
|
||||
|
||||
// Tones: 'info' (default), 'wait' (clock glyph), 'error' (alert glyph).
|
||||
ctx.pet.say('Codex 5h quota at 90%', { id: 'quota', tone: 'wait' })
|
||||
|
||||
dispose() // remove early, or
|
||||
ctx.pet.clear('quota') // by id, or ctx.pet.clear() for all of yours
|
||||
|
||||
// No pet on screen? Fall back to your own status-bar chip or pane.
|
||||
const visible = ctx.pet.visible.get()
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
interface PluginPet {
|
||||
say(text: string, options?: { id?: string; tone?: 'info' | 'wait' | 'error'; ttlMs?: number }): () => void
|
||||
clear(id?: string): void
|
||||
visible: ReadableAtom<boolean>
|
||||
}
|
||||
```
|
||||
|
||||
What the host guarantees, so you don't have to:
|
||||
|
||||
- **Plain text.** Control characters and bidi overrides are stripped,
|
||||
whitespace collapses to one line, and the text is capped at 120 characters.
|
||||
It renders as a text node; markup shows literally.
|
||||
- **Attributed.** Your plugin's name (from the Plugins inventory) is printed
|
||||
above the line, so the user can tell your words from the pet's.
|
||||
- **Short-lived.** Each line expires after `ttlMs` (default 6 s, clamped to
|
||||
1–30 s). Re-`say` with the same `id` to keep a value up. At most 3 lines per
|
||||
plugin are live (the oldest is evicted) and the bubble shows the newest
|
||||
line from any plugin.
|
||||
- **Rate-limited.** 10 `say` calls per plugin per 10 s; extra calls are dropped
|
||||
with a console warning and return a no-op disposer.
|
||||
- **Core first.** When the agent hits an error or is waiting on the user, the
|
||||
core status bubble wins; your line shows again once that clears (if it
|
||||
hasn't expired).
|
||||
- **The user's pet setting wins.** If no pet is adopted, or it is turned off,
|
||||
nothing shows. `ctx.pet.visible` lets you branch on that.
|
||||
- **Cleaned up with you.** Disabling, unloading, or hot-reloading your plugin
|
||||
removes every line it still has up.
|
||||
|
||||
In-window, the bubble appears only for plugin lines (the app itself shows the
|
||||
agent's status). In the popped-out overlay, plugin lines share the bubble with
|
||||
the core status lines. The overlay is a separate window that loads no plugin
|
||||
code; the main window sends it the live lines with the rest of the pet state.
|
||||
|
||||
Pointer input on the pet (drag, shift-click pop-out, overlay click) belongs to
|
||||
the host and has no plugin hook. Use a palette command, a status-bar item, or
|
||||
your own pane for actions.
|
||||
|
||||
### Desktop appearance settings — `host.settings`
|
||||
|
||||
`host.settings` is the supported door for the small set of Desktop-local
|
||||
@@ -1828,7 +1899,7 @@ pipeline as a trust boundary.
|
||||
| Category | Exports |
|
||||
|----------|---------|
|
||||
| Host | `host` (`.state.*`, `.settings`, `.notify`, `.notifyError`, `.navigate`, `.onEvent`, `.logs`, `.status`, `.restartGateway`, `.request`, `.composer`, `.sessions`, `.skills`, `.toolsets`, `.profiles`, `.pluginDecisions`) |
|
||||
| Plugin contract | `HermesPlugin`, `PluginContext`, `PluginContribution`, `PluginStorage`, `PluginOs`, `PluginRestOptions`, `PluginNativeNotificationInput`, `PluginNotificationAction`, `HermesOpenTarget`, `Contribution` |
|
||||
| Plugin contract | `HermesPlugin`, `PluginContext`, `PluginContribution`, `PluginStorage`, `PluginOs`, `PluginPet`, `PetSayOptions`, `PetMessageTone`, `PluginRestOptions`, `PluginNativeNotificationInput`, `PluginNotificationAction`, `HermesOpenTarget`, `Contribution` |
|
||||
| Area constants | `PANES_AREA`, `ROUTES_AREA`, `SIDEBAR_NAV_AREA`, `STATUSBAR_AREAS`, `TITLEBAR_AREAS`, `WORKSPACE_PAGE_HEADER_AREA`, `PALETTE_AREA`, `KEYBINDS_AREA`, `THEMES_AREA`, `COMPOSER_AREAS`, `MODEL_MENU_ROW_AREA`, `SESSION_ROW_AREAS`, `SIDEBAR_NAV_PREFS_AREA`, `APPEARANCE_AREAS`, `SETTINGS_PLUGINS_AREA` |
|
||||
| Area payloads | `PluginSettingsPage`, `PluginSettingsSubpage` (+ `pluginSettingsHref`), `RouteContribution`, `SidebarNavContribution`, `StatusbarItem`, `TitlebarTool`, `PaletteContribution`, `KeybindContribution`, `ComposerMiddleware`, `ComposerAttachmentProvider`, `SessionRowSlotContribution`, `SidebarNavPrefsContribution` |
|
||||
| React / state | `useValue`, `atom`, `computed`, `useQuery`, `useMutation`, `useQueryClient`, `queryClient`, `Contribute`, `WorkspacePageHeaderControl` |
|
||||
|
||||
Reference in new issue
Block a user