mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-30 11:52:04 +00:00
feat(desktop): add pet roam + motion/direction store signals
Opt-in $petRoam (localStorage), $petMotion (run/jump pose) and $petRoamDir (-1/0/1) feed the shared $petState only while the agent is at rest ($petAtRest), so a wander never overrides real activity.
This commit is contained in:
parent
ccc92c5213
commit
c6d6a1c30d
2 changed files with 97 additions and 4 deletions
|
|
@ -1,6 +1,14 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { $petActivity, $petState, derivePetState, flashPetActivity, setPetActivity } from './pet'
|
||||
import {
|
||||
$petActivity,
|
||||
$petAtRest,
|
||||
$petMotion,
|
||||
$petState,
|
||||
derivePetState,
|
||||
flashPetActivity,
|
||||
setPetActivity
|
||||
} from './pet'
|
||||
|
||||
describe('derivePetState', () => {
|
||||
it('rests at idle by default and uses waiting when awaiting input', () => {
|
||||
|
|
@ -32,6 +40,41 @@ describe('derivePetState', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('roam motion', () => {
|
||||
it('only reports at-rest when the agent-driven state is plain idle', () => {
|
||||
$petActivity.set({})
|
||||
expect($petAtRest.get()).toBe(true)
|
||||
|
||||
$petActivity.set({ busy: true })
|
||||
expect($petAtRest.get()).toBe(false)
|
||||
|
||||
$petActivity.set({})
|
||||
expect($petAtRest.get()).toBe(true)
|
||||
})
|
||||
|
||||
it('shows the roam pose while wandering, but never overrides real activity', () => {
|
||||
$petActivity.set({})
|
||||
$petMotion.set('run')
|
||||
expect($petState.get()).toBe('run')
|
||||
|
||||
// Hops surface the jump pose.
|
||||
$petMotion.set('jump')
|
||||
expect($petState.get()).toBe('jump')
|
||||
|
||||
// Activity wins over a wander in progress.
|
||||
$petActivity.set({ reasoning: true, busy: true })
|
||||
expect($petState.get()).toBe('review')
|
||||
|
||||
// Back at rest, the wander resumes its pose; clearing it returns to idle.
|
||||
$petActivity.set({})
|
||||
expect($petState.get()).toBe('jump')
|
||||
$petMotion.set(null)
|
||||
expect($petState.get()).toBe('idle')
|
||||
|
||||
$petActivity.set({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('flashPetActivity', () => {
|
||||
it('clears stale sibling beats so a completion never inherits a prior error', () => {
|
||||
// A turn errors (sad), then the next turn finishes cleanly. The celebrate
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { atom, computed } from 'nanostores'
|
||||
|
||||
import { persistBoolean, storedBoolean } from '@/lib/storage'
|
||||
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
import { $busy } from '@/store/session'
|
||||
|
||||
|
|
@ -134,15 +135,15 @@ export const flashPetActivity = (next: Partial<PetActivity>, ms = 1600) => {
|
|||
export const setPetInfo = (info: PetInfo) => $petInfo.set(info)
|
||||
|
||||
/**
|
||||
* The live pet state. Derives from the dedicated activity atom, falling back to
|
||||
* the always-present `$busy` chat signal so the pet reacts out of the box.
|
||||
* Resolve the live activity state from the dedicated activity atom, falling back
|
||||
* to the always-present `$busy` chat signal so the pet reacts out of the box.
|
||||
*
|
||||
* `awaitingInput` (a clarify/approval blocking on the user) is an explicit flag
|
||||
* on `$petActivity` — set by the controller from `$attentionSessionIds` and
|
||||
* mirrored to the pop-out overlay through the same atom, so both surfaces agree
|
||||
* without the overlay needing the session list.
|
||||
*/
|
||||
export const $petState = computed([$petActivity, $busy], (activity, busy): PetState => {
|
||||
function deriveLivePetState(activity: PetActivity, busy: boolean): PetState {
|
||||
const live = activity.busy ?? busy
|
||||
|
||||
return derivePetState({
|
||||
|
|
@ -156,4 +157,53 @@ export const $petState = computed([$petActivity, $busy], (activity, busy): PetSt
|
|||
justCompleted: activity.justCompleted,
|
||||
celebrate: activity.celebrate
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Opt-in: let the floating mascot wander around the window on its own while
|
||||
* idle. Pure desktop-client behavior (no agent/config dependency), so it lives
|
||||
* in localStorage like the pet's drag position — per-device, not per-profile.
|
||||
*/
|
||||
const ROAM_KEY = 'hermes.desktop.pet-roam.v1'
|
||||
export const $petRoam = atom<boolean>(storedBoolean(ROAM_KEY, false))
|
||||
|
||||
export const setPetRoam = (on: boolean) => {
|
||||
$petRoam.set(on)
|
||||
persistBoolean(ROAM_KEY, on)
|
||||
}
|
||||
|
||||
/**
|
||||
* The pose the roam loop is currently driving: `run` while walking a surface,
|
||||
* `jump` while hopping/falling between surfaces, or `null` at rest. Surfaced
|
||||
* through `$petState` (below) so the canvas animates the wander without any prop
|
||||
* change or re-render — it already subscribes to `$petState`.
|
||||
*/
|
||||
export const $petMotion = atom<PetState | null>(null)
|
||||
|
||||
/**
|
||||
* Horizontal travel direction while roaming: -1 left, 1 right, 0 not walking.
|
||||
* The floating pet maps this to the directional run row + mirror, keeping the
|
||||
* wander loop free of sprite-row knowledge.
|
||||
*/
|
||||
export const $petRoamDir = atom<-1 | 0 | 1>(0)
|
||||
|
||||
/**
|
||||
* Whether the agent-driven state is at rest (plain `idle`). The roam loop gates
|
||||
* on this — never on `$petState` itself, which would feed back on its own
|
||||
* `$petMotion`-driven pose and stall the wander.
|
||||
*/
|
||||
export const $petAtRest = computed(
|
||||
[$petActivity, $busy],
|
||||
(activity, busy): boolean => deriveLivePetState(activity, busy) === 'idle'
|
||||
)
|
||||
|
||||
/**
|
||||
* The live pet state. Activity always wins; only when the agent is at rest does
|
||||
* a roam pose (walking → `run`, hopping → `jump`) show through, so the wander
|
||||
* reads as deliberate movement.
|
||||
*/
|
||||
export const $petState = computed([$petActivity, $busy, $petMotion], (activity, busy, motion): PetState => {
|
||||
const base = deriveLivePetState(activity, busy)
|
||||
|
||||
return base === 'idle' && motion ? motion : base
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue