remove the desktop-pet system
The pet feature was three loosely coupled subsystems that no core flow depended on: the Petdex catalog page, the pet install/preference store, and the pet-state socket. All three are removed. The only cross-boundary touch point was a try/catch-wrapped observer that mapped chat-run events to pet animations. Removing it leaves the /chat-run event contract unchanged: no event name, field name, field type, or emission timing changed. Removing petdexPublicRoutes also closes the unauthenticated outbound proxy at /api/hermes/petdex/asset, which shrinks the SSRF surface.
This commit is contained in:
@@ -1,26 +0,0 @@
|
||||
import type { Context } from 'koa'
|
||||
import { fetchPetdexAsset, fetchPetdexManifest } from '../../services/hermes/petdex'
|
||||
|
||||
export async function manifest(ctx: Context) {
|
||||
const force = ctx.query.force === '1' || ctx.query.force === 'true'
|
||||
ctx.body = await fetchPetdexManifest({ force })
|
||||
}
|
||||
|
||||
export async function asset(ctx: Context) {
|
||||
const url = typeof ctx.query.url === 'string' ? ctx.query.url.trim() : ''
|
||||
if (!url) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Petdex asset URL is required' }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const asset = await fetchPetdexAsset(url)
|
||||
ctx.type = asset.mime
|
||||
ctx.set('Cache-Control', `public, max-age=${asset.maxAgeSeconds}`)
|
||||
ctx.body = asset.buffer
|
||||
} catch (err) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: err instanceof Error ? err.message : 'Petdex asset request failed' }
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { Context } from 'koa'
|
||||
import { getActiveProfileName } from '../../services/hermes/hermes-profile'
|
||||
import { adoptPetFromPetdex, getActivePet, updateActivePetPreferences } from '../../services/hermes/pets'
|
||||
|
||||
function requestedProfile(ctx: Context): string {
|
||||
return ctx.state.profile?.name || getActiveProfileName() || 'default'
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : 'Pet request failed'
|
||||
}
|
||||
|
||||
export async function active(ctx: Context) {
|
||||
ctx.body = { pet: await getActivePet(requestedProfile(ctx)) }
|
||||
}
|
||||
|
||||
export async function adopt(ctx: Context) {
|
||||
const body = ctx.request.body as { slug?: unknown } | undefined
|
||||
const slug = typeof body?.slug === 'string' ? body.slug.trim() : ''
|
||||
if (!slug) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Pet slug is required' }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
ctx.body = { pet: await adoptPetFromPetdex(requestedProfile(ctx), slug) }
|
||||
} catch (err) {
|
||||
const message = errorMessage(err)
|
||||
ctx.status = message.includes('was not found') ? 404 : 400
|
||||
ctx.body = { error: message }
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateActive(ctx: Context) {
|
||||
const body = ctx.request.body as {
|
||||
scale?: unknown
|
||||
position?: { x?: unknown; y?: unknown }
|
||||
enabled?: unknown
|
||||
} | undefined
|
||||
|
||||
const pet = await updateActivePetPreferences(requestedProfile(ctx), {
|
||||
scale: typeof body?.scale === 'number' ? body.scale : undefined,
|
||||
enabled: typeof body?.enabled === 'boolean' ? body.enabled : undefined,
|
||||
position: body?.position && typeof body.position.x === 'number' && typeof body.position.y === 'number'
|
||||
? { x: body.position.x, y: body.position.y }
|
||||
: undefined,
|
||||
})
|
||||
ctx.body = { pet }
|
||||
}
|
||||
@@ -27,7 +27,6 @@ import { scanLanDevices, startLanDiscoveryResponder } from './services/lan-disco
|
||||
import { getLanPeerSocketManager, getLanPeerSocketPath } from './services/lan-peer-socket'
|
||||
import { startGlobalAgentServer } from './services/global-agent/server'
|
||||
import { WorkflowSocketServer } from './services/workflow-socket'
|
||||
import { PetStateSocketServer } from './services/hermes/pet-state-socket'
|
||||
import { logger } from './services/logger'
|
||||
import { createStaticCompressionMiddleware } from './middleware/static-compression'
|
||||
import { requireUserJwt, resolveUserProfile } from './middleware/user-auth'
|
||||
@@ -57,7 +56,6 @@ let server: any = null
|
||||
let servers: any[] = []
|
||||
let chatRunServer: any = null
|
||||
let workflowSocketServer: WorkflowSocketServer | null = null
|
||||
let petStateSocketServer: PetStateSocketServer | null = null
|
||||
let agentBridgeManager: any = null
|
||||
|
||||
interface ListenResult {
|
||||
@@ -251,9 +249,6 @@ export async function bootstrap() {
|
||||
workflowSocketServer = new WorkflowSocketServer(groupChatServer.getIO())
|
||||
workflowSocketServer.init()
|
||||
|
||||
petStateSocketServer = new PetStateSocketServer(groupChatServer.getIO())
|
||||
petStateSocketServer.init()
|
||||
|
||||
const loopbackBaseUrl = getLoopbackBaseUrl(server)
|
||||
startGlobalAgentServer(groupChatServer.getIO(), { localBaseUrl: loopbackBaseUrl })
|
||||
console.log('[bootstrap] global agent server ready')
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import Router from '@koa/router'
|
||||
import * as ctrl from '../../controllers/hermes/petdex'
|
||||
|
||||
export const petdexRoutes = new Router()
|
||||
export const petdexPublicRoutes = new Router()
|
||||
|
||||
petdexPublicRoutes.get('/api/hermes/petdex/asset', ctrl.asset)
|
||||
petdexRoutes.get('/api/hermes/petdex/manifest', ctrl.manifest)
|
||||
@@ -1,8 +0,0 @@
|
||||
import Router from '@koa/router'
|
||||
import * as ctrl from '../../controllers/hermes/pets'
|
||||
|
||||
export const petRoutes = new Router()
|
||||
|
||||
petRoutes.get('/api/hermes/pets/active', ctrl.active)
|
||||
petRoutes.patch('/api/hermes/pets/active', ctrl.updateActive)
|
||||
petRoutes.post('/api/hermes/pets/adopt', ctrl.adopt)
|
||||
@@ -43,8 +43,6 @@ import { journeyRoutes } from './hermes/journey'
|
||||
import { mcpRoutes } from './hermes/mcp'
|
||||
import { runtimeVersionRoutes } from './hermes/runtime-versions'
|
||||
import { writeGateRoutes } from './hermes/write-gate'
|
||||
import { petdexPublicRoutes, petdexRoutes } from './hermes/petdex'
|
||||
import { petRoutes } from './hermes/pets'
|
||||
|
||||
/**
|
||||
* Register all routes on the Koa app.
|
||||
@@ -59,7 +57,6 @@ export function registerRoutes(app: any, authMiddleware: Array<(ctx: Context, ne
|
||||
app.use(devicePublicRoutes.routes())
|
||||
app.use(ttsRoutes.routes())
|
||||
app.use(apiDocsRoutes.routes())
|
||||
app.use(petdexPublicRoutes.routes())
|
||||
|
||||
// --- Auth middleware: all routes below require authentication ---
|
||||
authMiddleware.forEach((middleware) => app.use(middleware))
|
||||
@@ -102,6 +99,4 @@ export function registerRoutes(app: any, authMiddleware: Array<(ctx: Context, ne
|
||||
app.use(mcpRoutes.routes()) // MCP management
|
||||
app.use(runtimeVersionRoutes.routes()) // Runtime and version management
|
||||
app.use(writeGateRoutes.routes()) // Hermes Agent write approval review
|
||||
app.use(petdexRoutes.routes())
|
||||
app.use(petRoutes.routes())
|
||||
}
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
import type { Server, Socket } from 'socket.io'
|
||||
import { authenticateUserToken, isAuthEnabled, type AuthenticatedUser } from '../../middleware/user-auth'
|
||||
import { userCanAccessProfile } from '../../db/hermes/users-store'
|
||||
import { logger } from '../logger'
|
||||
|
||||
const PET_STATE_NAMESPACE = '/pet-state'
|
||||
const DEFAULT_PROFILE = 'default'
|
||||
|
||||
export interface PetActivity {
|
||||
busy?: boolean
|
||||
reasoning?: boolean
|
||||
toolRunning?: boolean
|
||||
awaitingInput?: boolean
|
||||
error?: boolean
|
||||
justCompleted?: boolean
|
||||
celebrate?: boolean
|
||||
}
|
||||
|
||||
export type PetState = 'idle' | 'run' | 'review' | 'failed' | 'wave' | 'jump' | 'waiting'
|
||||
|
||||
export interface PetStateSnapshot {
|
||||
profile: string
|
||||
state: PetState
|
||||
activity: PetActivity
|
||||
updatedAt: number
|
||||
sessionId?: string
|
||||
runId?: string
|
||||
activeTools: string[]
|
||||
awaiting: Array<'approval' | 'clarify' | 'input'>
|
||||
}
|
||||
|
||||
export interface PetActivityUpdate {
|
||||
profile?: string | null
|
||||
activity: PetActivity
|
||||
replace?: boolean
|
||||
}
|
||||
|
||||
function normalizeProfile(value: unknown): string {
|
||||
const profile = typeof value === 'string' ? value.trim() : ''
|
||||
return profile || DEFAULT_PROFILE
|
||||
}
|
||||
|
||||
export function derivePetState(activity: PetActivity): PetState {
|
||||
if (activity.error) return 'failed'
|
||||
if (activity.celebrate) return 'jump'
|
||||
if (activity.justCompleted) return 'wave'
|
||||
if (activity.awaitingInput) return 'waiting'
|
||||
if (activity.toolRunning) return 'run'
|
||||
if (activity.reasoning) return 'review'
|
||||
if (activity.busy) return 'run'
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
function canAccessProfile(user: AuthenticatedUser | undefined, profile: string): boolean {
|
||||
if (!user || user.role === 'super_admin') return true
|
||||
return userCanAccessProfile(user.id, profile)
|
||||
}
|
||||
|
||||
class PetStateRegistry {
|
||||
private snapshots = new Map<string, PetStateSnapshot>()
|
||||
private runProfiles = new Map<string, string>()
|
||||
private flashTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
get(profile: string): PetStateSnapshot {
|
||||
const normalized = normalizeProfile(profile)
|
||||
return this.snapshots.get(normalized) ?? {
|
||||
profile: normalized,
|
||||
state: 'idle',
|
||||
activity: {},
|
||||
updatedAt: Date.now(),
|
||||
activeTools: [],
|
||||
awaiting: [],
|
||||
}
|
||||
}
|
||||
|
||||
update(update: PetActivityUpdate): PetStateSnapshot {
|
||||
const profile = normalizeProfile(update.profile)
|
||||
const previous = this.get(profile)
|
||||
const activity = update.replace ? { ...update.activity } : { ...previous.activity, ...update.activity }
|
||||
const snapshot: PetStateSnapshot = {
|
||||
...previous,
|
||||
profile,
|
||||
state: derivePetState(activity),
|
||||
activity,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
this.snapshots.set(profile, snapshot)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
applyRunChatEvent(profileInput: string | null | undefined, event: string, payload: Record<string, unknown>): PetStateSnapshot {
|
||||
const sessionId = stringValue(payload.session_id)
|
||||
const runId = stringValue(payload.run_id)
|
||||
const knownProfile = sessionId ? this.runProfiles.get(sessionId) : ''
|
||||
const profile = normalizeProfile(profileInput || knownProfile)
|
||||
if (sessionId) this.runProfiles.set(sessionId, profile)
|
||||
|
||||
const previous = this.get(profile)
|
||||
const tools = new Set(previous.activeTools)
|
||||
const awaiting = new Set(previous.awaiting)
|
||||
let activity: PetActivity = { ...previous.activity }
|
||||
|
||||
switch (event) {
|
||||
case 'run.queued':
|
||||
case 'run.started':
|
||||
activity = { busy: true }
|
||||
awaiting.clear()
|
||||
tools.clear()
|
||||
break
|
||||
case 'tool.started':
|
||||
activity.busy = true
|
||||
activity.toolRunning = true
|
||||
tools.add(stringValue(payload.tool_name) || stringValue(payload.toolName) || stringValue(payload.name) || 'tool')
|
||||
break
|
||||
case 'tool.completed':
|
||||
case 'tool.failed': {
|
||||
const toolName = stringValue(payload.tool_name) || stringValue(payload.toolName) || stringValue(payload.name)
|
||||
if (toolName) tools.delete(toolName)
|
||||
else tools.clear()
|
||||
activity.toolRunning = tools.size > 0
|
||||
break
|
||||
}
|
||||
case 'reasoning.delta':
|
||||
case 'thinking.delta':
|
||||
case 'reasoning.available':
|
||||
case 'moa.aggregating':
|
||||
activity.busy = true
|
||||
activity.reasoning = true
|
||||
break
|
||||
case 'message.delta':
|
||||
activity.busy = true
|
||||
activity.reasoning = false
|
||||
break
|
||||
case 'approval.requested':
|
||||
awaiting.add('approval')
|
||||
activity.busy = false
|
||||
activity.awaitingInput = true
|
||||
activity.reasoning = false
|
||||
activity.toolRunning = false
|
||||
break
|
||||
case 'clarify.requested':
|
||||
awaiting.add('clarify')
|
||||
activity.busy = false
|
||||
activity.awaitingInput = true
|
||||
activity.reasoning = false
|
||||
activity.toolRunning = false
|
||||
break
|
||||
case 'approval.resolved':
|
||||
awaiting.delete('approval')
|
||||
activity.awaitingInput = awaiting.size > 0
|
||||
break
|
||||
case 'clarify.resolved':
|
||||
awaiting.delete('clarify')
|
||||
activity.awaitingInput = awaiting.size > 0
|
||||
break
|
||||
case 'run.completed':
|
||||
activity = { justCompleted: true }
|
||||
awaiting.clear()
|
||||
tools.clear()
|
||||
this.scheduleFlashClear(profile, 'justCompleted')
|
||||
break
|
||||
case 'run.failed':
|
||||
activity = { error: true }
|
||||
awaiting.clear()
|
||||
tools.clear()
|
||||
this.scheduleFlashClear(profile, 'error')
|
||||
break
|
||||
case 'abort.completed':
|
||||
activity = {}
|
||||
awaiting.clear()
|
||||
tools.clear()
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
const snapshot: PetStateSnapshot = {
|
||||
profile,
|
||||
sessionId: sessionId || previous.sessionId,
|
||||
runId: runId || previous.runId,
|
||||
state: derivePetState(activity),
|
||||
activity,
|
||||
updatedAt: Date.now(),
|
||||
activeTools: Array.from(tools),
|
||||
awaiting: Array.from(awaiting),
|
||||
}
|
||||
this.snapshots.set(profile, snapshot)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
private scheduleFlashClear(profile: string, key: 'error' | 'justCompleted'): void {
|
||||
const existing = this.flashTimers.get(profile)
|
||||
if (existing) clearTimeout(existing)
|
||||
const timer = setTimeout(() => {
|
||||
const current = this.get(profile)
|
||||
if (!current.activity[key]) return
|
||||
const activity = { ...current.activity, [key]: false }
|
||||
const snapshot: PetStateSnapshot = {
|
||||
...current,
|
||||
state: derivePetState(activity),
|
||||
activity,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
this.snapshots.set(profile, snapshot)
|
||||
activeServer?.broadcast(snapshot)
|
||||
}, 1600)
|
||||
this.flashTimers.set(profile, timer)
|
||||
}
|
||||
}
|
||||
|
||||
const registry = new PetStateRegistry()
|
||||
let activeServer: PetStateSocketServer | null = null
|
||||
|
||||
export function getPetStateSnapshot(profile?: string | null): PetStateSnapshot {
|
||||
return registry.get(normalizeProfile(profile))
|
||||
}
|
||||
|
||||
export function publishPetActivity(update: PetActivityUpdate): PetStateSnapshot {
|
||||
const snapshot = registry.update(update)
|
||||
activeServer?.broadcast(snapshot)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
export function observeRunChatPetEvent(profile: string | null | undefined, event: string, payload: Record<string, unknown>): PetStateSnapshot {
|
||||
const snapshot = registry.applyRunChatEvent(profile, event, payload)
|
||||
activeServer?.broadcast(snapshot)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
export class PetStateSocketServer {
|
||||
private readonly nsp: ReturnType<Server['of']>
|
||||
|
||||
constructor(io: Server) {
|
||||
this.nsp = io.of(PET_STATE_NAMESPACE)
|
||||
activeServer = this
|
||||
}
|
||||
|
||||
init(): void {
|
||||
this.nsp.use(this.authMiddleware.bind(this))
|
||||
this.nsp.on('connection', this.onConnection.bind(this))
|
||||
logger.info('[pet-state-socket] Socket.IO ready at /pet-state')
|
||||
}
|
||||
|
||||
broadcast(snapshot: PetStateSnapshot): void {
|
||||
this.nsp.to(this.profileRoom(snapshot.profile)).emit('pet.state.updated', snapshot)
|
||||
}
|
||||
|
||||
private async authMiddleware(socket: Socket, next: (err?: Error) => void): Promise<void> {
|
||||
if (!await isAuthEnabled()) {
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
const token = socket.handshake.auth?.token as string | undefined
|
||||
const user = await authenticateUserToken(token || '')
|
||||
if (!user) {
|
||||
next(new Error('Authentication failed'))
|
||||
return
|
||||
}
|
||||
|
||||
const profile = normalizeProfile(socket.handshake.query?.profile)
|
||||
if (!canAccessProfile(user, profile)) {
|
||||
next(new Error('Profile access denied'))
|
||||
return
|
||||
}
|
||||
|
||||
socket.data.user = user
|
||||
socket.data.profile = profile
|
||||
next()
|
||||
}
|
||||
|
||||
private onConnection(socket: Socket): void {
|
||||
const profile = normalizeProfile(socket.data.profile)
|
||||
void socket.join(this.profileRoom(profile))
|
||||
socket.emit('pet.state.snapshot', registry.get(profile))
|
||||
|
||||
socket.on('pet.state.get', (ack?: (snapshot: PetStateSnapshot) => void) => {
|
||||
if (typeof ack === 'function') {
|
||||
ack(registry.get(profile))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private profileRoom(profile: string): string {
|
||||
return `profile:${normalizeProfile(profile)}`
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
export interface PetdexPet {
|
||||
slug: string
|
||||
displayName: string
|
||||
kind: string
|
||||
submittedBy: string
|
||||
spritesheetUrl: string
|
||||
previewUrl?: string
|
||||
petJsonUrl: string
|
||||
zipUrl: string
|
||||
}
|
||||
|
||||
export interface PetdexManifest {
|
||||
generatedAt: string
|
||||
total: number
|
||||
pets: PetdexPet[]
|
||||
}
|
||||
|
||||
const PETDEX_MANIFEST_URL = 'https://assets.petdex.dev/manifests/petdex-v1.json'
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000
|
||||
const FETCH_TIMEOUT_MS = 15_000
|
||||
const MAX_ASSET_BYTES = 10 * 1024 * 1024
|
||||
|
||||
let cache: { expiresAt: number; manifest: PetdexManifest } | null = null
|
||||
|
||||
function asString(value: unknown): string {
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
function normalizePet(value: unknown): PetdexPet | null {
|
||||
if (!value || typeof value !== 'object') return null
|
||||
const item = value as Record<string, unknown>
|
||||
const slug = asString(item.slug).trim()
|
||||
const spritesheetUrl = asString(item.spritesheetUrl).trim()
|
||||
if (!slug || !spritesheetUrl) return null
|
||||
return {
|
||||
slug,
|
||||
displayName: asString(item.displayName).trim() || slug,
|
||||
kind: asString(item.kind).trim() || 'pet',
|
||||
submittedBy: asString(item.submittedBy).trim(),
|
||||
spritesheetUrl,
|
||||
previewUrl: `/api/hermes/petdex/asset?url=${encodeURIComponent(spritesheetUrl)}`,
|
||||
petJsonUrl: asString(item.petJsonUrl).trim(),
|
||||
zipUrl: asString(item.zipUrl).trim(),
|
||||
}
|
||||
}
|
||||
|
||||
function assertPetdexAssetUrl(value: string): URL {
|
||||
const url = new URL(value)
|
||||
const host = url.hostname.toLowerCase()
|
||||
if (url.protocol !== 'https:' || (host !== 'petdex.dev' && !host.endsWith('.petdex.dev'))) {
|
||||
throw new Error('Unsupported petdex asset host')
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
function imageMimeFromResponse(response: Response, pathname: string): string {
|
||||
const contentType = response.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase()
|
||||
if (contentType?.startsWith('image/')) return contentType
|
||||
const lower = pathname.toLowerCase()
|
||||
if (lower.endsWith('.webp')) return 'image/webp'
|
||||
if (lower.endsWith('.png')) return 'image/png'
|
||||
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg'
|
||||
return 'application/octet-stream'
|
||||
}
|
||||
|
||||
function normalizeManifest(value: unknown): PetdexManifest {
|
||||
if (!value || typeof value !== 'object') {
|
||||
throw new Error('petdex manifest is not an object')
|
||||
}
|
||||
|
||||
const data = value as Record<string, unknown>
|
||||
const pets = Array.isArray(data.pets) ? data.pets.map(normalizePet).filter((pet): pet is PetdexPet => Boolean(pet)) : []
|
||||
if (pets.length === 0) {
|
||||
throw new Error('petdex manifest has no pets')
|
||||
}
|
||||
|
||||
return {
|
||||
generatedAt: asString(data.generatedAt),
|
||||
total: typeof data.total === 'number' ? data.total : pets.length,
|
||||
pets,
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchPetdexManifest(options: { force?: boolean } = {}): Promise<PetdexManifest> {
|
||||
const now = Date.now()
|
||||
if (!options.force && cache && cache.expiresAt > now) {
|
||||
return cache.manifest
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
|
||||
try {
|
||||
const response = await fetch(PETDEX_MANIFEST_URL, {
|
||||
headers: { 'User-Agent': 'hermes-ui-petdex' },
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`petdex manifest request failed: ${response.status}`)
|
||||
}
|
||||
const manifest = normalizeManifest(await response.json())
|
||||
cache = { expiresAt: now + CACHE_TTL_MS, manifest }
|
||||
return manifest
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchPetdexAsset(urlValue: string): Promise<{ buffer: Buffer; mime: string; maxAgeSeconds: number }> {
|
||||
const url = assertPetdexAssetUrl(urlValue)
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { 'User-Agent': 'hermes-ui-petdex' },
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!response.ok) throw new Error(`petdex asset request failed: ${response.status}`)
|
||||
|
||||
const length = Number(response.headers.get('content-length') || '0')
|
||||
if (Number.isFinite(length) && length > MAX_ASSET_BYTES) {
|
||||
throw new Error('petdex asset is too large')
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
if (arrayBuffer.byteLength > MAX_ASSET_BYTES) {
|
||||
throw new Error('petdex asset is too large')
|
||||
}
|
||||
|
||||
return {
|
||||
buffer: Buffer.from(arrayBuffer),
|
||||
mime: imageMimeFromResponse(response, url.pathname),
|
||||
maxAgeSeconds: 60 * 60,
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
import { existsSync } from 'fs'
|
||||
import { mkdir, readFile, writeFile } from 'fs/promises'
|
||||
import { dirname, join } from 'path'
|
||||
import { getWebUiHome } from '../../config'
|
||||
import { fetchPetdexManifest, type PetdexPet } from './petdex'
|
||||
|
||||
export type ActivePetState = 'idle' | 'run' | 'review' | 'failed' | 'wave' | 'jump' | 'waiting'
|
||||
|
||||
export interface InstalledPet {
|
||||
slug: string
|
||||
displayName: string
|
||||
kind: string
|
||||
submittedBy: string
|
||||
source: 'petdex'
|
||||
spritesheetUrl: string
|
||||
petJsonUrl: string
|
||||
zipUrl: string
|
||||
spritesheetFile: string
|
||||
petJsonFile?: string
|
||||
mime: string
|
||||
installedAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface ActivePetConfig {
|
||||
enabled: boolean
|
||||
slug: string
|
||||
scale: number
|
||||
position?: {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface ActivePetResponse {
|
||||
enabled: boolean
|
||||
slug: string
|
||||
displayName: string
|
||||
kind: string
|
||||
submittedBy: string
|
||||
source: 'petdex'
|
||||
mime: string
|
||||
spritesheetDataUrl: string
|
||||
spritesheetRevision: number
|
||||
frameW: number
|
||||
frameH: number
|
||||
framesPerState: number
|
||||
loopMs: number
|
||||
scale: number
|
||||
position?: {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
stateRows: string[]
|
||||
installedAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
const FRAME_W = 192
|
||||
const FRAME_H = 208
|
||||
const FRAMES_PER_STATE = 6
|
||||
const LOOP_MS = 1100
|
||||
const DEFAULT_SCALE = 0.33
|
||||
const STATE_ROWS = [
|
||||
'idle',
|
||||
'running-right',
|
||||
'running-left',
|
||||
'waving',
|
||||
'jumping',
|
||||
'failed',
|
||||
'waiting',
|
||||
'running',
|
||||
'review',
|
||||
]
|
||||
|
||||
const MAX_SPRITESHEET_BYTES = 10 * 1024 * 1024
|
||||
const MAX_JSON_BYTES = 512 * 1024
|
||||
const FETCH_TIMEOUT_MS = 20_000
|
||||
|
||||
function profileMetadataRoot(): string {
|
||||
return join(getWebUiHome(), 'profile-metadata')
|
||||
}
|
||||
|
||||
function profileMetadataDir(name: string): string {
|
||||
const segment = Buffer.from(name || 'default', 'utf-8').toString('base64url')
|
||||
return join(profileMetadataRoot(), segment)
|
||||
}
|
||||
|
||||
function petsRoot(profile: string): string {
|
||||
return join(profileMetadataDir(profile), 'pets')
|
||||
}
|
||||
|
||||
function activePetPath(profile: string): string {
|
||||
return join(petsRoot(profile), 'active.json')
|
||||
}
|
||||
|
||||
function petDir(profile: string, slug: string): string {
|
||||
return join(petsRoot(profile), safeSlug(slug))
|
||||
}
|
||||
|
||||
function petMetaPath(profile: string, slug: string): string {
|
||||
return join(petDir(profile, slug), 'pet.json')
|
||||
}
|
||||
|
||||
function safeSlug(slug: string): string {
|
||||
const normalized = String(slug || '').trim().toLowerCase()
|
||||
const safe = normalized.replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '')
|
||||
if (!safe) throw new Error('Pet slug is required')
|
||||
return safe.slice(0, 120)
|
||||
}
|
||||
|
||||
function assertPetdexAssetUrl(value: string): URL {
|
||||
const url = new URL(value)
|
||||
const host = url.hostname.toLowerCase()
|
||||
if (url.protocol !== 'https:' || (host !== 'petdex.dev' && !host.endsWith('.petdex.dev'))) {
|
||||
throw new Error('Unsupported pet asset host')
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
function mimeFromResponse(response: Response, fallbackUrl: string): string {
|
||||
const contentType = response.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase()
|
||||
if (contentType?.startsWith('image/')) return contentType
|
||||
if (fallbackUrl.endsWith('.webp')) return 'image/webp'
|
||||
if (fallbackUrl.endsWith('.png')) return 'image/png'
|
||||
return 'application/octet-stream'
|
||||
}
|
||||
|
||||
async function fetchBytes(urlValue: string, maxBytes: number): Promise<{ buffer: Buffer; mime: string }> {
|
||||
const url = assertPetdexAssetUrl(urlValue)
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: { 'User-Agent': 'hermes-ui-pets' },
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!response.ok) throw new Error(`Pet asset request failed: ${response.status}`)
|
||||
|
||||
const length = Number(response.headers.get('content-length') || '0')
|
||||
if (Number.isFinite(length) && length > maxBytes) {
|
||||
throw new Error('Pet asset is too large')
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
if (arrayBuffer.byteLength > maxBytes) {
|
||||
throw new Error('Pet asset is too large')
|
||||
}
|
||||
|
||||
return {
|
||||
buffer: Buffer.from(arrayBuffer),
|
||||
mime: mimeFromResponse(response, url.pathname.toLowerCase()),
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTextAsset(urlValue: string, maxBytes: number): Promise<string | null> {
|
||||
if (!urlValue) return null
|
||||
const { buffer } = await fetchBytes(urlValue, maxBytes)
|
||||
return buffer.toString('utf-8')
|
||||
}
|
||||
|
||||
async function readJsonFile<T>(filePath: string): Promise<T | null> {
|
||||
try {
|
||||
return JSON.parse(await readFile(filePath, 'utf-8')) as T
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
|
||||
await mkdir(dirname(filePath), { recursive: true })
|
||||
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf-8', mode: 0o600 })
|
||||
}
|
||||
|
||||
function normalizeInstalledPet(pet: PetdexPet, asset: { mime: string }, now: number): InstalledPet {
|
||||
return {
|
||||
slug: pet.slug,
|
||||
displayName: pet.displayName || pet.slug,
|
||||
kind: pet.kind || 'pet',
|
||||
submittedBy: pet.submittedBy || '',
|
||||
source: 'petdex',
|
||||
spritesheetUrl: pet.spritesheetUrl,
|
||||
petJsonUrl: pet.petJsonUrl,
|
||||
zipUrl: pet.zipUrl,
|
||||
spritesheetFile: 'spritesheet.webp',
|
||||
petJsonFile: pet.petJsonUrl ? 'petdex-pet.json' : undefined,
|
||||
mime: asset.mime,
|
||||
installedAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
export async function adoptPetFromPetdex(profile: string, slugInput: string): Promise<ActivePetResponse> {
|
||||
const slug = safeSlug(slugInput)
|
||||
const manifest = await fetchPetdexManifest()
|
||||
const pet = manifest.pets.find(item => safeSlug(item.slug) === slug)
|
||||
if (!pet) throw new Error(`Pet "${slugInput}" was not found in petdex`)
|
||||
|
||||
const targetDir = petDir(profile, pet.slug)
|
||||
await mkdir(targetDir, { recursive: true })
|
||||
|
||||
const spritesheet = await fetchBytes(pet.spritesheetUrl, MAX_SPRITESHEET_BYTES)
|
||||
await writeFile(join(targetDir, 'spritesheet.webp'), spritesheet.buffer, { mode: 0o600 })
|
||||
|
||||
if (pet.petJsonUrl) {
|
||||
const petJson = await fetchTextAsset(pet.petJsonUrl, MAX_JSON_BYTES)
|
||||
if (petJson) await writeFile(join(targetDir, 'petdex-pet.json'), petJson, { encoding: 'utf-8', mode: 0o600 })
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
const installed = normalizeInstalledPet(pet, spritesheet, now)
|
||||
const active: ActivePetConfig = {
|
||||
enabled: true,
|
||||
slug: installed.slug,
|
||||
scale: DEFAULT_SCALE,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
await writeJsonFile(petMetaPath(profile, pet.slug), installed)
|
||||
await writeJsonFile(activePetPath(profile), active)
|
||||
const response = await buildActivePetResponse(profile, installed, active)
|
||||
if (!response) throw new Error('Installed pet asset is missing')
|
||||
return response
|
||||
}
|
||||
|
||||
export async function getActivePet(profile: string): Promise<ActivePetResponse | null> {
|
||||
const active = await readJsonFile<ActivePetConfig>(activePetPath(profile))
|
||||
if (!active?.enabled || !active.slug) return null
|
||||
|
||||
const installed = await readJsonFile<InstalledPet>(petMetaPath(profile, active.slug))
|
||||
if (!installed) return null
|
||||
|
||||
return buildActivePetResponse(profile, installed, active)
|
||||
}
|
||||
|
||||
export async function updateActivePetPreferences(
|
||||
profile: string,
|
||||
input: { scale?: number; position?: { x?: number; y?: number }; enabled?: boolean },
|
||||
): Promise<ActivePetResponse | null> {
|
||||
const active = await readJsonFile<ActivePetConfig>(activePetPath(profile))
|
||||
if (!active?.slug) return null
|
||||
|
||||
const next: ActivePetConfig = {
|
||||
...active,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
if (typeof input.enabled === 'boolean') {
|
||||
next.enabled = input.enabled
|
||||
}
|
||||
|
||||
if (typeof input.scale === 'number' && Number.isFinite(input.scale)) {
|
||||
next.scale = Math.min(1.2, Math.max(0.18, input.scale))
|
||||
}
|
||||
|
||||
if (input.position && typeof input.position.x === 'number' && typeof input.position.y === 'number') {
|
||||
next.position = {
|
||||
x: Math.round(Math.max(0, input.position.x)),
|
||||
y: Math.round(Math.max(0, input.position.y)),
|
||||
}
|
||||
}
|
||||
|
||||
await writeJsonFile(activePetPath(profile), next)
|
||||
if (!next.enabled) return null
|
||||
return getActivePet(profile)
|
||||
}
|
||||
|
||||
async function buildActivePetResponse(
|
||||
profile: string,
|
||||
installed: InstalledPet,
|
||||
active: ActivePetConfig,
|
||||
): Promise<ActivePetResponse | null> {
|
||||
const filePath = join(petDir(profile, installed.slug), installed.spritesheetFile || 'spritesheet.webp')
|
||||
if (!existsSync(filePath)) return null
|
||||
|
||||
const data = await readFile(filePath)
|
||||
const spritesheetRevision = installed.updatedAt || installed.installedAt || 0
|
||||
const updatedAt = Math.max(active.updatedAt || 0, spritesheetRevision)
|
||||
return {
|
||||
enabled: active.enabled,
|
||||
slug: installed.slug,
|
||||
displayName: installed.displayName,
|
||||
kind: installed.kind,
|
||||
submittedBy: installed.submittedBy,
|
||||
source: installed.source,
|
||||
mime: installed.mime || 'image/webp',
|
||||
spritesheetDataUrl: `data:${installed.mime || 'image/webp'};base64,${data.toString('base64')}`,
|
||||
frameW: FRAME_W,
|
||||
frameH: FRAME_H,
|
||||
framesPerState: FRAMES_PER_STATE,
|
||||
loopMs: LOOP_MS,
|
||||
scale: typeof active.scale === 'number' && active.scale > 0 ? active.scale : DEFAULT_SCALE,
|
||||
position: active.position && Number.isFinite(active.position.x) && Number.isFinite(active.position.y)
|
||||
? { x: active.position.x, y: active.position.y }
|
||||
: undefined,
|
||||
stateRows: STATE_ROWS,
|
||||
installedAt: installed.installedAt,
|
||||
spritesheetRevision,
|
||||
updatedAt,
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,6 @@ import { markAbortCompleted } from './abort'
|
||||
import { writeModelRunProfileToken } from './model-run-prompt'
|
||||
import type { AuthenticatedUser } from '../../../middleware/user-auth'
|
||||
import { ensureHermesRunWorkspace } from './workspace'
|
||||
import { observeRunChatPetEvent } from '../pet-state-socket'
|
||||
import { completeWorkspaceRunCheckpoint, startWorkspaceRunCheckpoint } from './workspace-diff-tracker'
|
||||
|
||||
const BRIDGE_USAGE_FLUSH_DELAY_MS = 200
|
||||
@@ -462,7 +461,6 @@ export async function handleBridgeRun(
|
||||
}
|
||||
const emit = (event: string, payload: any) => {
|
||||
const tagged = { ...payload, session_id }
|
||||
observePetEvent(profile, event, tagged)
|
||||
data.onEvent?.(event, tagged)
|
||||
nsp.to(`session:${session_id}`).emit(event, tagged)
|
||||
if (!data.onEvent && !nsp.adapter.rooms.get(`session:${session_id}`)?.size && socket.connected) {
|
||||
@@ -735,7 +733,6 @@ export async function resumeBridgeRun(
|
||||
|
||||
const emit = (event: string, payload: any) => {
|
||||
const tagged = { ...payload, session_id: sessionId }
|
||||
observePetEvent(profile, event, tagged)
|
||||
nsp.to(`session:${sessionId}`).emit(event, tagged)
|
||||
if (!nsp.adapter.rooms.get(`session:${sessionId}`)?.size && socket.connected) {
|
||||
socket.emit(event, tagged)
|
||||
@@ -851,14 +848,6 @@ export async function resumeBridgeRun(
|
||||
}
|
||||
}
|
||||
|
||||
function observePetEvent(profile: string, event: string, payload: Record<string, unknown>): void {
|
||||
try {
|
||||
observeRunChatPetEvent(profile, event, payload)
|
||||
} catch (err) {
|
||||
logger.debug(err, '[chat-run-socket] failed to update pet state')
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshFinalContextUsage(args: {
|
||||
sessionId: string
|
||||
profile: string
|
||||
|
||||
@@ -24,7 +24,6 @@ import { contentBlocksToString } from './content-blocks'
|
||||
import type { ChatRunSource, ContentBlock, QueuedRun, SessionState } from './types'
|
||||
import { authenticateUserToken, isAuthEnabled, type AuthenticatedUser } from '../../../middleware/user-auth'
|
||||
import { userCanAccessProfile } from '../../../db/hermes/users-store'
|
||||
import { observeRunChatPetEvent } from '../pet-state-socket'
|
||||
|
||||
export type { ContentBlock } from './types'
|
||||
|
||||
@@ -798,8 +797,6 @@ export class ChatRunSocket {
|
||||
|
||||
emitExternalEvent(sessionId: string, event: string, payload: any) {
|
||||
const tagged = { ...payload, session_id: sessionId }
|
||||
const profile = this.resolvePetEventProfile(sessionId, tagged)
|
||||
this.observePetEvent(profile, event, tagged)
|
||||
const state = this.sessionMap.get(sessionId)
|
||||
if (state?.isWorking) {
|
||||
state.events.push({ event, data: tagged })
|
||||
@@ -932,8 +929,6 @@ export class ChatRunSocket {
|
||||
|
||||
private emitToSession(socket: Socket, sessionId: string, event: string, payload: any) {
|
||||
const tagged = { ...payload, session_id: sessionId }
|
||||
const profile = this.resolvePetEventProfile(sessionId, tagged)
|
||||
this.observePetEvent(profile, event, tagged)
|
||||
this.nsp.to(`session:${sessionId}`).emit(event, tagged)
|
||||
if (!this.nsp.adapter.rooms.get(`session:${sessionId}`)?.size && socket.connected) {
|
||||
socket.emit(event, tagged)
|
||||
@@ -968,21 +963,4 @@ export class ChatRunSocket {
|
||||
this.sessionMap.clear()
|
||||
logger.info('[chat-run-socket] closed all connections and cleared state')
|
||||
}
|
||||
|
||||
private resolvePetEventProfile(sessionId: string, payload: Record<string, unknown>): string {
|
||||
const payloadProfile = typeof payload.profile === 'string' ? payload.profile.trim() : ''
|
||||
if (payloadProfile) return payloadProfile
|
||||
const stateProfile = this.sessionMap.get(sessionId)?.profile
|
||||
if (stateProfile) return stateProfile
|
||||
const storedProfile = getSession(sessionId)?.profile
|
||||
return storedProfile || 'default'
|
||||
}
|
||||
|
||||
private observePetEvent(profile: string, event: string, payload: Record<string, unknown>): void {
|
||||
try {
|
||||
observeRunChatPetEvent(profile, event, payload)
|
||||
} catch (err) {
|
||||
logger.debug(err, '[chat-run-socket] failed to update pet state')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user