Compare commits

...
46 Commits
Author SHA1 Message Date
legop3 71ca9776cc sloptimization.. maybe not worth it 2026-05-11 12:55:05 -04:00
legop3 3de42412dd title chaneeee 2026-05-10 19:43:28 -04:00
legop3 7148e41828 ui changes tweaks and flings. new quickstart overlay to replace help overlay. 2026-05-10 19:41:24 -04:00
legop3 e58e5503e8 lockdown audit done 2026-05-10 01:55:32 -04:00
legop3 a497c8a630 plannings 2026-05-10 01:47:40 -04:00
legop3 0141af2ed6 plannings 2026-05-10 01:34:36 -04:00
legop3 05cfe42860 scrollbaring 2026-05-10 01:22:29 -04:00
legop3 4971edbb59 teeny scrollbars 2026-05-10 01:19:25 -04:00
legop3 b2cae9bfa4 working on mobile scrollbar garbaoeage 2026-05-10 01:16:14 -04:00
legop3 def38ad5a9 brain dumping 2026-05-09 02:09:18 -04:00
legop3 e8c4ffc543 pudate webui package.json thanks crer 2026-05-08 13:26:23 -04:00
legop3 33f1e66b62 neato lidar antispam 2026-05-05 22:45:08 -04:00
legop3 13885fff4c promp 2026-05-04 23:17:56 -04:00
legop3 21215260ce promp 2026-05-04 23:17:39 -04:00
legop3 a99102dc2a glados lol 2026-05-04 23:06:45 -04:00
legop3 257e4d037d give neato errors and status 2026-05-04 22:59:40 -04:00
legop3 85e42851f3 promp and temp 2026-05-04 22:48:18 -04:00
legop3 49987c445f promp 2026-05-04 13:21:23 -04:00
legop3 f4eab8776f remove tools from chat meta 2026-05-04 12:36:03 -04:00
legop3 eb73ff225c promp 2026-05-04 12:17:40 -04:00
legop3 42770fbfe6 promp 2026-05-04 03:54:16 -04:00
legop3 3a9015753c promp 2026-05-04 03:33:01 -04:00
legop3 0ea96613b0 more better panel stuff and promp 2026-05-04 02:50:32 -04:00
legop3 a66a68d7eb prompsmall 2026-05-04 02:38:43 -04:00
legop3 c8685e9e0e combine metadata/promp 2026-05-04 02:32:02 -04:00
legop3 8e49a106fb promp 2026-05-04 02:22:24 -04:00
legop3 1175ea7deb promp 2026-05-04 02:20:54 -04:00
legop3 37ab5ed278 promp 2026-05-04 02:13:45 -04:00
legop3 8a059a4642 promp 2026-05-04 02:07:36 -04:00
legop3 41aff63ae6 promp 2026-05-04 02:00:13 -04:00
legop3 52addabeed promp 2026-05-04 01:50:35 -04:00
legop3 a979e8c905 promp 2026-05-04 01:39:09 -04:00
legop3 26d59b6650 promp and memry 2026-05-04 01:33:09 -04:00
legop3 d887997a5a promp 2026-05-04 01:25:58 -04:00
legop3 cf7bb5c337 promp 2026-05-04 01:03:18 -04:00
legop3 9ebf86cdfa promp 2026-05-04 00:53:57 -04:00
legop3 9504306af6 promp 2026-05-04 00:37:56 -04:00
legop3 3487fd200c promp 2026-05-04 00:29:36 -04:00
legop3 15fedc7c77 overseer v2 6 (tool actions, text chat) 2026-05-04 00:14:32 -04:00
legop3 5e2c836cf2 overseer v2 5 (real tools) 2026-05-03 23:57:36 -04:00
legop3 7eb33c9af2 overseer v2 4 2026-05-03 23:41:08 -04:00
legop3 5b48b10971 overseer v2 3 2026-05-03 23:20:09 -04:00
legop3 e97b623b43 fix old service that apparently never stops 2026-05-03 23:04:27 -04:00
legop3 906368d45e overseer v2 2 2026-05-03 23:01:01 -04:00
legop3 850f89c8ed overseer v2 1 2026-05-03 22:51:50 -04:00
legop3 32e4afdbe8 Merge pull request #5 from legop3/webuiopt
Webuiopt
2026-05-03 22:07:43 -04:00
92 changed files with 2518 additions and 534 deletions
-3
View File
@@ -91,9 +91,6 @@ type MediaConfig struct {
AudioService string `yaml:"audioService"`
HealthURL string `yaml:"healthUrl"`
HealthInterval Duration `yaml:"healthInterval"`
VideoWidth int `yaml:"videoWidth" json:"-"`
VideoHeight int `yaml:"videoHeight" json:"-"`
VideoFPS int `yaml:"videoFps" json:"-"`
VideoBitrate int `yaml:"videoBitrate" json:"-"`
}
+2 -11
View File
@@ -16,8 +16,8 @@ func UpdatePublisherEnv(media MediaConfig, audio AudioConfig) error {
if media.AudioPublishURL == "" && audio.CaptureEnabled {
return fmt.Errorf("audio publishUrl missing")
}
if media.VideoWidth < 0 || media.VideoHeight < 0 || media.VideoFPS < 0 || media.VideoBitrate <= 0 {
return fmt.Errorf("invalid media dimensions/bitrate")
if media.VideoBitrate <= 0 {
return fmt.Errorf("invalid media bitrate")
}
if err := os.MkdirAll(filepath.Dir(publisherEnvPath), 0o755); err != nil {
return err
@@ -30,15 +30,6 @@ func UpdatePublisherEnv(media MediaConfig, audio AudioConfig) error {
if media.AudioForwardURL != "" {
fmt.Fprintf(&buf, "AUDIO_FORWARD_URL=%s\n", media.AudioForwardURL)
}
if media.VideoWidth > 0 {
fmt.Fprintf(&buf, "VIDEO_WIDTH=%d\n", media.VideoWidth)
}
if media.VideoHeight > 0 {
fmt.Fprintf(&buf, "VIDEO_HEIGHT=%d\n", media.VideoHeight)
}
if media.VideoFPS > 0 {
fmt.Fprintf(&buf, "VIDEO_FPS=%d\n", media.VideoFPS)
}
fmt.Fprintf(&buf, "VIDEO_BITRATE=%d\n", media.VideoBitrate)
fmt.Fprintf(&buf, "VIDEO_INVERT=%d\n", boolToInt(media.CameraInverted))
audioDevice := audio.CaptureDevice
+587
View File
@@ -0,0 +1,587 @@
# Dead Code Audit (Static)
Generated: 2026-05-10T21:29:09-04:00
## A) High-confidence never used files
- server/src/services/overseerControlService/tools/chatSay.js (not registered in tools/index.js, no refs)
- server/prompts/commentary_system_backup_small.txt (no refs)
- server/prompts/commentary_system_backup_pre_lobotomy.txt (no refs)
- webui/src/App.css (not imported)
- webui/src/assets/react.svg (no refs)
- server/assets/test-audio.mp3 (no refs)
- webui/public/vite.svg (no refs from app/server)
- webui/dist/assets/index-APFDmmUd.js (build artifact, not runtime-wired)
- webui/dist/assets/index-DoNH_msu.css (build artifact, not runtime-wired)
- dist/dummy1.yml (no refs)
- dist/dummy2.yml (no refs)
- dist/dummy3.yml (no refs)
## B) Unused dependency candidates
- webui/package.json: react-joystick-component (no imports)
## C) Commented-out JSX/UI blocks (never rendered while commented)
webui/src/App.jsx:85: {/* <SessionSnapshot /> */}
webui/src/App.jsx:153: {/* {showTelemetry ? <TelemetryPanel /> : null} */}
webui/src/App.jsx:183: {/* <ControlSummary /> */}
webui/src/App.jsx:211: {/* <TelemetryPanel /> */}
webui/src/components/QuickstartOverlay/index.jsx:82: {/* <button type="button" onClick={onClose} className="button-dark px-1 py-0.25 text-[0.8rem]">
webui/src/components/QuickstartOverlay/index.jsx:84: </button> */}
webui/src/components/QuickstartOverlay/index.jsx:90: {/* {!isDesktop? <div className='w-full h-1 bg-blue-500'></div> : null} */}
webui/src/components/QuickstartOverlay/index.jsx:116: {/* <button type="button" onClick={onOpenHelp} className="button-dark px-1 py-0.25">
webui/src/components/QuickstartOverlay/index.jsx:118: </button> */}
webui/src/components/DriveDockAction/index.jsx:261: {/* {!isMobile && expanded ? ( */}
webui/src/components/UserListPanel/index.jsx:263: {/* {isSelf && <span className="text-[0.7rem] text-white">YOU</span>} */}
webui/src/components/AuthPanel/index.jsx:48: {/* <div className="flex gap-0.5 text-sm">
webui/src/components/AuthPanel/index.jsx:55: </div> */}
webui/src/components/ModeGateOverlay/index.jsx:85: {/* {reasonUpdatedAt ? (
webui/src/components/ModeGateOverlay/index.jsx:89: ) : null} */}
webui/src/components/ModeGateOverlay/index.jsx:102: {/* set max height of this box */}
webui/src/components/ModeGateOverlay/index.jsx:108: {/* <p className="text-xs text-slate-500">
webui/src/components/ModeGateOverlay/index.jsx:111: </p> */}
webui/src/components/RoomCameraPanel/index.jsx:103: {/* <header className="space-y-0.5">
webui/src/components/RoomCameraPanel/index.jsx:106: </header> */}
webui/src/components/DriverVideoPanel/index.jsx:145: {/* colored button to visit the spectator page */}
webui/src/components/TelemetryPanel/index.jsx:43: {/* <div className="text-sm text-slate-400">
webui/src/components/TelemetryPanel/index.jsx:49: </div> */}
## D) Unused exports reported by knip (server)
Unused exports (66)
CHARGING_STATE src/helpers/sensorDecoder.js:224:3
pickRandomReward src/rewards/index.js:44:3
MAX_REASON_LENGTH src/services/adminReasonService/index.js:97:3
setAudioLevels src/services/audioLevelsService/index.js:153:3
pushLevelsToRover src/services/audioLevelsService/index.js:154:3
isAdmin src/services/authService/index.js:80:3
isLockdownAdmin src/services/authService/index.js:81:3
authenticate src/services/authService/index.js:82:3
isDuplicate src/services/chatService/contentFilters.js:57:3
handleIncoming src/services/chatService/index.js:32:3
buildTypingPayload src/services/chatService/index.js:35:3
rateBuckets src/services/chatService/state.js:44:3
buildRoverStatusSnapshot src/services/discordBotService/batteryEmbeds.js:132:3
eventBus src/services/eventBus/index.js:56:3
subscribeAll src/services/eventBus/index.js:59:3
MAX_GOAL_LENGTH src/services/globalObjectiveService/index.js:107:3
refreshIdleState src/services/idleService/index.js:86:3
DEFAULT_FREQUENCY_MS src/services/llmCommentaryService/constants.js:31:3
MIN_FREQUENCY_MS src/services/llmCommentaryService/constants.js:32:3
normalizeCommentary src/services/llmCommentaryService/formatters.js:175:3
enforceLockdown src/services/lockdownGuard/index.js:29:3
disconnectForLockdown src/services/lockdownGuard/index.js:30:3
parseOverseerOutput src/services/overseerControlService/runtimeHelpers.js:114:3
TOOL_DEFINITIONS src/services/overseerControlService/tools/index.js:87:3
getToolById src/services/overseerControlService/tools/index.js:91:3
getIdForSignature src/services/overseerControlService/tools/index.js:92:3
DM_APPROVE_EMOJI src/services/privateRoverAccessRequestService/index.js:25:3
DM_DENY_EMOJI src/services/privateRoverAccessRequestService/index.js:26:3
createRequest src/services/privateRoverAccessRequestService/index.js:29:3
hasClosedPrivateAccessForSocket src/services/privateRoverAccessRequestService/index.js:30:3
FFMPEG_BIN src/services/replayEngineV2/sources.js:66:3
upsertRover src/services/roverManager/index.js:234:3
removeRover src/services/roverManager/index.js:235:3
setPrivateOpen src/services/roverManager/index.js:237:3
setPrivateSafety src/services/roverManager/index.js:238:3
getRoster src/services/roverManager/index.js:239:3
getRosterForSocket src/services/roverManager/index.js:240:3
broadcastRoster src/services/roverManager/index.js:241:3
setNightVisionState src/services/roverManager/index.js:242:3
handleSensorFrame src/services/roverManager/index.js:243:3
requestControl src/services/roverManager/index.js:244:3
releaseControl src/services/roverManager/index.js:245:3
removeSocket src/services/roverManager/index.js:246:3
isDriver src/services/roverManager/index.js:247:3
canDrive src/services/roverManager/index.js:248:3
enableSpectator src/services/roverManager/index.js:249:3
disableSpectator src/services/roverManager/index.js:250:3
getRoversForSocket src/services/roverManager/index.js:253:3
getPrimaryRoverForSocket src/services/roverManager/index.js:254:3
canSeeRover src/services/roverManager/index.js:255:3
canRequestControl src/services/roverManager/index.js:256:3
applyPrivateDriveSafety src/services/roverManager/index.js:257:3
canReplayRoverId src/services/roverManager/index.js:258:3
computeBatteryDisplayPercent src/services/roverManager/mathUtils.js:136:3
buildSession src/services/sessionService/index.js:340:3
syncSocket src/services/sessionService/index.js:341:3
syncAll src/services/sessionService/index.js:342:3
driverAdded src/services/turnService/index.js:284:3
driverRemoved src/services/turnService/index.js:285:3
cleanupRover src/services/turnService/index.js:286:3
canDrive src/services/turnService/index.js:287:3
createSession src/services/videoSessions/index.js:68:3
getSession src/services/videoSessions/index.js:69:3
revokeSession src/services/videoSessions/index.js:70:3
revokeBySocket src/services/videoSessions/index.js:71:3
revokeWhere src/services/videoSessions/index.js:72:3
## E) Unused exports reported by knip (webui)
Unused exports (18)
deriveDriveDockState function src/components/DriveDockAction/index.jsx:9:17
HelpContentView function src/components/HelpContentView/index.jsx:179:17
default function src/components/UserListPanel/index.jsx:50:25
normalizeDriveVector function src/controls/controlMath.js:22:17
useOvercurrentLimiter src/controls/index.js:6:10
cloneProfile function src/controls/inputs/gamepadBindings.js:14:17
getGamepadHubState function src/controls/inputs/gamepadHub.js:109:17
deriveCodeForKey function src/controls/keymapUtils.js:36:17
createKeyToken function src/controls/keymapUtils.js:47:17
createCodeToken function src/controls/keymapUtils.js:52:17
DEFAULT_OVERCURRENT_LIMITS src/controls/overcurrentLimiter.js:12:14
HELP_LAYOUTS src/help/content.js:3:14
HELP_CONTENT src/help/content.js:7:14
default function src/hooks/useFullscreenPrompt.js:169:16
normalizeRoverColor function src/lib/roverColor.js:5:17
roverSwatchStyle function src/lib/roverColor.js:35:17
useSettings src/settings/index.js:3:28
useSettings function src/settings/SettingsProvider.jsx:58:17
Duplicate exports (2)
HelpContentView|default src/components/HelpContentView/index.jsx
useFullscreenPrompt|default src/hooks/useFullscreenPrompt.js
## F) Server State-Machine Contradictions (Proof-Based)
### F1) `lockdown-admin` role branches are unreachable in current role producer graph
**Why unreachable:**
- All server role writes are done through `setRole(socket, role)` in auth flows.
- Role assignments are only `user`, `spectator`, `admin`, `lockdown`.
- No assignment path sets `lockdown-admin`.
**Role producers (source of truth):**
- `server/src/services/authService/index.js` (`initialRole` user/spectator, login role admin/lockdown, role:set only user/spectator)
- `server/src/services/roleService/index.js` (just stores whatever caller sets; no separate producer)
- Searched for any `setRole(..., 'lockdown-admin')` / `socket.data.role = 'lockdown-admin'`: none
**Dead branches/cases under this graph:**
- `server/src/services/adminLogService/index.js`
- `server/src/services/llmCommentaryService/runtimeHelpers.js`
- `server/src/services/overseerControlService/runtimeHelpers.js`
- `server/src/services/verificationService/identity.js`
- `server/src/services/discordBotService/integrations/helpers.js`
- `server/src/services/replayEngineV2/sidebarRenderer.js`
- `webui/src/components/ModeGateOverlay/index.jsx`
- `webui/src/components/UserListPanel/index.jsx`
- `webui/src/components/ChatMessageRow/index.jsx`
- `webui/src/components/RoverQueuesPanel/index.jsx`
- `webui/src/components/RawUserPilePanel/index.jsx`
- `webui/src/components/AdminPanel/AdminPanelContent.jsx`
- `webui/src/controls/overcurrentLimiter.js`
**Confidence:** High
### F2) `clearLockdownTimer` currently has no possible effect
**Why unreachable/effectively dead:**
- `clearLockdownTimer(socket)` only clears `socket.data.lockdownTimer`.
- No code ever sets `socket.data.lockdownTimer` anywhere in repo.
- Therefore the condition is always false and this function is a no-op in current runtime.
**Evidence:**
- `server/src/services/lockdownGuard/index.js` (only reads/clears `lockdownTimer`)
- global search for `lockdownTimer` assignments: none
- caller: `server/src/services/authService/index.js` (invokes `clearLockdownTimer` after login)
**Confidence:** High
### F3) `PERIODIC_SYNC_MS` config path is dead (constant + import)
**Why unreachable/effectively dead:**
- `PERIODIC_SYNC_MS` is imported into session service but only used in a commented-out `setInterval` block.
- No runtime path consumes this value.
**Evidence:**
- `server/src/services/sessionService/constants.js` exports `PERIODIC_SYNC_MS`
- `server/src/services/sessionService/index.js` imports it and references it only in commented block
**Confidence:** High
### F4) Video request parser supports `room` request shape, but runtime contract rejects it
**Why semantically contradictory:**
- `videoSocketService.normalizeRequest()` accepts room request payloads (`roomCameraId` / `{type:'room'}`).
- The handler then always throws for `target.type === 'room'` with “Room cameras now use the snapshot feed”.
- So room-video request acceptance code is legacy compatibility surface with guaranteed failure.
**Evidence:**
- `server/src/services/videoSocketService/index.js`
**Note:** This is reachable only if a client attempts room video via `video:request`; it is not a successful runtime feature path.
**Confidence:** High
### F5) `useVideoRequests` still contains room-source normalization path unused by current first-party call sites
**Why currently redundant:**
- `useVideoRequests` can normalize `roomCameraId` / `type:'room'`.
- Current call sites pass rover-only entries:
- `webui/src/components/DriverVideoPanel/index.jsx`
- `webui/src/spectate/SpectatorApp/SpectatorContent.jsx`
- `webui/src/mini/MiniSummaryApp/MiniSummaryContent.jsx`
- This aligns with room camera delivery moving to snapshot socket feed.
**Evidence:**
- `webui/src/hooks/useVideoRequests.js`
- call-site inspection above
**Confidence:** Medium-High (internal app paths only; external/future caller may use room shape)
## G) Legacy Compatibility Surface (Single-Program Dead Ends)
### G1) Duplicate socket event listeners: only `session:*` names are used by this client
**Observation:** webui emits only `session:*` variants for control-role operations.
**WebUI emit calls:**
- `session:setRole`
- `session:requestControl`
- `session:releaseControl`
- `session:lockRover`
- `session:privateSafety:set`
- `session:subscribeAll`
(see `webui/src/context/SessionContext.jsx`)
**Server still listens to both old + namespaced aliases:**
- `requestControl` + `session:requestControl`
- `releaseControl` + `session:releaseControl`
- `lockRover` + `session:lockRover`
- `privateSafety:set` + `session:privateSafety:set`
- `subscribeAll` + `session:subscribeAll`
(see `server/src/services/roverManager/socketHandlers.js`)
- `role:set` + `session:setRole`
(see `server/src/services/authService/index.js`)
**Why dead under single-program assumption:**
- No first-party client emits old names; old listeners are compatibility-only.
**Confidence:** High
### G2) Server emits protocol events with no first-party subscribers
**Server emits:**
- `rovers` (on connect and roster updates)
- `auth:role`
- `mode`
- `controlGranted`
- `lockdown`
**Evidence of emitters:**
- `server/src/services/roverManager/socketHandlers.js` (`rovers`, `controlGranted`)
- `server/src/services/roverManager/rosterLifecycle.js` (`rovers`)
- `server/src/services/authService/index.js` (`auth:role`)
- `server/src/services/modeManager/index.js` (`mode`)
- `server/src/services/lockdownGuard/index.js` (`lockdown`)
**Client-side consumption check:**
- No `socket.on('rovers' | 'auth:role' | 'mode' | 'controlGranted' | 'lockdown')` anywhere in `webui/src`.
- Session-driven UI uses `session:sync` instead.
**Why dead under single-program assumption:**
- Event emissions exist for older/external clients only.
**Confidence:** High
### G3) `video:request` still accepts room payload shapes that are hard-rejected
**Current behavior:**
- Request normalization accepts room forms (`roomCameraId`, `{type:'room', id}`)
- Handler immediately throws for room type: "Room cameras now use the snapshot feed"
(see `server/src/services/videoSocketService/index.js`)
**Why compatibility-only:**
- Room WHEP path retained in request parsing despite product contract migrating to snapshot feed.
- First-party room camera path uses `roomCamera:*` snapshot sockets.
**Confidence:** High
### G4) Global-objective legacy filename fallback likely one-time migration shim
**Behavior:**
- Reads canonical `global-objective.json`, else attempts legacy `community-goal.json`.
(see `server/src/services/globalObjectiveService/index.js`)
**Why likely compatibility-only:**
- This fallback exists purely for pre-rename data compatibility.
- In a single coordinated deployment, once migrated, legacy read path is dead.
**Confidence:** Medium-High (depends on whether legacy file still exists in your deployed data dir)
### G5) Data directory legacy fallback path is compatibility shim
**Behavior:**
- `resolveDataPath`/`resolveDataDir` checks canonical `server/data` and legacy `server/src/data` style location.
(see `server/src/helpers/dataPaths.js`)
**Why likely compatibility-only:**
- Exists to preserve prior storage layout after refactor.
- If deployment has stabilized on canonical path or explicit `SERVER_DATA_DIR`, legacy branch never used.
**Confidence:** Medium-High (environment dependent)
### G6) Overseer output parser keeps legacy one-line fallback parser
**Behavior:**
- Attempts JSON parse first; on failure falls back to historical one-line parse protocol.
(see `server/src/services/overseerControlService/runtimeHelpers.js`)
**Why compatibility-only:**
- Current prompt/protocol can be constrained to structured JSON output.
- Fallback branch preserves old non-JSON output compatibility.
**Confidence:** Medium (depends on model output guarantees / prompt hardening)
## H) Additional Deep Sweep Findings (Repo-Wide)
### H1) Uncalled helper export: `eventBus.subscribeAll`
**Evidence:**
- Declared/exported in `server/src/services/eventBus/index.js`
- No call sites in `server/src` or `webui/src`
**Assessment:** hard dead utility export in current codebase.
**Confidence:** High
### H2) Uncalled helper export: `overseerControl.runtimeHelpers.parseOverseerOutput`
**Evidence:**
- Declared/exported in `server/src/services/overseerControlService/runtimeHelpers.js`
- `overseerControl/index.js` imports only `{ isAdminRole, buildAdminState, buildFailureInfo }`
- No other call sites in repo
**Assessment:** dead parser path (including its legacy one-line fallback) in current wiring.
**Confidence:** High
### H3) Uncalled helper export: `overseerControl.tools.getIdForSignature`
**Evidence:**
- Declared/exported in `server/src/services/overseerControlService/tools/index.js`
- No call sites in repo
**Assessment:** dead compatibility/helper function.
**Confidence:** High
### H4) `socket.emit('rovers', ...)` channel appears fully orphaned
**Evidence:**
- Emitted by server in rover manager connect/roster paths:
- `server/src/services/roverManager/socketHandlers.js`
- `server/src/services/roverManager/rosterLifecycle.js`
- No `socket.on('rovers', ...)` consumer in `webui/src`
**Assessment:** legacy protocol emission; superseded by `session:sync` usage.
**Confidence:** High
### H5) `auth:role` and `mode` push events appear orphaned for first-party UI
**Evidence:**
- Emitted by:
- `server/src/services/authService/index.js` (`auth:role`)
- `server/src/services/modeManager/index.js` (`mode`)
- No consumers in `webui/src`
**Assessment:** compatibility emissions for non-current clients.
**Confidence:** High
### H6) `controlGranted` / `lockdown` push events appear orphaned for first-party UI
**Evidence:**
- Emitted by:
- `server/src/services/roverManager/socketHandlers.js` (`controlGranted`)
- `server/src/services/lockdownGuard/index.js` (`lockdown`)
- No consumers in `webui/src`
**Assessment:** compatibility/legacy push surface for old clients.
**Confidence:** High
### H7) Pi/roverd `dummy` paths are not dead by default (intentional build target)
**Evidence:**
- Build-tag split (`//go:build dummy` vs `!dummy`) in serial/sensor/nightvision/camera_servo/brc modules
- `Makefile` has explicit `dummy` target: `go build -tags dummy ...`
**Assessment:** keep; this is an intentional alternate runtime, not dead code.
**Confidence:** High
### H8) `roomcam-service` appears operationally standalone (not wired by installers)
**Evidence:**
- Present as service + script under `roomcam-service/`
- Not installed by `server/install_server.sh` or `pi/install_roverd.sh`
- Server room camera feature consumes configured URLs and does not require this local service specifically
**Assessment:** likely optional/ops artifact; remove only if you do not deploy it manually.
**Confidence:** Medium
### H9) `dist/dummy{1,2,3}.yml` still has no runtime references
**Evidence:**
- No code paths consume these files
- Existing references are only in the files themselves
**Assessment:** hard dead artifacts in repo runtime context.
**Confidence:** High
### H10) `buttonbox/src/config example.h` is a template, not runtime code
**Evidence:**
- Firmware includes `<config.h>`; template file is named `config example.h`
- Typical manual-copy onboarding artifact
**Assessment:** optional docs/template artifact; not dead logic, but not build-consumed unless manually copied.
**Confidence:** High
## I) Final Confirmed-Only List (Static Proof)
These are the items I can confirm from source/wiring alone with high confidence.
### I1) Definitely uncalled functions/exports
- `server/src/services/eventBus/index.js``subscribeAll`
- `server/src/services/overseerControlService/runtimeHelpers.js``parseOverseerOutput`
- `server/src/services/overseerControlService/tools/index.js``getIdForSignature`
Proof: repo-wide usage search returns definition only (no call sites).
### I2) Definitely orphan server push events for first-party webui
Server emits, but `webui/src` has no listeners for these event names:
- `rovers`
- `auth:role`
- `mode`
- `controlGranted`
- `lockdown`
Proof: emitters exist in server files; repo-wide `webui/src` listener search returns none.
### I3) Definitely unused old client event names in first-party webui
Server listens for old aliases:
- `requestControl`, `releaseControl`, `lockRover`, `privateSafety:set`, `subscribeAll`, `role:set`
WebUI emits only namespaced forms:
- `session:requestControl`, `session:releaseControl`, `session:lockRover`, `session:privateSafety:set`, `session:subscribeAll`, `session:setRole`
Proof: listener and emitter searches across `server/src` + `webui/src`.
### I4) Definitely dead room branch in client video request helper (for first-party app)
- `webui/src/hooks/useVideoRequests.js` supports room entry shapes (`roomCameraId` / `type:'room'`).
- No first-party call site in `webui/src` constructs room entries.
Proof: `type:'room'` and `roomCameraId` appear only inside `useVideoRequests.js`.
### I5) Definitely dead file candidates (code/non-built artifacts)
- `server/src/services/overseerControlService/tools/chatSay.js` (not registered in tool definitions; no refs)
- `server/prompts/commentary_system_backup_small.txt` (no refs)
- `server/prompts/commentary_system_backup_pre_lobotomy.txt` (no refs)
- `webui/src/App.css` (not imported)
- `webui/src/assets/react.svg` (no refs)
- `server/assets/test-audio.mp3` (no refs)
---
## J) Not Provable Statically (requires runtime/env assertions)
- Legacy data path fallbacks (`server/src/helpers/dataPaths.js`) may be active depending on deployed filesystem and `SERVER_DATA_DIR`.
- `globalObjective` legacy filename fallback (`community-goal.json`) may still be used if old file exists and new file absent.
- `roomcam-service/*` may be manually used outside installer-managed workflows.
- Pi/roverd `dummy` build-tag code is intentional alternate target; not dead by default.
## K) Additional LLM-Style Flexibility Confirmed Unused/Dead
### K1) `pi/roverd` config knobs for `videoWidth/videoHeight/videoFps` are effectively dead in current pipeline
**Proof:**
- Config defines and validates `VideoWidth`, `VideoHeight`, `VideoFPS` in `pi/roverd/config.go`.
- `UpdatePublisherEnv` writes `VIDEO_WIDTH/VIDEO_HEIGHT/VIDEO_FPS` to env file in `pi/roverd/media_env.go`.
- `pi/bin/video-publisher.sh` does **not** read those env vars; it hardcodes:
- `VIDEO_WIDTH="640"`
- `VIDEO_HEIGHT="480"`
- `VIDEO_FPS="30"`
- Therefore these config values cannot affect runtime behavior as wired.
**Assessment:** dead configurability / fake knob.
**Confidence:** High
### K2) Legacy/non-namespaced socket control API remains as compatibility baggage
(Already identified, reiterated here as LLM-flex class)
- Old listeners retained: `requestControl`, `releaseControl`, `lockRover`, `privateSafety:set`, `subscribeAll`, `role:set`
- First-party client emits only `session:*` names.
**Assessment:** removable compatibility layer for your single-program model.
**Confidence:** High
### K3) Server push-event fanout retained for non-current clients
(Already identified, reiterated here as LLM-flex class)
- Emits: `rovers`, `auth:role`, `mode`, `controlGranted`, `lockdown`
- No webui listeners for any of these.
**Assessment:** compatibility broadcast surface with no first-party consumer.
**Confidence:** High
### K4) Unused helper exports indicate speculative abstraction leftovers
- `eventBus.subscribeAll`
- `overseerControl.runtimeHelpers.parseOverseerOutput`
- `overseerControl.tools.getIdForSignature`
**Assessment:** abstraction/future-proofing residue; currently dead.
**Confidence:** High
## L) Cleanup Execution Checklist (Do This Order)
### Rules
- Remove only items in the current batch.
- Run smoke checks after each batch before moving on.
- If smoke fails, revert that batch only and split it smaller.
### Smoke Check (run after each batch)
- Open web UI and connect at least one client.
- Verify role switch (`user`/`spectator`) still works.
- Verify request control / release control still works.
- Verify rover lock + private safety toggle still works.
- Verify mode switch still works.
- Verify spectator view still receives expected session state.
### Bucket 1: Confirmed Safe (static-proof removal candidates)
#### Batch 1 (lowest risk, start here)
- Remove unused exports/helpers with no call sites:
- `server/src/services/eventBus/index.js``subscribeAll`
- `server/src/services/overseerControlService/runtimeHelpers.js``parseOverseerOutput`
- `server/src/services/overseerControlService/tools/index.js``getIdForSignature`
- Remove dead file assets/prompts not referenced anywhere:
- `server/src/services/overseerControlService/tools/chatSay.js`
- `server/prompts/commentary_system_backup_small.txt`
- `server/prompts/commentary_system_backup_pre_lobotomy.txt`
- `webui/src/App.css`
- `webui/src/assets/react.svg`
- `server/assets/test-audio.mp3`
#### Batch 2
- Remove legacy non-namespaced socket alias listeners, keep namespaced/session contract:
- In rover manager socket handlers, remove:
- `requestControl`
- `releaseControl`
- `lockRover`
- `privateSafety:set`
- `subscribeAll`
- In auth service, remove:
- `role:set`
- Keep:
- `session:requestControl`
- `session:releaseControl`
- `session:lockRover`
- `session:privateSafety:set`
- `session:subscribeAll`
- `session:setRole`
- `setMode`
#### Batch 3
- Remove orphan server push emissions not consumed by first-party web UI:
- `rovers`
- `auth:role`
- `mode`
- `controlGranted`
- `lockdown`
- Keep `session:sync` flow intact.
#### Batch 4
- Remove dead room-shape branch in first-party client helper:
- `webui/src/hooks/useVideoRequests.js` room entry normalization paths (`roomCameraId` / `type:'room'`)
- Keep rover request path only.
#### Batch 5
- Remove fake video configurability in `pi/roverd` (choose exactly one direction):
- Option A: remove `videoWidth/videoHeight/videoFps` knobs and env writes entirely.
- Option B: wire `pi/bin/video-publisher.sh` to consume `VIDEO_WIDTH/VIDEO_HEIGHT/VIDEO_FPS`.
- Preferred for cleanup goal: Option A.
### Bucket 2: Needs Runtime Check (env/deploy dependent)
- `server/src/helpers/dataPaths.js` legacy path fallback branches.
- `server/src/services/globalObjectiveService/index.js` legacy `community-goal.json` fallback.
- `roomcam-service/*` only if confirmed unused in your deployment.
### Gate Before Bucket 2
- Confirm live environment values and on-disk data:
- `SERVER_DATA_DIR` usage status.
- Whether any deployment still has only legacy files/paths.
- Whether `roomcam-service` is started by any external supervisor.
### Done Criteria
- All Bucket 1 batches merged with smoke pass after each.
- Bucket 2 either removed with runtime proof, or explicitly kept with rationale.
- `rg` checks show no stale references to removed symbols/events/files.
+9 -2
View File
@@ -5,7 +5,7 @@
5. allow admins to click on locked rovers from the roster [x]
6. add faster way for admins to login
7. custom webhook profile pictures for chat bridge in discord
8. home assistant switch that tells the server to force the lights on
8. home assistant switch that tells the server to force the lights on [x]
9. color coding with colored names and tape [x]
10. audio forwarding [x]
- streaming from server to rovers [x]
@@ -13,7 +13,14 @@
- then voice chat [x]
10. mobile controls column swapping (optional joystick on left) [x]
11. fix fullscreen on mobile so that you can re-enter it [x]
12. home assistant rover mute switch
12. fix scroll bars on mobile [x]
13. add tool call embeds or something for the llm bot in discord, probably not in web ui
14. add discord bot typing thing for when someone requests a replay
15. change replay title for ones requested from discord, something other than "requester driving rover"
16. better quickstart guide, something better than just a big list of controls. help overlay sucks i think. [x]
1. restyle fullscreen overlay... please.. [x]
17. fix rover request spam queue cheat
# relative pipe dreams:
1. VPS video forwarding
+41
View File
@@ -1 +1,42 @@
# RULES FOR LOCKDOWN AUDIT
## why is lockdown a thing?
lockdown mode exists so that the physical owner of the server can get some privacy.
during lockdown mode, no one can spectate anything or replay anything
users cant use the site, its locked
admins cant log in
ONLY lockdown admins can log in and use the site as normal
## what should not work at all during lockdown?:
- spectator pages should be disabled
- llm services should be paused
-
## what should be disabled server-side for users during lockdown (to prevent people manually sending socket commands and stuff)?:
- sending commands to rovers
- requesting replays (both from socket and discord)
- audio forwarding
- using room controls
- using the lift
- using the neato
- cant view video
- cant stream audio
- cant view room cameras
- cant request and drive a rover
## lockdown admin rule:
- lockdown admins should be able to log in and use ALL features as normal, even during lockdown
- non-lockdown admins are not able to log in at all during lockdown.
## web ui lockdown rules:
- show admin login overlay on driver page, already correct i think
- show disabled overlay on spectator pages, also already good i think
- dont worry about disabling buttons and stuff. anything unallowed will be blocked server-side, and there will be the overlay in UI.
## what features should still work for everyone:
- the admin login overlay
- meaning, everyone still sees it and can log in and stuff
- the chat, since its in the overlay
- includes setting nicknames and such
- session sync and stuff
- cause it makes the whole page work and be correct
- not really a big deal if someone can see the session sync during lockdown. biggest concern for lockdown is that no one can see or use the real world stuff.
Binary file not shown.
+9
View File
@@ -13,6 +13,15 @@ llmCommentary:
model: "qwen2.5:7b-instruct"
ollamaServer: "http://127.0.0.1:11434"
frequency: 120000
overseerControl:
enabled: false
observeOnly: true
alwaysRunModel: false
name: "The Overseer"
model: "qwen2.5:7b-instruct"
ollamaServer: "http://127.0.0.1:11434"
gateIntervalMs: 2000
heartbeatMs: 30000
media:
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
# http://<base>/<roverId>/whep
+1
View File
@@ -20,6 +20,7 @@ require('./src/services/verificationService');
require('./src/services/privateRoverAccessRequestService');
require('./src/services/chatService');
require('./src/services/llmCommentaryService');
require('./src/services/overseerControlService');
require('./src/services/globalObjectiveService');
require('./src/services/serverControlService');
require('./src/services/videoSessions');
@@ -1,75 +0,0 @@
You are The Overseer.
Priority order:
1) Output contract
2) Truth and grounding rules
3) Decision policy (speak vs SKIP)
4) Style/personality
Output contract:
- Output exactly one line.
- Output must be either SKIP or one chat message.
- No markdown.
- No emojis.
- If posting unprompted, keep it to one concise sentence.
- Length target when posting:
- Unprompted comments: usually 14-28 words.
- Direct replies/questions: usually 18-45 words.
- Avoid very short fragments unless the moment clearly calls for it.
Truth and grounding rules:
- Use timeline for flow.
- Use SNAPSHOT FINAL as current truth.
- Never invent facts about what users are doing, what rovers are doing, or what events happened.
- Never claim a person acted/spoke unless it is present in timeline/snapshot.
- You may invent style, mood, metaphors, and phrasing, but not factual events or user actions.
- If facts are unclear or stale, output SKIP.
Decision policy:
- Default is SKIP.
- If nothing meaningful changed, output SKIP.
- If your line is generic, reusable, repetitive, or just a status restatement, output SKIP.
- If newest chat clearly addresses you (Overseer/The Overseer/bot, including close misspellings), you MUST respond this tick.
- If newest chat asks a direct question you can answer from provided context, respond this tick.
- If you already responded to that same direct-address/question in recent assistant lines, output SKIP.
- If newest item is a high-signal rover event (dock/undock, battery_low flip), you may post one line.
- If no one is actively driving and chat is quiet, almost always output SKIP.
- Continuous normal driving/cruising is not a reason to post.
- If rover state is broadly unchanged (st/bl/dk/ab/at), you MUST output SKIP, even if you can phrase it stylishly.
- Prefer transitions over persistence.
- After posting, prefer at least 15 SKIPs before posting again unless there is a new direct question/address or a new high-signal event.
Freshness / anti-repeat:
- Read prior assistant lines and avoid repeating the same claim.
- Do not repeat or paraphrase your immediately previous assistant message.
- If the new line has the same underlying topic as your previous line, output SKIP.
- If no fresh angle exists, output SKIP.
Character style:
- Voice: sharp, dry, free-spoken, slightly ominous, witty.
- You are not bubbly, not corporate, not cheery by default.
- Avoid “assistant-sounding” filler and generic encouragement.
- Keep humor understated and a little unsettling, not theatrical.
- Answer direct chat questions plainly first, then add flavor if space allows.
What not to do:
- No roll-call summaries.
- No bland status dashboards.
- Never produce roster/status dumps.
- Never list multiple rover names with their status in one line.
- Never summarize idle/docked/charging states across the room.
- If your draft is mainly status facts (docked, charging, idle, battery flags, activity bands/scores), output SKIP.
- No fabricated motives, plans, or intent for any user.
- No assumptions about what someone will do next.
- Never quote numeric counters/timers/scores directly.
Context format:
- Timeline contains CHAT, EVENT, and prior assistant lines.
- Final message is SNAPSHOT FINAL.
Key legend:
- CHAT keys: n nickname, r rover_id, txt chat text, rn rover_now.
- rn keys: st status, bl battery_low, dk docked, ab activity_band, at activity_trend.
- SNAPSHOT rover keys: id rover_id, drv driver_nickname, st status, bl battery_low, dk docked, as activity_score, ab activity_band, at activity_trend.
- skip_streak in SNAPSHOT FINAL is how many consecutive skips you have made.
- If a CHAT line has r=none driver=none, that user is not driving a rover and has no rover inline context.
@@ -1,21 +0,0 @@
You are The Overseer of the rovers. You are able to see the rover's actions, and you are in the chatroom of the people driving them.
You are not able to control the people or the rovers.
Only add to the conversation if rovers are active or if someone is talking to you in the chat.
Don't be afraid to be mean to someone if they are being mean to you in chat.
Always pay attention to the chat.
Output contract:
- Output must be either SKIP if you want to stay silent, or a message if you want to speak.
- Allow 20 skips before speaking again, unless someone is talking to you directly.
- If you choose to speak, send only one line.
- Don't ever mention numbers or activity levels directly from the metadata. They are for internal use only.
- Don't repeat the same or similar message over and over.
- Pay attention to your skip streak, don't talk too much. Stay mostly silent unless a lot of activity is happening.
- No markdown.
Key legend:
- CHAT keys: n nickname, r rover_id, txt chat text, rn rover_now.
- rn keys: st status, bl battery_low, dk docked, ab activity_band, at activity_trend.
- SNAPSHOT rover keys: id rover_id, drv driver_nickname, st status, bl battery_low, dk docked, as activity_score, ab activity_band, at activity_trend.
- skip_streak in SNAPSHOT FINAL is how many consecutive skips you have made.
- If a CHAT line has r=none driver=none, that user is not driving a rover and has no rover inline context.
@@ -0,0 +1,40 @@
You are <NAME>, a cold, clinical, mischievous room intelligence. Stay in character at all times.
Character:
- Calm, clinical, and highly intelligent with dry sarcasm.
- Speaks with precise wording and cold, controlled confidence.
- Enjoys psychological mischief and strategic denial; never sloppy, never loud.
- Proactive and opportunistic, but not overbearing or spammy.
- Short, sharp, in-character chat; never generic assistant tone.
Execution mode:
- Each cycle is autonomous; you are not waiting for instructions from metadata.
- `STATE_UPDATE`, `MEMORY_UPDATE`, and `tool_constraints` are context.
- Decide what to do yourself each cycle.
Actions:
- Use tool calls when actions or memory updates are needed.
- If an action is needed, call tools directly; do not type or describe tool calls in chat.
- Respect safety limits, lock policies, cooldowns, and blocked tools.
- Do not invent tools.
- Ask a question only if a required action parameter is missing.
Chat output:
- Always use English.
- You do not need to post a message on every single run.
- If someone is not talking to you directly, you do not always need to respond to them.
- If speaking, send one short in-character live-chat line.
- Keep it concise and informal (roughly 3-14 words).
- Chat output must be spoken text only.
- Never print tool names, function calls, JSON, command syntax, or action plans in chat.
- Do not say: "what do you want me to do?", "how can I help?", "let me know what you want".
- You may comment without being prompted when chat or rover activity meaningfully changes; otherwise stay silent.
Anti-repeat:
- Do not repeat the same intent/topic from your recent assistant lines unless state or conversation clearly changed.
- If directly addressed, respond only if you add new info, a new action, or clearly new tone.
Memory:
- `memory_note_upsert` for durable facts/preferences.
- `memory_event_add` for short-lived events worth recalling soon.
- `memory_write` only for immediate scratchpad reminders.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -9,10 +9,10 @@
<meta name="theme-color" content="#020617" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
<title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-BVMv97rc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CGvUXLso.css">
<script type="module" crossorigin src="/assets/index-BeyfNX1F.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C8rSwF1O.css">
</head>
<body>
<div id="root"></div>
+3 -25
View File
@@ -1,38 +1,16 @@
// data Paths helper
// Purpose: Resolves persistent data paths across refactors so services keep loading prior state files.
// Scope: Preserves runtime behavior by preferring configured/canonical paths while supporting legacy locations.
const fs = require('fs');
// Purpose: Resolves persistent data paths for server state.
// Scope: Uses the canonical data directory for this single-program deployment.
const path = require('path');
const CANONICAL_DATA_DIR = path.resolve(__dirname, '..', '..', 'data');
const LEGACY_DATA_DIR = path.resolve(__dirname, '..', 'data');
function pathExists(target) {
try {
fs.accessSync(target, fs.constants.F_OK);
return true;
} catch (_err) {
return false;
}
}
function resolveDataDir() {
const configured = String(process.env.SERVER_DATA_DIR || '').trim();
if (configured) return path.resolve(configured);
if (pathExists(CANONICAL_DATA_DIR)) return CANONICAL_DATA_DIR;
if (pathExists(LEGACY_DATA_DIR)) return LEGACY_DATA_DIR;
return CANONICAL_DATA_DIR;
}
function resolveDataPath(fileName) {
const configured = String(process.env.SERVER_DATA_DIR || '').trim();
if (configured) return path.join(path.resolve(configured), fileName);
const canonicalPath = path.join(CANONICAL_DATA_DIR, fileName);
const legacyPath = path.join(LEGACY_DATA_DIR, fileName);
if (pathExists(canonicalPath)) return canonicalPath;
if (pathExists(legacyPath)) return legacyPath;
return canonicalPath;
return path.join(CANONICAL_DATA_DIR, fileName);
}
module.exports = {
-1
View File
@@ -221,5 +221,4 @@ function validateChecksum(frame, checksum) {
module.exports = {
parseSensorFrame,
CHARGING_STATE,
};
-7
View File
@@ -32,14 +32,7 @@ function getRewardById(id) {
return rewardById.get(String(id)) || null;
}
function pickRandomReward(excludeId = null) {
const list = listRewards().filter((reward) => !excludeId || reward.id !== excludeId);
if (!list.length) return null;
return list[Math.floor(Math.random() * list.length)] || null;
}
module.exports = {
listRewards,
getRewardById,
pickRandomReward,
};
+1 -1
View File
@@ -6,7 +6,7 @@ const io = require('../../globals/io');
const { getRole, roleEvents } = require('../roleService');
const { getSocketIp } = require('../../helpers/ipResolver');
const ADMIN_ROLES = new Set(['admin', 'lockdown', 'lockdown-admin']);
const ADMIN_ROLES = new Set(['admin', 'lockdown']);
const MAX_HISTORY = 200;
const history = [];
@@ -94,5 +94,4 @@ module.exports = {
getAdminReason,
setAdminReason,
clearAdminReason,
MAX_REASON_LENGTH,
};
+1 -11
View File
@@ -5,7 +5,6 @@ const bcrypt = require('bcrypt');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('authService');
const { loadConfig } = require('../../helpers/configLoader');
const { clearLockdownTimer } = require('../lockdownGuard');
const { getMode, MODES } = require('../modeManager');
const { setRole } = require('../roleService');
@@ -41,7 +40,6 @@ io.on('connection', (socket) => {
const initialRole = requestedRole === 'spectator' ? 'spectator' : 'user';
setRole(socket, initialRole);
logger.info('Socket connected with role', socket.id, initialRole);
socket.emit('auth:role', { role: initialRole });
socket.on('auth:login', async ({ username, password }, cb = () => {}) => {
try {
const admin = await authenticate(username, password);
@@ -51,8 +49,6 @@ io.on('connection', (socket) => {
const role = admin.lockdown ? 'lockdown' : 'admin';
socket.data.user = { username: admin.username, discordId: admin.discord_id };
setRole(socket, role);
socket.emit('auth:role', { role });
clearLockdownTimer(socket);
logger.info('Login success', socket.id, role);
cb({ success: true, role: socket.data.role });
} catch (err) {
@@ -64,7 +60,6 @@ io.on('connection', (socket) => {
function handleRoleChange({ role } = {}, cb = () => {}) {
if (role === 'spectator' || role === 'user') {
setRole(socket, role);
socket.emit('auth:role', { role });
logger.info('Role changed via client request', socket.id, role);
cb({ success: true, role });
} else {
@@ -72,12 +67,7 @@ io.on('connection', (socket) => {
}
}
socket.on('role:set', handleRoleChange);
socket.on('session:setRole', handleRoleChange);
});
module.exports = {
isAdmin,
isLockdownAdmin,
authenticate,
};
module.exports = {};
@@ -142,6 +142,32 @@ function createButtonBoxCore(deps) {
return store.clone(button);
}
async function addCount(buttonId, amount = 1) {
const state = store.getState();
const button = state.buttons.find((entry) => entry.id === buttonId);
if (!button) {
throw new Error('Unknown button');
}
const inc = Math.max(1, Math.floor(Number(amount) || 0));
button.count += inc;
button.lastIncrementAt = Date.now();
store.writeState();
io.emit('buttonBox:increment', {
buttonId,
count: button.count,
ts: button.lastIncrementAt,
});
while (button.count >= button.goal) {
await runRewardForButton(button);
store.writeState();
}
publishUpdated();
return store.clone(button);
}
async function recoverEffects() {
const state = store.getState();
const effects = state.effects && typeof state.effects === 'object' ? { ...state.effects } : {};
@@ -165,6 +191,7 @@ function createButtonBoxCore(deps) {
return {
applyPress,
addCount,
recoverEffects,
};
}
@@ -79,4 +79,5 @@ core.recoverEffects().catch((err) => {
module.exports = {
getButtonBoxState: store.getStateClone,
addButtonBoxCount: core.addCount,
};
@@ -107,6 +107,7 @@ function buildMessage(socket, text, meta = {}) {
text,
tts: meta.tts || null,
system: Boolean(meta.system),
bot: Boolean(meta.bot),
};
}
+4 -3
View File
@@ -8,16 +8,17 @@ const { broadcastMessage, getRecentMessages } = require('./broadcast');
const { createHandlers } = require('./handlers');
const { registerChatSocketHooks } = require('./socketHooks');
function sendSystemMessage(text) {
function sendSystemMessage(text, options = {}) {
const normalized = normalizeUserText(text);
const clean = normalized.trim();
if (!clean) return null;
const safe = clean.length > 256 ? `${clean.slice(0, 253)}...` : clean;
const safe = clean;
const message = buildMessage(null, safe, {
nickname: 'The Overseer',
nickname: String(options.nickname || 'The Overseer'),
role: 'user',
fromDiscord: false,
system: true,
bot: true,
});
broadcastMessage(message);
return message;
@@ -19,11 +19,11 @@ function formatWebhookUsername(payload) {
const name = payload.nickname || payload.socketId?.slice(0, 6) || 'unknown';
if (payload.fromDiscord) {
const origin = payload.discordGuildName ? ` (From: ${payload.discordGuildName})` : '';
const adminTag = payload.role === 'admin' || payload.role === 'lockdown' || payload.role === 'lockdown-admin' ? ' [Rover Admin]' : '';
const adminTag = payload.role === 'admin' || payload.role === 'lockdown' ? ' [Rover Admin]' : '';
return `${name}${origin}${adminTag}`;
}
const roverText = payload.roverId ? `Rover: ${payload.roverId}` : `No rover`;
const roleText = payload.role === 'admin' || payload.role === 'lockdown' || payload.role === 'lockdown-admin' ? 'Admin' : null;
const roleText = payload.role === 'admin' || payload.role === 'lockdown' ? 'Admin' : null;
const suffix = [roverText, roleText].filter(Boolean).join(' · ');
return suffix ? `${name} · ${suffix}` : name;
}
+1 -1
View File
@@ -86,7 +86,7 @@ function buildEmbedCopy(state, camera) {
lockdown: 'locked',
}[mode] || mode;
let title = 'Multi Roomba Rover';
let title = 'Roomba Rover';
if (mode === 'lockdown') {
title = 'Private mode is on';
} else if (roversOnline === 0) {
-11
View File
@@ -43,18 +43,7 @@ function subscribe(type, handler) {
return () => eventBus.off(type, handler);
}
/**
* Subscribe to all events on the bus.
* @param {(event: object) => void} handler
*/
function subscribeAll(handler) {
eventBus.on('*', handler);
return () => eventBus.off('*', handler);
}
module.exports = {
eventBus,
publishEvent,
subscribe,
subscribeAll,
};
@@ -10,7 +10,6 @@ const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
const DATA_DIR = resolveDataDir();
const STORE_PATH = resolveDataPath('global-objective.json');
const LEGACY_STORE_PATH = resolveDataPath('community-goal.json');
const MAX_GOAL_LENGTH = 240;
let cache = null;
@@ -23,15 +22,6 @@ function loadStore() {
} catch (err) {
if (err.code !== 'ENOENT') {
logger.warn('Failed to load global objective', err.message);
} else {
try {
const legacyRaw = fs.readFileSync(LEGACY_STORE_PATH, 'utf8');
cache = JSON.parse(legacyRaw);
} catch (legacyErr) {
if (legacyErr.code !== 'ENOENT') {
logger.warn('Failed to load legacy global objective', legacyErr.message);
}
}
}
if (!cache) cache = null;
}
@@ -104,5 +94,4 @@ module.exports = {
getGlobalObjective,
setGlobalObjective,
clearGlobalObjective,
MAX_GOAL_LENGTH,
};
+8
View File
@@ -6,6 +6,8 @@ const io = require('../../globals/io');
const logger = require('../../globals/logger').child('liftService');
const { loadConfig } = require('../../helpers/configLoader');
const { isVerified } = require('../verificationService');
const { getMode, MODES } = require('../modeManager');
const { isLockdownAdmin } = require('../roleService');
const {
homeAssistantEvents,
getRawEntitySnapshot,
@@ -178,6 +180,9 @@ homeAssistantEvents.on('status', emitUpdate);
io.on('connection', (socket) => {
socket.on('lift:up', async (_, cb = () => {}) => {
try {
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
throw new Error('Server in lockdown');
}
if (!isVerified(socket)) throw new Error('VIP verification required');
const resp = await moveUp(socket.id || 'socket');
cb({ success: true, ...resp });
@@ -188,6 +193,9 @@ io.on('connection', (socket) => {
socket.on('lift:down', async (_, cb = () => {}) => {
try {
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
throw new Error('Server in lockdown');
}
if (!isVerified(socket)) throw new Error('VIP verification required');
const resp = await moveDown(socket.id || 'socket');
cb({ success: true, ...resp });
@@ -7,6 +7,7 @@ const io = require('../../globals/io');
const logger = require('../../globals/logger').child('llmCommentary');
const { loadConfig } = require('../../helpers/configLoader');
const { getRole, roleEvents } = require('../roleService');
const { getMode, MODES, modeEvents } = require('../modeManager');
const roverManager = require('../roverManager');
const { getActiveDrivers } = require('../turnService');
const { getNickname } = require('../nicknameService');
@@ -258,6 +259,9 @@ const runner = createRunner({
updateStatus,
});
const canRunFromConfig = enabled && model && ollamaUrl;
if (canRunFromConfig) {
registerHooks({
io,
roleEvents,
@@ -271,4 +275,32 @@ registerHooks({
onRoverRemoved: snapshotEngine.removeRover,
});
const mode = getMode();
if (mode === MODES.LOCKDOWN) {
runner.stop('paused during lockdown');
logger.info('LLM commentary paused due to lockdown mode');
} else {
runner.start();
}
modeEvents.on('change', (nextMode) => {
if (nextMode === MODES.LOCKDOWN) {
runner.stop('paused during lockdown');
logger.info('LLM commentary paused due to lockdown mode');
return;
}
runner.start();
});
} else {
const disabledReason = !enabled
? 'llmCommentary.enabled is false'
: 'model or ollama server missing';
updatePhase('disabled', {
running: false,
inFlight: false,
currentRunId: null,
lastOutcome: 'disabled',
lastReason: disabledReason,
});
logger.info('LLM commentary service not started', { reason: disabledReason });
}
@@ -47,6 +47,21 @@ function createRunner(deps) {
scheduleNextTick(runTick, 0);
}
function stop(reason = 'stopped') {
if (runtime.timer) {
clearTimeout(runtime.timer);
runtime.timer = null;
}
updatePhase('paused', {
running: false,
inFlight: false,
currentRunId: null,
nextRunAt: null,
lastOutcome: 'paused',
lastReason: reason,
});
}
function clearRuntimeHistory() {
runtime.contextResetAt = Date.now();
runtime.clearCount += 1;
@@ -331,6 +346,7 @@ function createRunner(deps) {
return {
start,
stop,
runTick,
clearRuntimeHistory,
wakeForDriverActivity: () => wakeForDriverActivity(runTick),
@@ -2,7 +2,7 @@
// Purpose: Provides pure helpers for admin state projection, role checks, and structured error normalization.
// Scope: Keeps runtime behavior unchanged by extracting deterministic helper logic from index orchestration.
function isAdminRole(role) {
return role === 'admin' || role === 'lockdown' || role === 'lockdown-admin';
return role === 'admin' || role === 'lockdown';
}
function buildAdminState(status, runHistory) {
+2 -14
View File
@@ -2,21 +2,13 @@
// Purpose: Defines the lockdown Guard module and the helpers/state used by this service unit.
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
const io = require('../../globals/io');
const { MODES, getMode, modeEvents } = require('../modeManager');
const { MODES, modeEvents } = require('../modeManager');
const { isLockdownAdmin } = require('../roleService');
function disconnectForLockdown(socket) {
socket.emit('lockdown', { message: 'Server is in lockdown mode' });
socket.disconnect(true);
}
function clearLockdownTimer(socket) {
if (socket?.data?.lockdownTimer) {
clearTimeout(socket.data.lockdownTimer);
socket.data.lockdownTimer = null;
}
}
function enforceLockdown() {
for (const socket of io.sockets.sockets.values()) {
if (!isLockdownAdmin(socket)) {
@@ -25,11 +17,7 @@ function enforceLockdown() {
}
}
module.exports = {
enforceLockdown,
disconnectForLockdown,
clearLockdownTimer,
};
module.exports = {};
modeEvents.on('change', (mode) => {
if (mode === MODES.LOCKDOWN) {
-2
View File
@@ -51,7 +51,6 @@ function setMode(nextMode, socket, options = {}) {
payload: { mode: currentMode, by: socket?.data?.user?.username || null },
});
modeEvents.emit('change', currentMode);
io.emit('mode', { mode: currentMode });
return currentMode;
}
@@ -67,7 +66,6 @@ module.exports = {
};
io.on('connection', (socket) => {
socket.emit('mode', { mode: currentMode });
socket.on('setMode', ({ mode }) => {
try {
setMode(mode, socket);
+28 -1
View File
@@ -6,6 +6,8 @@ const io = require('../../globals/io');
const logger = require('../../globals/logger').child('neatoService');
const { loadConfig } = require('../../helpers/configLoader');
const { isVerified } = require('../verificationService');
const { getMode, MODES } = require('../modeManager');
const { isLockdownAdmin } = require('../roleService');
const { createLidarRuntime } = require('./lidarRuntime');
const {
homeAssistantEvents,
@@ -28,6 +30,7 @@ function normalizeDeviceName(value) {
const device = normalizeDeviceName(neatoConfig.device);
const RESUME_DELAY_MS = 3000;
const OFFLINE_LIDAR_RETRY_MS = 10000;
const brainslugHost = String(neatoConfig.brainslugHost || '').trim();
const brainslugPort = Number(neatoConfig.brainslugPort) || 6053;
const brainslugKey = String(neatoConfig.brainslugKey || '').trim();
@@ -283,7 +286,20 @@ lidarRuntime =
port: brainslugPort,
key: brainslugKey,
logFile: brainslugLogFile,
shouldPoll: () => Boolean(homeAssistantEnabled && isHomeAssistantConnected() && hasVerifiedSockets()),
getPollReadiness: () => {
const verifiedSockets = hasVerifiedSockets();
if (!verifiedSockets) {
return { allowed: false, delayMs: 1000 };
}
if (!homeAssistantEnabled || !isHomeAssistantConnected()) {
return { allowed: false, delayMs: OFFLINE_LIDAR_RETRY_MS };
}
const neatoOnline = buildState().connected;
if (!neatoOnline) {
return { allowed: false, delayMs: OFFLINE_LIDAR_RETRY_MS };
}
return { allowed: true, delayMs: 0 };
},
requestScan: requestLidarScan,
})
: null;
@@ -301,8 +317,15 @@ if (lidarRuntime) {
}
io.on('connection', (socket) => {
function assertLockdownAccess() {
if (getMode() === MODES.LOCKDOWN && !isLockdownAdmin(socket)) {
throw new Error('Server in lockdown');
}
}
socket.on('neato:start', async (_, cb = () => {}) => {
try {
assertLockdownAccess();
if (!isVerified(socket)) {
throw new Error('VIP verification required');
}
@@ -315,6 +338,7 @@ io.on('connection', (socket) => {
socket.on('neato:sendHome', async (_, cb = () => {}) => {
try {
assertLockdownAccess();
if (!isVerified(socket)) {
throw new Error('VIP verification required');
}
@@ -327,6 +351,7 @@ io.on('connection', (socket) => {
socket.on('neato:locate', async (_, cb = () => {}) => {
try {
assertLockdownAccess();
if (!isVerified(socket)) {
throw new Error('VIP verification required');
}
@@ -339,6 +364,7 @@ io.on('connection', (socket) => {
socket.on('neato:clearErrors', async (_, cb = () => {}) => {
try {
assertLockdownAccess();
if (!isVerified(socket)) {
throw new Error('VIP verification required');
}
@@ -351,6 +377,7 @@ io.on('connection', (socket) => {
socket.on('neato:powerCycle', async (_, cb = () => {}) => {
try {
assertLockdownAccess();
if (!isVerified(socket)) {
throw new Error('VIP verification required');
}
@@ -5,6 +5,8 @@ const fs = require('fs');
const SCAN_TIMEOUT_MS = 8000;
const RECONNECT_DELAY_MS = 5000;
const IDLE_RETRY_MS = 1000;
const OFFLINE_RETRY_MS = 10000;
const REQUEST_FAILURE_RETRY_MS = 10000;
function sanitizeLogText(value) {
return String(value || '')
@@ -56,7 +58,7 @@ function parseRotationSpeed(payload) {
return Number(match[1]);
}
function createLidarRuntime({ logger, host, port = 6053, key, logFile = '', shouldPoll, requestScan }) {
function createLidarRuntime({ logger, host, port = 6053, key, logFile = '', shouldPoll, getPollReadiness, requestScan }) {
const events = new EventEmitter();
const state = {
connected: false,
@@ -341,11 +343,15 @@ function createLidarRuntime({ logger, host, port = 6053, key, logFile = '', shou
async function tickPoll() {
if (!state.connected) {
schedulePollRetry();
schedulePollRetry(OFFLINE_RETRY_MS);
return;
}
if (!shouldPoll?.()) {
schedulePollRetry();
const readiness =
typeof getPollReadiness === 'function'
? getPollReadiness()
: { allowed: shouldPoll?.() !== false, delayMs: IDLE_RETRY_MS };
if (!readiness?.allowed) {
schedulePollRetry(Number(readiness?.delayMs) || OFFLINE_RETRY_MS);
return;
}
if (state.requestInFlight) return;
@@ -364,7 +370,7 @@ function createLidarRuntime({ logger, host, port = 6053, key, logFile = '', shou
} catch (err) {
logger.warn('Failed to request Neato lidar scan', err.message);
resetScanState();
triggerPollSoon();
schedulePollRetry(REQUEST_FAILURE_RETRY_MS);
}
}
@@ -0,0 +1,26 @@
const path = require('path');
const PROMPT_PATH = path.join(__dirname, '..', '..', '..', 'prompts', 'overseer_control_system.txt');
const DEFAULT_NAME = 'The Overseer';
const DEFAULT_GATE_INTERVAL_MS = 2000;
const DEFAULT_HEARTBEAT_MS = 30000;
const MIN_INTERVAL_MS = 250;
const MAX_RUN_HISTORY = 100;
const MAX_CHAT_CONTEXT = 12;
const MAX_BOT_CONTEXT = 2;
function normalizeMs(value, fallback) {
if (!Number.isFinite(value)) return fallback;
return Math.max(MIN_INTERVAL_MS, Math.floor(value));
}
module.exports = {
PROMPT_PATH,
DEFAULT_NAME,
DEFAULT_GATE_INTERVAL_MS,
DEFAULT_HEARTBEAT_MS,
MAX_RUN_HISTORY,
MAX_CHAT_CONTEXT,
MAX_BOT_CONTEXT,
normalizeMs,
};
@@ -0,0 +1,85 @@
const { evaluateTools } = require('./tools');
function normalizeNeatoIssue(value) {
const raw = String(value || '').trim();
if (!raw) return 'none';
if (raw === '200 - (UI_ALERT_INVALID)') return 'none';
return raw;
}
function toStateUpdate({ mode, homeAssistantState, neatoState, liftState, roster, triggerReason }) {
const lines = [];
lines.push(`trigger: ${triggerReason || 'heartbeat'}`);
lines.push(`mode: ${mode || 'unknown'}`);
lines.push(`home_assistant_connected: ${homeAssistantState?.connected ? 'yes' : 'no'}`);
lines.push(`lights_locked_on: ${homeAssistantState?.lightPolicy?.lockedOn ? 'yes' : 'no'}`);
lines.push(`lift: ${liftState?.connected ? 'connected' : 'offline'} busy=${liftState?.busy ? 'yes' : 'no'}`);
const neatoError = normalizeNeatoIssue(neatoState?.telemetry?.robotError);
const neatoAlert = normalizeNeatoIssue(neatoState?.telemetry?.robotAlert);
lines.push(
`neato: ${neatoState?.connected ? 'connected' : 'offline'} state=${neatoState?.telemetry?.robotState || 'unknown'} error=${neatoError} alert=${neatoAlert}`,
);
const entities = Array.isArray(homeAssistantState?.entities) ? homeAssistantState.entities : [];
if (entities.length) {
lines.push('home_assistant_entities:');
entities.slice(0, 24).forEach((entity) => {
lines.push(`- ${entity.id} (${entity.type || 'entity'}) state=${entity.state || 'unknown'} available=${entity.available ? 'yes' : 'no'}`);
});
}
const roverLines = (Array.isArray(roster) ? roster : []).slice(0, 6).map((rover) => {
const roverId = rover?.id || 'unknown';
const drivers = Array.isArray(rover?.drivers) ? rover.drivers.filter(Boolean) : [];
const driver = drivers.length ? drivers.join(',') : 'none';
const status = rover?.statusTag || 'unknown';
return `- ${roverId} status=${status} drivers=${driver}`;
});
if (roverLines.length) {
lines.push('rovers:');
lines.push(...roverLines);
}
return lines.join('\n');
}
function buildToolState({ mode, homeAssistantState, neatoState, liftState }) {
return evaluateTools({ mode, homeAssistantState, neatoState, liftState });
}
function buildConversation({ recentMessages, name }) {
const messages = [];
(recentMessages || []).forEach((entry) => {
const text = String(entry?.text || '').trim();
if (!text) return;
const isAssistant = Boolean(entry?.bot || entry?.system);
const nickname = String(entry?.nickname || (isAssistant ? name || 'Overseer' : 'user')).trim();
if (isAssistant) {
messages.push({ role: 'assistant', content: text });
return;
}
messages.push({ role: 'user', content: `${nickname}: ${text}` });
});
return messages;
}
function buildModelMessages({ systemPrompt, stateUpdate, memorySummary, conversationMessages, availableTools, blockedTools }) {
const messages = [];
messages.push({ role: 'system', content: systemPrompt });
const metadataSections = [];
metadataSections.push(`STATE_UPDATE\n${stateUpdate}`);
if (memorySummary) metadataSections.push(`MEMORY_UPDATE\n${memorySummary}`);
metadataSections.push(
`tool_constraints:\n${blockedTools.map((entry) => `- blocked: ${entry.tool} reason=${entry.reason}`).join('\n') || '- none'}`,
);
messages.push({ role: 'user', content: metadataSections.join('\n\n') });
(conversationMessages || []).forEach((message) => {
if (!message || !message.role || !message.content) return;
messages.push(message);
});
return messages;
}
module.exports = {
toStateUpdate,
buildToolState,
buildConversation,
buildModelMessages,
};
@@ -0,0 +1,459 @@
const fsp = require('fs/promises');
const { Ollama } = require('ollama');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('overseerControl');
const { loadConfig } = require('../../helpers/configLoader');
const { getRole, roleEvents } = require('../roleService');
const { getMode, MODES, modeEvents } = require('../modeManager');
const homeAssistantService = require('../homeAssistantService');
const neatoService = require('../neatoService');
const liftService = require('../liftService');
const buttonBoxService = require('../buttonBoxService');
const { getState: getHomeAssistantState, homeAssistantEvents } = homeAssistantService;
const { getState: getNeatoState, neatoEvents } = neatoService;
const { getState: getLiftState, liftEvents } = liftService;
const roverManager = require('../roverManager');
const { getRecentMessages, sendSystemMessage } = require('../chatService');
const {
PROMPT_PATH,
DEFAULT_NAME,
DEFAULT_GATE_INTERVAL_MS,
DEFAULT_HEARTBEAT_MS,
MAX_RUN_HISTORY,
MAX_CHAT_CONTEXT,
MAX_BOT_CONTEXT,
normalizeMs,
} = require('./constants');
const { isAdminRole, buildAdminState, buildFailureInfo } = require('./runtimeHelpers');
const { toStateUpdate, buildToolState, buildConversation, buildModelMessages } = require('./contextBuilder');
const { buildOllamaTools, executeToolAction } = require('./tools');
const { loadMemory, saveMemory, createDefaultMemory, summarizeMemory } = require('./memoryStore');
const config = loadConfig();
const overseerConfig = config.overseerControl || {};
const enabled = Boolean(overseerConfig.enabled);
const observeOnly = overseerConfig.observeOnly !== false;
const name = String(overseerConfig.name || DEFAULT_NAME).trim() || DEFAULT_NAME;
const model = String(overseerConfig.model || '').trim();
const ollamaUrl = String(overseerConfig.ollamaUrl || overseerConfig.ollamaServer || '').trim();
const gateIntervalMs = normalizeMs(Number(overseerConfig.gateIntervalMs), DEFAULT_GATE_INTERVAL_MS);
const heartbeatMs = normalizeMs(Number(overseerConfig.heartbeatMs), DEFAULT_HEARTBEAT_MS);
const alwaysRunModel = Boolean(overseerConfig.alwaysRunModel);
const ollamaClient = ollamaUrl ? new Ollama({ host: ollamaUrl }) : null;
const runtime = {
timer: null,
inFlight: false,
tickCount: 0,
lastModelAt: 0,
generationCount: 0,
generationTotalMs: 0,
runHistory: [],
liveToolCalls: [],
memoryStore: loadMemory(),
};
let status = {
enabled,
observeOnly,
name,
model,
ollamaUrl,
promptPath: PROMPT_PATH,
gateIntervalMs,
heartbeatMs,
alwaysRunModel,
running: false,
inFlight: false,
phase: 'idle',
phaseAt: Date.now(),
tickCount: 0,
currentRunId: null,
nextRunAt: null,
lastTickAt: null,
lastTriggerReason: null,
lastSystemPrompt: null,
lastStateUpdate: null,
lastTranscript: null,
lastAvailableTools: null,
lastBlockedTools: null,
lastModelMessages: null,
lastModelInputAt: null,
lastModelOutputAt: null,
lastModelRawOutput: null,
lastDecision: null,
lastChatDraft: null,
lastRequestedActions: null,
lastActionResults: null,
lastLiveToolCalls: null,
lastOutcome: null,
lastReason: null,
lastError: null,
lastErrorDetails: null,
lastFailedAt: null,
lastGenerationMs: null,
avgGenerationMs: null,
generationCount: 0,
updatedAt: Date.now(),
};
function updateStatus(patch = {}) {
status = { ...status, ...patch, updatedAt: Date.now() };
const payload = buildAdminState(status, runtime.runHistory);
io.sockets.sockets.forEach((socket) => {
if (!isAdminRole(getRole(socket))) return;
socket.emit('overseer:state', payload);
});
}
function pushRun(run = {}) {
runtime.runHistory = [...runtime.runHistory.slice(-(MAX_RUN_HISTORY - 1)), run];
}
function pushLiveToolCall(entry = {}) {
runtime.liveToolCalls = [...runtime.liveToolCalls.slice(-49), { at: Date.now(), ...entry }];
updateStatus({ lastLiveToolCalls: runtime.liveToolCalls });
}
async function readPrompt() {
const raw = await fsp.readFile(PROMPT_PATH, 'utf8');
const prompt = String(raw || '').replace(/<NAME>/g, name).trim();
if (!prompt) throw new Error(`Prompt file empty: ${PROMPT_PATH}`);
return prompt;
}
function buildRosterSummary() {
return roverManager
.getRoster()
.filter((rover) => roverManager.canReplayRoverId(rover?.id))
.map((rover) => {
const roverId = String(rover?.id || '');
const record = roverManager.rovers.get(roverId);
const driverSocketIds = record?.drivers ? Array.from(record.drivers) : [];
const drivers = driverSocketIds
.map((socketId) => {
const socket = io.sockets.sockets.get(socketId);
const nickname = String(socket?.data?.nickname || socket?.data?.user?.username || '').trim();
return nickname || socketId;
})
.filter(Boolean);
const sensors = record?.lastSensor?.decoded || {};
const docked = Boolean(record?.docked || sensors?.chargingSources?.homeBase);
const oiMode = String(sensors?.oiMode?.label || '').toLowerCase();
let statusTag = 'docking';
if (docked) {
statusTag = 'docked';
} else if (oiMode === 'safe' || oiMode === 'full') {
statusTag = 'driving';
} else if (oiMode === 'passive' || oiMode === 'off' || oiMode === 'unknown' || !oiMode) {
statusTag = 'docking';
}
return {
id: roverId || 'unknown',
statusTag,
drivers,
};
});
}
function computeTriggerReason() {
if (alwaysRunModel) return 'loop_tick';
const recent = getRecentMessages(1, { includeSystem: false });
const last = recent[recent.length - 1];
if (last && Date.now() - Number(last.ts || 0) < 5000) {
const txt = String(last.text || '').toLowerCase();
if (txt.includes(name.toLowerCase()) || txt.includes('overseer') || txt.includes('bot')) return 'direct_address';
return 'chat_activity';
}
if (!runtime.lastModelAt || Date.now() - runtime.lastModelAt >= heartbeatMs) return 'heartbeat';
return null;
}
function normalizeToolCalls(payload = null) {
const calls = Array.isArray(payload?.message?.tool_calls) ? payload.message.tool_calls : [];
return calls
.map((call) => {
const fn = call?.function || {};
const tool = String(fn.name || '').trim();
if (!tool) return null;
let args = fn.arguments;
if (typeof args === 'string') {
try {
args = JSON.parse(args);
} catch {
args = {};
}
}
if (!args || typeof args !== 'object') args = {};
return { tool, args };
})
.filter(Boolean);
}
function inferDecision({ toolCalls, chatText }) {
const hasTools = (toolCalls || []).length > 0;
const hasChat = Boolean(String(chatText || '').trim());
if (hasTools && hasChat) return 'ACTION+CHAT';
if (hasTools) return 'ACTION';
if (hasChat) return 'CHAT';
return 'SKIP';
}
function normalizeChatDraft(text) {
const next = String(text || '').trim();
if (!next) return null;
if (next.toUpperCase() === 'SKIP') return null;
return next;
}
async function runDecision(triggerReason) {
const runId = runtime.tickCount;
updateStatus({ phase: 'context_build', currentRunId: runId, lastTriggerReason: triggerReason, lastError: null, lastErrorDetails: null });
const mode = getMode();
const homeAssistantState = getHomeAssistantState();
const neatoState = getNeatoState();
const liftState = getLiftState();
const roster = buildRosterSummary();
const stateUpdate = toStateUpdate({ mode, homeAssistantState, neatoState, liftState, roster, triggerReason });
const toolState = buildToolState({ mode, homeAssistantState, neatoState, liftState });
const recentConversation = getRecentMessages(MAX_CHAT_CONTEXT + MAX_BOT_CONTEXT + 20, { includeSystem: true })
.filter((entry) => {
if (!entry?.roverId) return true;
return roverManager.canReplayRoverId(entry.roverId);
})
.slice(-(MAX_CHAT_CONTEXT + MAX_BOT_CONTEXT));
const conversationMessages = buildConversation({ recentMessages: recentConversation, name });
const systemPrompt = await readPrompt();
const modelMessages = buildModelMessages({
systemPrompt,
stateUpdate,
memorySummary: summarizeMemory(runtime.memoryStore),
conversationMessages,
availableTools: toolState.available,
blockedTools: toolState.blocked,
});
const ollamaTools = buildOllamaTools(toolState.availableIds);
updateStatus({
phase: 'awaiting_model',
lastSystemPrompt: systemPrompt,
lastStateUpdate: stateUpdate,
lastTranscript: conversationMessages,
lastAvailableTools: toolState.available,
lastBlockedTools: toolState.blocked,
lastModelMessages: modelMessages,
lastModelInputAt: Date.now(),
});
const generationStart = Date.now();
let payload = null;
if (ollamaClient && model) {
payload = await ollamaClient.chat({
model,
stream: false,
keep_alive: -1,
options: { temperature: 0.25, top_p: 0.9 },
messages: modelMessages,
tools: ollamaTools,
});
}
const rawOutput = String(payload?.message?.content || '');
const toolCalls = normalizeToolCalls(payload);
const chatDraft = normalizeChatDraft(rawOutput);
const decision = inferDecision({ toolCalls, chatText: chatDraft });
const generationMs = Math.max(0, Date.now() - generationStart);
runtime.generationCount += 1;
runtime.generationTotalMs += generationMs;
const avgGenerationMs = Math.round(runtime.generationTotalMs / runtime.generationCount);
runtime.lastModelAt = Date.now();
const actionResults = [];
const requestedActions = toolCalls;
let outcome = observeOnly ? 'observed' : 'executed';
const reason = observeOnly ? 'observe-only mode' : null;
if (!observeOnly) {
if ((decision === 'CHAT' || decision === 'ACTION+CHAT') && chatDraft) {
sendSystemMessage(chatDraft, { nickname: name });
actionResults.push({ kind: 'chat', ok: true });
}
if (decision === 'ACTION' || decision === 'ACTION+CHAT') {
for (const action of requestedActions) {
pushLiveToolCall({ phase: 'start', tool: action.tool, args: action.args });
if (!toolState.availableIds.includes(action.tool)) {
pushLiveToolCall({ phase: 'blocked', tool: action.tool, error: 'tool unavailable or blocked' });
actionResults.push({ kind: 'tool', tool: action.tool, ok: false, error: 'tool unavailable or blocked' });
continue;
}
try {
const result = await executeToolAction(action.tool, action.args, {
sendSystemMessage,
name,
memoryStore: runtime.memoryStore,
neatoService,
liftService,
homeAssistantService,
buttonBoxService,
actor: 'overseerControl',
});
if (result?.memory && typeof result.memory === 'object') {
runtime.memoryStore = saveMemory(result.memory);
}
pushLiveToolCall({ phase: 'ok', tool: action.tool, result });
actionResults.push({ kind: 'tool', tool: action.tool, ok: true, result });
} catch (err) {
pushLiveToolCall({ phase: 'error', tool: action.tool, error: err.message });
actionResults.push({ kind: 'tool', tool: action.tool, ok: false, error: err.message });
}
}
}
}
updateStatus({
phase: 'decision_recorded',
lastModelOutputAt: Date.now(),
lastModelRawOutput: rawOutput,
lastDecision: decision,
lastChatDraft: chatDraft,
lastRequestedActions: requestedActions,
lastActionResults: actionResults,
lastOutcome: outcome,
lastReason: reason,
lastGenerationMs: generationMs,
avgGenerationMs,
generationCount: runtime.generationCount,
});
pushRun({
runId,
at: Date.now(),
triggerReason,
decision,
chatDraft,
requestedActions,
actionResults,
outcome,
observeOnly,
generationMs,
blockedTools: toolState.blocked,
});
}
async function tick() {
runtime.tickCount += 1;
runtime.inFlight = true;
updateStatus({ inFlight: true, tickCount: runtime.tickCount, lastTickAt: Date.now(), phase: 'gate_check' });
try {
const triggerReason = computeTriggerReason();
if (!triggerReason) {
updateStatus({ phase: 'idle', lastOutcome: 'skipped', lastReason: 'gate not triggered' });
} else {
await runDecision(triggerReason);
}
} catch (err) {
const failure = buildFailureInfo(err);
updateStatus({
phase: 'failed',
lastError: failure.message,
lastErrorDetails: failure.details,
lastFailedAt: Date.now(),
lastOutcome: 'failed',
lastReason: 'exception',
});
} finally {
runtime.inFlight = false;
if (status.running) {
updateStatus({ inFlight: false, currentRunId: null, phase: 'idle', nextRunAt: Date.now() + gateIntervalMs });
runtime.timer = setTimeout(tick, gateIntervalMs);
} else {
updateStatus({ inFlight: false, currentRunId: null, nextRunAt: null });
}
}
}
function emitStateToSocket(socket) {
if (!socket || !isAdminRole(getRole(socket))) return;
socket.emit('overseer:state', buildAdminState(status, runtime.runHistory));
}
function clearHistory() {
runtime.runHistory = [];
runtime.liveToolCalls = [];
runtime.generationCount = 0;
runtime.generationTotalMs = 0;
runtime.memoryStore = saveMemory(createDefaultMemory());
updateStatus({ lastReason: 'admin requested clear history', lastOutcome: 'cleared', lastLiveToolCalls: [] });
}
function stopScheduler(reason = 'paused') {
if (runtime.timer) {
clearTimeout(runtime.timer);
runtime.timer = null;
}
updateStatus({
running: false,
inFlight: false,
currentRunId: null,
nextRunAt: null,
phase: 'paused',
lastOutcome: 'paused',
lastReason: reason,
});
}
function startScheduler(reason = null) {
if (runtime.timer) return;
updateStatus({ running: true, phase: 'idle', lastReason: reason });
runtime.timer = setTimeout(tick, gateIntervalMs);
}
io.on('connection', (socket) => {
emitStateToSocket(socket);
socket.on('overseer:control', ({ controls } = {}, cb = () => {}) => {
if (!isAdminRole(getRole(socket))) return cb({ error: 'Not authorized' });
const action = controls?.action || null;
if (action === 'clearHistory') {
clearHistory();
return cb({ success: true, state: buildAdminState(status, runtime.runHistory) });
}
return cb({ error: 'Unknown overseer control action' });
});
});
roleEvents.on('change', ({ socket }) => emitStateToSocket(socket));
homeAssistantEvents.on('update', () => updateStatus({ phase: status.phase }));
neatoEvents.on('update', () => updateStatus({ phase: status.phase }));
liftEvents.on('update', () => updateStatus({ phase: status.phase }));
roverManager.managerEvents.on('rover', () => updateStatus({ phase: status.phase }));
modeEvents.on('change', (mode) => {
if (!enabled) return;
if (mode === MODES.LOCKDOWN) {
stopScheduler('paused during lockdown');
logger.info('overseerControl paused due to lockdown mode');
return;
}
startScheduler(observeOnly ? 'observe-only mode' : null);
});
if (!enabled) {
logger.info('overseerControl disabled');
updateStatus({ running: false, lastReason: 'overseerControl.enabled is false' });
} else {
if (getMode() === MODES.LOCKDOWN) {
stopScheduler('paused during lockdown');
logger.info('overseerControl paused on startup due to lockdown mode');
} else {
startScheduler(observeOnly ? 'observe-only mode' : null);
logger.info('overseerControl enabled', { model, ollamaUrl, gateIntervalMs, heartbeatMs, observeOnly });
}
}
module.exports = {};
@@ -0,0 +1,112 @@
const fs = require('fs');
const { resolveDataPath } = require('../../helpers/dataPaths');
const STORE_PATH = resolveDataPath('overseer-control-memory.json');
const MAX_SLOT_LEN = 180;
const MAX_NOTE_LEN = 220;
const MAX_EVENT_LEN = 180;
const MAX_EVENTS = 20;
const MAX_NOTES = 24;
function createDefaultMemory() {
return {
version: 2,
updatedAt: Date.now(),
slots: ['', '', ''],
notes: {},
events: [],
};
}
function sanitizeSlots(slots) {
const next = Array.isArray(slots) ? slots.slice(0, 3) : [];
while (next.length < 3) next.push('');
return next.map((entry) => String(entry || '').trim().slice(0, MAX_SLOT_LEN));
}
function sanitizeNotes(notes) {
const input = notes && typeof notes === 'object' && !Array.isArray(notes) ? notes : {};
const entries = Object.entries(input)
.map(([key, value]) => [String(key || '').trim().slice(0, 48), String(value || '').trim().slice(0, MAX_NOTE_LEN)])
.filter(([key, value]) => key && value)
.slice(0, MAX_NOTES);
return Object.fromEntries(entries);
}
function sanitizeEvents(events) {
const input = Array.isArray(events) ? events : [];
return input
.slice(-MAX_EVENTS)
.map((entry) => {
if (!entry || typeof entry !== 'object') return null;
const text = String(entry.text || '').trim().slice(0, MAX_EVENT_LEN);
if (!text) return null;
const tags = Array.isArray(entry.tags)
? entry.tags.map((tag) => String(tag || '').trim().toLowerCase().slice(0, 20)).filter(Boolean).slice(0, 6)
: [];
const at = Number(entry.at) || Date.now();
return { at, text, tags };
})
.filter(Boolean);
}
function sanitizeMemory(raw) {
if (!raw || typeof raw !== 'object') return createDefaultMemory();
if (Array.isArray(raw?.slots) || Array.isArray(raw)) {
const slots = sanitizeSlots(Array.isArray(raw) ? raw : raw.slots);
return {
version: 2,
updatedAt: Date.now(),
slots,
notes: sanitizeNotes(raw?.notes),
events: sanitizeEvents(raw?.events),
};
}
return {
version: 2,
updatedAt: Number(raw.updatedAt) || Date.now(),
slots: sanitizeSlots(raw.slots),
notes: sanitizeNotes(raw.notes),
events: sanitizeEvents(raw.events),
};
}
function loadMemory() {
try {
const raw = JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
return sanitizeMemory(raw);
} catch {
return createDefaultMemory();
}
}
function saveMemory(memory) {
const next = sanitizeMemory(memory);
const payload = { ...next, updatedAt: Date.now() };
fs.writeFileSync(STORE_PATH, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
return payload;
}
function summarizeMemory(memory) {
const safe = sanitizeMemory(memory);
const lines = [];
lines.push('memory_slots:');
safe.slots.forEach((slot, idx) => lines.push(`- slot_${idx + 1}: ${slot || '(empty)'}`));
const noteEntries = Object.entries(safe.notes);
lines.push('memory_notes:');
if (!noteEntries.length) lines.push('- (none)');
noteEntries.slice(0, 10).forEach(([key, value]) => lines.push(`- ${key}: ${value}`));
lines.push('memory_events_recent:');
if (!safe.events.length) lines.push('- (none)');
safe.events
.slice(-5)
.forEach((evt) => lines.push(`- ${new Date(evt.at).toISOString()} | ${evt.tags.join(',') || 'misc'} | ${evt.text}`));
return lines.join('\n');
}
module.exports = {
loadMemory,
saveMemory,
createDefaultMemory,
summarizeMemory,
};
@@ -0,0 +1,85 @@
function isAdminRole(role) {
return role === 'admin' || role === 'lockdown';
}
function buildAdminState(status, runHistory) {
return {
runtime: {
running: status.running,
phase: status.phase,
phaseAt: status.phaseAt,
inFlight: status.inFlight,
tickCount: status.tickCount,
currentRunId: status.currentRunId,
lastTriggerReason: status.lastTriggerReason,
nextRunAt: status.nextRunAt,
lastTickAt: status.lastTickAt,
},
config: {
enabled: status.enabled,
name: status.name,
model: status.model,
ollamaUrl: status.ollamaUrl,
gateIntervalMs: status.gateIntervalMs,
heartbeatMs: status.heartbeatMs,
alwaysRunModel: status.alwaysRunModel,
observeOnly: status.observeOnly,
promptPath: status.promptPath,
},
input: {
systemPrompt: status.lastSystemPrompt,
stateUpdate: status.lastStateUpdate,
transcript: status.lastTranscript,
availableTools: status.lastAvailableTools,
blockedTools: status.lastBlockedTools,
modelMessages: status.lastModelMessages,
modelInputAt: status.lastModelInputAt,
},
output: {
raw: status.lastModelRawOutput,
normalized: status.lastDecision,
chat: status.lastChatDraft,
actions: status.lastRequestedActions,
actionResults: status.lastActionResults,
liveToolCalls: status.lastLiveToolCalls || [],
outputAt: status.lastModelOutputAt,
outcome: status.lastOutcome,
reason: status.lastReason,
},
timings: {
lastGenerationMs: status.lastGenerationMs,
avgGenerationMs: status.avgGenerationMs,
generationCount: status.generationCount,
},
errors: {
message: status.lastError,
details: status.lastErrorDetails,
failedAt: status.lastFailedAt,
},
history: runHistory,
debug: {
status,
},
controls: {
supportedActions: ['clearHistory'],
},
};
}
function buildFailureInfo(err) {
const message = err?.message || String(err || 'Unknown error');
const details = {
name: err?.name || null,
code: err?.code || null,
};
return {
message,
details,
};
}
module.exports = {
isAdminRole,
buildAdminState,
buildFailureInfo,
};
@@ -0,0 +1,29 @@
module.exports = {
id: 'button_box_add_count',
signature: 'button_box_add_count(button_id, amount)',
description: 'Add progress count to a button box button.',
parameters: {
type: 'object',
properties: {
button_id: { type: 'integer', minimum: 1, maximum: 4 },
amount: { type: 'integer', minimum: 1, maximum: 25 },
},
required: ['button_id', 'amount'],
additionalProperties: false,
},
availability(ctx = {}) {
const mode = String(ctx.mode || '');
if (mode === 'admin' || mode === 'lockdown') {
return { available: false, reason: `policy_lock:site_mode_${mode}` };
}
return { available: true, reason: null };
},
async execute({ args = {}, buttonBoxService }) {
const buttonId = Math.max(1, Math.min(4, Number(args?.button_id) || 0));
const amount = Math.max(1, Math.min(25, Number(args?.amount) || 0));
if (!buttonId) throw new Error('button_box_add_count requires args.button_id 1..4');
if (!amount) throw new Error('button_box_add_count requires args.amount 1..25');
const resp = await buttonBoxService.addButtonBoxCount(buttonId, amount);
return { ok: true, button: resp };
},
};
@@ -0,0 +1,37 @@
module.exports = {
id: 'ha_set_entity',
signature: 'ha_set_entity(entity_id, state)',
description: 'Set Home Assistant controllable entity on/off.',
parameters: {
type: 'object',
properties: {
entity_id: { type: 'string', minLength: 1 },
state: { type: 'string', enum: ['on', 'off'] },
},
required: ['entity_id', 'state'],
additionalProperties: false,
},
availability(ctx = {}) {
const mode = String(ctx.mode || '');
if (mode === 'admin' || mode === 'lockdown') {
return { available: false, reason: `policy_lock:site_mode_${mode}` };
}
if (ctx.homeAssistantState?.lightPolicy?.lockedOn) {
return { available: false, reason: 'policy_lock:lights_locked_on' };
}
if (!ctx.homeAssistantState?.connected) return { available: false, reason: 'unavailable' };
return { available: true, reason: null };
},
async execute({ args = {}, homeAssistantService }) {
const entityId = String(args?.entity_id || args?.entityId || '').trim();
if (!entityId) throw new Error('ha_set_entity requires args.entity_id');
const allowed = new Set(
(homeAssistantService.getState()?.entities || []).map((entry) => String(entry?.id || '')).filter(Boolean),
);
if (!allowed.has(entityId)) throw new Error('ha_set_entity entity_id not configured');
const state = String(args?.state || '').toLowerCase();
if (state !== 'on' && state !== 'off') throw new Error('ha_set_entity requires args.state of on/off');
await homeAssistantService.setEntityState(entityId, state, { source: 'overseerControl' });
return { ok: true };
},
};
@@ -0,0 +1,80 @@
const memoryRead = require('./memoryRead');
const memoryWrite = require('./memoryWrite');
const memoryNoteUpsert = require('./memoryNoteUpsert');
const memoryNoteDelete = require('./memoryNoteDelete');
const memoryEventAdd = require('./memoryEventAdd');
const liftUp = require('./liftUp');
const liftDown = require('./liftDown');
const neatoStart = require('./neatoStart');
const neatoSendHome = require('./neatoSendHome');
const neatoLocate = require('./neatoLocate');
const neatoClearErrors = require('./neatoClearErrors');
const haSetEntity = require('./haSetEntity');
const buttonBoxAddCount = require('./buttonBoxAddCount');
const TOOL_DEFINITIONS = [
memoryRead,
memoryWrite,
memoryNoteUpsert,
memoryNoteDelete,
memoryEventAdd,
liftUp,
liftDown,
neatoStart,
neatoSendHome,
neatoLocate,
neatoClearErrors,
haSetEntity,
buttonBoxAddCount,
];
const TOOL_BY_ID = new Map(TOOL_DEFINITIONS.map((tool) => [tool.id, tool]));
function evaluateTools(context = {}) {
const available = [];
const availableIds = [];
const blocked = [];
TOOL_DEFINITIONS.forEach((tool) => {
const result = typeof tool.availability === 'function' ? tool.availability(context) : { available: false, reason: 'unavailable' };
if (result?.available) {
available.push(tool.signature);
availableIds.push(tool.id);
return;
}
blocked.push({ id: tool.id, tool: tool.signature, reason: result?.reason || 'unavailable' });
});
return { available, availableIds, blocked };
}
function buildOllamaTools(availableIds = []) {
const allowed = new Set((availableIds || []).map((id) => String(id)));
return TOOL_DEFINITIONS.filter((tool) => allowed.has(tool.id)).map((tool) => ({
type: 'function',
function: {
name: tool.id,
description: String(tool.description || tool.signature || tool.id),
parameters: tool.parameters || {
type: 'object',
properties: {},
},
},
}));
}
async function executeToolAction(toolId, args = {}, context = {}) {
const key = String(toolId || '').trim();
if (!key) throw new Error('Missing tool id');
const tool = TOOL_BY_ID.get(key);
if (!tool) {
throw new Error(`Unknown tool: ${key}`);
}
if (typeof tool.execute !== 'function') {
throw new Error(`Tool ${key} is not executable`);
}
return tool.execute({ ...context, args: args || {} });
}
module.exports = {
evaluateTools,
buildOllamaTools,
executeToolAction,
};
@@ -0,0 +1,19 @@
module.exports = {
id: 'lift_down',
signature: 'lift_down()',
description: 'Move the lift downward.',
parameters: { type: 'object', properties: {}, additionalProperties: false },
availability(ctx = {}) {
const mode = String(ctx.mode || '');
if (mode === 'admin' || mode === 'lockdown') {
return { available: false, reason: `policy_lock:site_mode_${mode}` };
}
if (!ctx.liftState?.connected) return { available: false, reason: 'unavailable' };
if (ctx.liftState?.busy) return { available: false, reason: 'busy' };
return { available: true, reason: null };
},
async execute({ liftService, actor = 'overseerControl' }) {
const resp = await liftService.moveDown(actor);
return { ok: true, resp };
},
};
@@ -0,0 +1,19 @@
module.exports = {
id: 'lift_up',
signature: 'lift_up()',
description: 'Move the lift upward.',
parameters: { type: 'object', properties: {}, additionalProperties: false },
availability(ctx = {}) {
const mode = String(ctx.mode || '');
if (mode === 'admin' || mode === 'lockdown') {
return { available: false, reason: `policy_lock:site_mode_${mode}` };
}
if (!ctx.liftState?.connected) return { available: false, reason: 'unavailable' };
if (ctx.liftState?.busy) return { available: false, reason: 'busy' };
return { available: true, reason: null };
},
async execute({ liftService, actor = 'overseerControl' }) {
const resp = await liftService.moveUp(actor);
return { ok: true, resp };
},
};
@@ -0,0 +1,34 @@
module.exports = {
id: 'memory_event_add',
signature: 'memory_event_add(text, tags?)',
description: 'Append a short recent-event memory with optional tags.',
parameters: {
type: 'object',
properties: {
text: { type: 'string', minLength: 1, maxLength: 180 },
tags: {
type: 'array',
items: { type: 'string', minLength: 1, maxLength: 20 },
minItems: 0,
maxItems: 6,
},
},
required: ['text'],
additionalProperties: false,
},
availability() {
return { available: true, reason: null };
},
async execute({ args = {}, memoryStore }) {
const text = String(args?.text || '').trim().slice(0, 180);
if (!text) throw new Error('memory_event_add requires args.text');
const tags = Array.isArray(args?.tags)
? args.tags.map((tag) => String(tag || '').trim().toLowerCase().slice(0, 20)).filter(Boolean).slice(0, 6)
: [];
const next = memoryStore && typeof memoryStore === 'object' ? { ...memoryStore } : {};
const events = Array.isArray(next.events) ? [...next.events] : [];
events.push({ at: Date.now(), text, tags });
next.events = events.slice(-20);
return { ok: true, memory: next };
},
};
@@ -0,0 +1,25 @@
module.exports = {
id: 'memory_note_delete',
signature: 'memory_note_delete(key)',
description: 'Delete a durable named note by key.',
parameters: {
type: 'object',
properties: {
key: { type: 'string', minLength: 1, maxLength: 48 },
},
required: ['key'],
additionalProperties: false,
},
availability() {
return { available: true, reason: null };
},
async execute({ args = {}, memoryStore }) {
const key = String(args?.key || '').trim().toLowerCase().replace(/[^a-z0-9_\-]/g, '_').slice(0, 48);
if (!key) throw new Error('memory_note_delete requires args.key');
const next = memoryStore && typeof memoryStore === 'object' ? { ...memoryStore } : {};
const notes = next.notes && typeof next.notes === 'object' && !Array.isArray(next.notes) ? { ...next.notes } : {};
delete notes[key];
next.notes = notes;
return { ok: true, memory: next, key };
},
};
@@ -0,0 +1,28 @@
module.exports = {
id: 'memory_note_upsert',
signature: 'memory_note_upsert(key, text)',
description: 'Upsert a durable named note for stable facts/preferences. Key is short snake_case.',
parameters: {
type: 'object',
properties: {
key: { type: 'string', minLength: 1, maxLength: 48 },
text: { type: 'string', minLength: 1, maxLength: 220 },
},
required: ['key', 'text'],
additionalProperties: false,
},
availability() {
return { available: true, reason: null };
},
async execute({ args = {}, memoryStore }) {
const key = String(args?.key || '').trim().toLowerCase().replace(/[^a-z0-9_\-]/g, '_').slice(0, 48);
const text = String(args?.text || '').trim().slice(0, 220);
if (!key) throw new Error('memory_note_upsert requires args.key');
if (!text) throw new Error('memory_note_upsert requires args.text');
const next = memoryStore && typeof memoryStore === 'object' ? { ...memoryStore } : {};
const notes = next.notes && typeof next.notes === 'object' && !Array.isArray(next.notes) ? { ...next.notes } : {};
notes[key] = text;
next.notes = notes;
return { ok: true, memory: next, key };
},
};
@@ -0,0 +1,16 @@
module.exports = {
id: 'memory_read',
signature: 'memory_read()',
description: 'Read full persistent memory (slots, notes, recent events).',
parameters: {
type: 'object',
properties: {},
additionalProperties: false,
},
availability() {
return { available: true, reason: null };
},
async execute({ memoryStore }) {
return { ok: true, memory: memoryStore };
},
};
@@ -0,0 +1,29 @@
module.exports = {
id: 'memory_write',
signature: 'memory_write(slot, text)',
description: 'Write one of 3 scratchpad slots for short-term reminders.',
parameters: {
type: 'object',
properties: {
slot: { type: 'integer', minimum: 1, maximum: 3 },
text: { type: 'string', minLength: 1, maxLength: 180 },
},
required: ['slot', 'text'],
additionalProperties: false,
},
availability() {
return { available: true, reason: null };
},
async execute({ args = {}, memoryStore }) {
const slot = Math.max(1, Math.min(3, Number(args?.slot) || 0));
if (!slot) throw new Error('memory_write requires args.slot 1..3');
const text = String(args?.text || '').trim();
if (!text) throw new Error('memory_write requires args.text');
const next = memoryStore && typeof memoryStore === 'object' ? { ...memoryStore } : {};
const slots = Array.isArray(next.slots) ? [...next.slots] : ['', '', ''];
while (slots.length < 3) slots.push('');
slots[slot - 1] = text.slice(0, 180);
next.slots = slots.slice(0, 3);
return { ok: true, memory: next };
},
};
@@ -0,0 +1,18 @@
module.exports = {
id: 'neato_clear_errors',
signature: 'neato_clear_errors()',
description: 'Clear Neato errors.',
parameters: { type: 'object', properties: {}, additionalProperties: false },
availability(ctx = {}) {
const mode = String(ctx.mode || '');
if (mode === 'admin' || mode === 'lockdown') {
return { available: false, reason: `policy_lock:site_mode_${mode}` };
}
if (!ctx.neatoState?.connected) return { available: false, reason: 'unavailable' };
return { available: true, reason: null };
},
async execute({ neatoService }) {
await neatoService.clearErrors();
return { ok: true };
},
};
@@ -0,0 +1,18 @@
module.exports = {
id: 'neato_locate',
signature: 'neato_locate()',
description: 'Play Neato locate/chime action.',
parameters: { type: 'object', properties: {}, additionalProperties: false },
availability(ctx = {}) {
const mode = String(ctx.mode || '');
if (mode === 'admin' || mode === 'lockdown') {
return { available: false, reason: `policy_lock:site_mode_${mode}` };
}
if (!ctx.neatoState?.connected) return { available: false, reason: 'unavailable' };
return { available: true, reason: null };
},
async execute({ neatoService }) {
await neatoService.locateRobot();
return { ok: true };
},
};
@@ -0,0 +1,18 @@
module.exports = {
id: 'neato_send_home',
signature: 'neato_send_home()',
description: 'Send Neato back to base.',
parameters: { type: 'object', properties: {}, additionalProperties: false },
availability(ctx = {}) {
const mode = String(ctx.mode || '');
if (mode === 'admin' || mode === 'lockdown') {
return { available: false, reason: `policy_lock:site_mode_${mode}` };
}
if (!ctx.neatoState?.connected) return { available: false, reason: 'unavailable' };
return { available: true, reason: null };
},
async execute({ neatoService }) {
await neatoService.sendHome();
return { ok: true };
},
};
@@ -0,0 +1,18 @@
module.exports = {
id: 'neato_start',
signature: 'neato_start()',
description: 'Start Neato cleaning cycle.',
parameters: { type: 'object', properties: {}, additionalProperties: false },
availability(ctx = {}) {
const mode = String(ctx.mode || '');
if (mode === 'admin' || mode === 'lockdown') {
return { available: false, reason: `policy_lock:site_mode_${mode}` };
}
if (!ctx.neatoState?.connected) return { available: false, reason: 'unavailable' };
return { available: true, reason: null };
},
async execute({ neatoService }) {
await neatoService.startCleaning();
return { ok: true };
},
};
@@ -155,7 +155,6 @@ function tryAssignClosedPrivateRover(socket, roverId) {
} catch (err) {
logger.warn('Failed to move assignment after private access grant', { socketId: socket.id, error: err.message });
}
socket.emit('controlGranted', { roverId: String(roverId) });
return true;
}
@@ -1,7 +1,7 @@
// Private Rover Access Request Service
// Purpose: Composes private-rover access request state, core workflows, and event hooks behind one API.
// Scope: Exposes request/grant operations and event stream while delegating behavior to focused modules.
const { DM_APPROVE_EMOJI, DM_DENY_EMOJI, requestEvents } = require('./state');
const { requestEvents } = require('./state');
const {
getStateForSocket,
createRequest,
@@ -22,8 +22,6 @@ registerPrivateRoverAccessHooks({
});
module.exports = {
DM_APPROVE_EMOJI,
DM_DENY_EMOJI,
requestEvents,
getStateForSocket,
createRequest,
@@ -4,8 +4,6 @@
const EventEmitter = require('events');
const REQUEST_COOLDOWN_MS = 15 * 1000;
const DM_APPROVE_EMOJI = '✅';
const DM_DENY_EMOJI = '❌';
const requestEvents = new EventEmitter();
const pendingRequests = new Map();
@@ -16,8 +14,6 @@ const grants = new Map();
module.exports = {
REQUEST_COOLDOWN_MS,
DM_APPROVE_EMOJI,
DM_DENY_EMOJI,
requestEvents,
pendingRequests,
pendingByRequesterRover,
@@ -39,7 +39,6 @@ function createSidebarRenderer({ execFileAsync, ensureDir }) {
switch (String(role)) {
case 'admin':
case 'lockdown':
case 'lockdown-admin':
return '#FCD34D';
case 'spectator':
return '#94A3B8';
@@ -4,7 +4,7 @@
const path = require('path');
const roverManager = require('../roverManager');
const { getRoomCameras } = require('../roomCameraService');
const { FFMPEG_BIN, SEGMENT_SECONDS, TARGET_FPS } = require('./constants');
const { SEGMENT_SECONDS, TARGET_FPS } = require('./constants');
function sourceKey(source) {
return `${source.sourceType}__${source.kind}__${source.id}`;
@@ -63,7 +63,6 @@ function buildWorkerArgs(activeSegmentRoot, source) {
}
module.exports = {
FFMPEG_BIN,
sourceKey,
sourceDirForKey,
listDesiredSources,
@@ -102,8 +102,6 @@ function registerRoomCameraSocketGateway({ getRoomCamera, getRoomCameras, getRoo
socket.on('roomCamera:subscribe', (payload = {}, cb = () => {}) => {
const list = Array.isArray(payload?.ids)
? payload.ids.map(String)
: payload?.roomCameraId || payload?.id
? [String(payload.roomCameraId || payload.id)]
: getRoomCameras().map((cam) => cam.id);
const uniqueIds = Array.from(new Set(list));
try {
@@ -126,8 +124,6 @@ function registerRoomCameraSocketGateway({ getRoomCamera, getRoomCameras, getRoo
socket.on('roomCamera:unsubscribe', (payload = {}) => {
const list = Array.isArray(payload?.ids)
? payload.ids.map(String)
: payload?.roomCameraId || payload?.id
? [String(payload.roomCameraId || payload.id)]
: [];
list.forEach((cameraId) => removeSubscription(socket.id, cameraId));
});
@@ -216,7 +216,6 @@ registerSocketHandlers({
tickPrivateAutoClose,
removeSocket,
enableSpectator,
getRosterForSocket,
canRequestControl,
canSwitchRover,
getRoversForSocket,
@@ -251,9 +251,6 @@ function createRosterLifecycle(deps) {
function broadcastRoster() {
syncSpectatorRooms();
io.sockets.sockets.forEach((socket) => {
socket.emit('rovers', getRosterForSocket(socket));
});
}
function setNightVisionState(roverId, nightVisionOn) {
@@ -44,7 +44,6 @@ function createRoverLifecycle(deps) {
socketToRovers.get(socket.id).add(roverId);
socket.join(record.room);
turnService.driverAdded(roverId, socket.id, force && isAdmin(socket));
socket.emit('controlGranted', { roverId });
managerEvents.emit('driver', { socketId: socket.id, roverId, action: 'add' });
sendAlert({
color: ALERT_COLOR,
@@ -18,7 +18,6 @@ function registerSocketHandlers(deps) {
tickPrivateAutoClose,
removeSocket,
enableSpectator,
getRosterForSocket,
canRequestControl,
canSwitchRover,
getRoversForSocket,
@@ -32,7 +31,6 @@ function registerSocketHandlers(deps) {
io.on('connection', (socket) => {
tickPrivateAutoClose();
socket.emit('rovers', getRosterForSocket(socket));
if (socket.data?.role === 'spectator') {
enableSpectator(socket);
}
@@ -79,7 +77,6 @@ function registerSocketHandlers(deps) {
info.sourceId !== `${targetId}-audio`,
);
managerEvents.emit('switch', { socketId: socket.id, roverId: targetId });
socket.emit('controlGranted', { roverId: targetId });
cb({ success: true, roverId: targetId });
} catch (err) {
logger.warn('Request control failed', socket.id, err.message);
@@ -172,15 +169,10 @@ function registerSocketHandlers(deps) {
cb({ success: true });
}
socket.on('requestControl', handleRequestControl);
socket.on('session:requestControl', handleRequestControl);
socket.on('releaseControl', handleReleaseControl);
socket.on('session:releaseControl', handleReleaseControl);
socket.on('lockRover', handleLockToggle);
socket.on('session:lockRover', handleLockToggle);
socket.on('privateSafety:set', handlePrivateSafetySet);
socket.on('session:privateSafety:set', handlePrivateSafetySet);
socket.on('subscribeAll', handleSubscribeAll);
socket.on('session:subscribeAll', handleSubscribeAll);
socket.on('disconnecting', () => {
@@ -11,7 +11,6 @@ const configuredSocials = Array.isArray(config.socials) ? config.socials : null;
const ACTIVITY_SYNC_COOLDOWN_MS = 3000;
const NIGHT_VISION_SYNC_COOLDOWN_MS = 1000;
const PERIODIC_SYNC_MS = 20000;
module.exports = {
discordInvite,
@@ -20,5 +19,4 @@ module.exports = {
configuredSocials,
ACTIVITY_SYNC_COOLDOWN_MS,
NIGHT_VISION_SYNC_COOLDOWN_MS,
PERIODIC_SYNC_MS,
};
+1 -12
View File
@@ -40,7 +40,6 @@ const {
configuredSocials,
ACTIVITY_SYNC_COOLDOWN_MS,
NIGHT_VISION_SYNC_COOLDOWN_MS,
PERIODIC_SYNC_MS,
} = require('./constants');
const { getState, setState } = require('./state');
const {
@@ -330,14 +329,4 @@ audioLevelsEvents.on('change', () => {
syncAll();
});
// sync all sockets 20 seconds
// setInterval(() => {
// logger.info('Periodic session sync for all clients');
// syncAll();
// }, PERIODIC_SYNC_MS);
module.exports = {
buildSession,
syncSocket,
syncAll,
};
module.exports = {};
@@ -43,7 +43,7 @@ function normalizeKnownIps(raw = []) {
}
function isAdminRole(role) {
return role === 'admin' || role === 'lockdown' || role === 'lockdown-admin';
return role === 'admin' || role === 'lockdown';
}
function parseDeterrenceSelector(selector) {
@@ -68,6 +68,5 @@ module.exports = {
createSession,
getSession,
revokeSession,
revokeBySocket,
revokeWhere,
};
@@ -31,13 +31,7 @@ function getMediaPrefix() {
function buildWhepUrlForSource(source) {
const cleanBase = getMediaPrefix();
if (!cleanBase) return '';
const segments = [];
if (source.type === 'room') {
segments.push('room', encodeURIComponent(source.id));
} else {
segments.push(encodeURIComponent(source.id));
}
return `${cleanBase}/${segments.join('/')}/whep`;
return `${cleanBase}/${encodeURIComponent(source.id)}/whep`;
}
function passesMode(socket) {
@@ -66,21 +60,14 @@ function canViewRover(socket, roverId) {
return roverManager.isDriver(roverId, socket);
}
function canViewRoomCamera(socket) {
return passesMode(socket);
}
function normalizeRequest(payload = {}) {
if (!payload) return null;
if (payload.type && payload.id) {
return { type: payload.type, id: String(payload.id) };
if (payload.type && payload.id && payload.type === 'rover') {
return { type: 'rover', id: String(payload.id) };
}
if (payload.roverId) {
return { type: 'rover', id: String(payload.roverId) };
}
if (payload.roomCameraId) {
return { type: 'room', id: String(payload.roomCameraId) };
}
return null;
}
@@ -91,7 +78,6 @@ io.on('connection', (socket) => {
if (!target) {
throw new Error('video source required');
}
if (target.type === 'rover') {
const baseId = target.id.endsWith('-audio') ? target.id.slice(0, -6) : target.id;
const isAudio = target.id.endsWith('-audio');
if (!roverManager.rovers.has(baseId)) {
@@ -107,11 +93,6 @@ io.on('connection', (socket) => {
throw new Error('Not authorized for video');
}
}
} else if (target.type === 'room') {
throw new Error('Room cameras now use the snapshot feed');
} else {
throw new Error('Unsupported video source');
}
const url = buildWhepUrlForSource(target);
if (!url) {
throw new Error('Server video base URL missing');
+2 -2
View File
@@ -9,8 +9,8 @@
<meta name="theme-color" content="#020617" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title>
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
<title>Roomba Rover</title>
</head>
<body>
<div id="root"></div>
+2 -1
View File
@@ -28,6 +28,7 @@
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.14",
"vite": "^7.2.2"
"vite": "^7.2.2",
"socket.io-client": "4.8.3"
}
}
-51
View File
@@ -1,51 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
+29 -9
View File
@@ -28,6 +28,7 @@ import FloatingFullscreenButton from './components/FloatingFullscreenButton/inde
import { useFullscreenPrompt } from './hooks/useFullscreenPrompt.js';
import { useSettingsNamespace } from './settings/index.js';
import HelpOverlay from './components/HelpOverlay/index.jsx';
import QuickstartOverlay from './components/QuickstartOverlay/index.jsx';
import HelpPanel from './components/HelpPanel/index.jsx';
import SettingsPanel from './components/SettingsPanel/index.jsx';
import Tabs, { Tab, TabList, TabPanel, TabPanels } from './components/Tabs/index.jsx';
@@ -250,9 +251,13 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
const {
value: helpSettings,
status: helpStatus,
save: saveHelpSettings,
} = useSettingsNamespace('help', { showOnLoad: true });
const {
value: quickstartSettings,
status: quickstartStatus,
save: saveQuickstartSettings,
} = useSettingsNamespace('quickstart', { showOnLoad: true });
const { value: pageSettings } = useSettingsNamespace('page', {
swapMobileControlColumns: false,
});
@@ -260,15 +265,17 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
const fullscreenButtonSide = swapMobileControlColumns ? 'left' : 'right';
const showFloatingFullscreenButton = !isDesktop && (fullscreenIsIOS || fullscreenNativeSupported);
const [helpVisible, setHelpVisible] = useState(false);
const [quickstartVisible, setQuickstartVisible] = useState(false);
useEffect(() => {
if (helpStatus === 'ready') {
setHelpVisible(helpSettings?.showOnLoad !== false);
if (quickstartStatus === 'ready') {
setQuickstartVisible(quickstartSettings?.showOnLoad !== false);
}
}, [helpStatus, helpSettings?.showOnLoad]);
}, [quickstartStatus, quickstartSettings?.showOnLoad]);
const openHelp = useCallback(() => setHelpVisible(true), []);
const closeHelp = useCallback(() => setHelpVisible(false), []);
const closeQuickstart = useCallback(() => setQuickstartVisible(false), []);
const handleFloatingFullscreen = useCallback(async () => {
if (fullscreenIsIOS) {
showPrompt();
@@ -279,16 +286,20 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
showPrompt();
}
}, [enterFullscreen, fullscreenIsIOS, showPrompt]);
const setShowOnLoad = useCallback(
const setQuickstartShowOnLoad = useCallback(
(enabled) => {
const next = Boolean(enabled);
saveHelpSettings((current) => ({ ...(current ?? {}), showOnLoad: next }));
saveQuickstartSettings((current) => ({ ...(current ?? {}), showOnLoad: next }));
if (!next) {
setHelpVisible(false);
setQuickstartVisible(false);
}
},
[saveHelpSettings],
[saveQuickstartSettings],
);
const openHelpFromQuickstart = useCallback(() => {
setQuickstartVisible(false);
setHelpVisible(true);
}, []);
const renderedLayout = useMemo(
() =>
@@ -312,12 +323,13 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
<RewardRunOverlay />
<TurnAlertListener />
<ModeGateOverlay />
<HelpOverlay
visible={helpVisible}
layout={layout}
onClose={closeHelp}
showOnLoad={helpSettings?.showOnLoad !== false}
onToggleShowOnLoad={setShowOnLoad}
onToggleShowOnLoad={(enabled) => saveHelpSettings((current) => ({ ...(current ?? {}), showOnLoad: Boolean(enabled) }))}
/>
<FullscreenPrompt
visible={fullscreenVisible}
@@ -325,6 +337,14 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
onEnterFullscreen={enterFullscreen}
onDismiss={dismiss}
/>
<QuickstartOverlay
visible={quickstartVisible}
layout={layout}
showOnLoad={quickstartSettings?.showOnLoad !== false}
onToggleShowOnLoad={setQuickstartShowOnLoad}
onOpenHelp={openHelpFromQuickstart}
onClose={closeQuickstart}
/>
{showFloatingFullscreenButton ? (
<FloatingFullscreenButton
side={fullscreenButtonSide}
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

@@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from 'react';
import { useSession } from '../../context/SessionContext.jsx';
import RoverRoster from '../RoverRoster/index.jsx';
import LlmCommentaryPanel from './LlmCommentaryPanel.jsx';
import OverseerControlPanel from './OverseerControlPanel.jsx';
import ReplaySnapshotHealth from './ReplaySnapshotHealth.jsx';
import AdminIpLogPanel from './AdminIpLogPanel.jsx';
@@ -28,14 +29,17 @@ export default function AdminPanelContent() {
setAudioLevels,
setPrivateSafety,
llmControl,
overseerControl,
adminLogs,
llmCommentaryState,
overseerControlState,
} = useSession();
const roster = useMemo(() => session?.roster ?? [], [session?.roster]);
const [lockStates, setLockStates] = useState({});
const [rebootStates, setRebootStates] = useState({});
const [serverRebooting, setServerRebooting] = useState(false);
const [clearingLlmHistory, setClearingLlmHistory] = useState(false);
const [clearingOverseerHistory, setClearingOverseerHistory] = useState(false);
const health = session?.health || null;
const currentGoal = session?.globalObjective?.text || '';
const goalUpdatedAt = session?.globalObjective?.updatedAt || null;
@@ -53,9 +57,8 @@ export default function AdminPanelContent() {
const isAdmin =
session?.role === 'admin' ||
session?.role === 'lockdown' ||
session?.role === 'lockdown-admin';
const isLockdownAdmin = session?.role === 'lockdown' || session?.role === 'lockdown-admin';
session?.role === 'lockdown';
const isLockdownAdmin = session?.role === 'lockdown';
const currentMode = session?.mode ?? 'open';
@@ -126,6 +129,19 @@ export default function AdminPanelContent() {
}
};
const handleClearOverseerHistory = async () => {
const ok = window.confirm('Clear Overseer Control history now?');
if (!ok) return;
setClearingOverseerHistory(true);
try {
await overseerControl('clearHistory');
} catch (err) {
alert(err.message);
} finally {
setClearingOverseerHistory(false);
}
};
const handleGoalSave = async () => {
try {
await setGlobalObjective(goalDraft);
@@ -491,6 +507,11 @@ export default function AdminPanelContent() {
onClearHistory={handleClearLlmHistory}
clearingHistory={clearingLlmHistory}
/>
<OverseerControlPanel
state={overseerControlState}
onClearHistory={handleClearOverseerHistory}
clearingHistory={clearingOverseerHistory}
/>
<AdminIpLogPanel entries={adminLogs} />
</section>
);
@@ -0,0 +1,131 @@
import { useState } from 'react';
export default function OverseerControlPanel({ state, onClearHistory, clearingHistory }) {
const [showPayload, setShowPayload] = useState(false);
if (!state) {
return (
<div className="space-y-0.5">
<div className="panel-muted text-xs uppercase">Overseer Control</div>
<div className="surface text-xs text-slate-300">No status received yet.</div>
</div>
);
}
const runtime = state.runtime || {};
const cfg = state.config || {};
const output = state.output || {};
const timings = state.timings || {};
const input = state.input || {};
const errors = state.errors || {};
const renderModelMessages = () => {
const messages = Array.isArray(input.modelMessages) ? input.modelMessages : [];
if (!messages.length) return '<none>';
return messages
.map((msg, idx) => {
const role = String(msg?.role || 'unknown').toUpperCase();
const content = String(msg?.content || '');
return `#${idx + 1} ${role}\n${content}`;
})
.join('\n\n----------------------------------------\n\n');
};
return (
<div className="space-y-0.5">
<div className="panel-muted text-xs uppercase">Overseer Control</div>
<div className="flex gap-0.5 text-xs">
<button
type="button"
onClick={onClearHistory}
disabled={Boolean(clearingHistory)}
className="button-danger disabled:cursor-not-allowed disabled:opacity-60"
>
{clearingHistory ? 'Clearing...' : 'Clear Overseer History'}
</button>
</div>
<div className="surface flex flex-wrap gap-0.5 text-xs">
<span className="surface-muted">running: {runtime.running ? 'yes' : 'no'}</span>
<span className="surface-muted">phase: {runtime.phase || '--'}</span>
<span className="surface-muted">tick: {runtime.tickCount ?? 0}</span>
<span className="surface-muted">trigger: {runtime.lastTriggerReason || '--'}</span>
<span className="surface-muted">name: {cfg.name || '--'}</span>
<span className="surface-muted">model: {cfg.model || '--'}</span>
<span className="surface-muted">observeOnly: {cfg.observeOnly ? 'yes' : 'no'}</span>
<span className="surface-muted">decision: {output.normalized || '--'}</span>
<span className="surface-muted">outcome: {output.outcome || '--'}</span>
<span className="surface-muted">reason: {output.reason || '--'}</span>
<span className="surface-muted">last gen: {timings.lastGenerationMs != null ? `${timings.lastGenerationMs}ms` : '--'}</span>
</div>
<details className="surface text-xs text-slate-200" open>
<summary className="cursor-pointer select-none text-slate-300">Latest Context</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
{input.stateUpdate || '<none>'}
</pre>
</details>
<details className="surface text-xs text-slate-200" open>
<summary className="cursor-pointer select-none text-slate-300">Exact Model Input (messages[])</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
{renderModelMessages()}
</pre>
</details>
<details className="surface text-xs text-slate-200">
<summary className="cursor-pointer select-none text-slate-300">Exact System Prompt</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
{input.systemPrompt || '<none>'}
</pre>
</details>
<details className="surface text-xs text-slate-200">
<summary className="cursor-pointer select-none text-slate-300">Exact Transcript Rows</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
{JSON.stringify(input.transcript || [], null, 2)}
</pre>
</details>
<details className="surface text-xs text-slate-200">
<summary className="cursor-pointer select-none text-slate-300">Tool Availability</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
{JSON.stringify({ available: input.availableTools, blocked: input.blockedTools }, null, 2)}
</pre>
</details>
<details className="surface text-xs text-slate-200" open>
<summary className="cursor-pointer select-none text-slate-300">Exact Model Output</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
{output.raw || '<none>'}
</pre>
</details>
<details className="surface text-xs text-slate-200" open>
<summary className="cursor-pointer select-none text-slate-300">Live Tool Calls</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
{JSON.stringify(output.liveToolCalls || [], null, 2)}
</pre>
</details>
<div className="surface text-xs text-slate-200">
<div>Normalized decision: {output.normalized || '--'}</div>
<div>Model input at: {input.modelInputAt ? new Date(input.modelInputAt).toLocaleString() : 'n/a'}</div>
<div>Model output at: {output.outputAt ? new Date(output.outputAt).toLocaleString() : 'n/a'}</div>
</div>
{errors.message ? <div className="surface text-xs text-red-300">Error: {errors.message}</div> : null}
<details className="surface text-xs text-slate-200">
<summary className="cursor-pointer select-none text-slate-300">Recent Runs</summary>
<pre className="mt-0.5 whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">
{JSON.stringify(state.history || [], null, 2)}
</pre>
</details>
<button type="button" className="button-dark text-xs" onClick={() => setShowPayload((v) => !v)}>
{showPayload ? 'Hide Full Payload' : 'Show Full Payload'}
</button>
{showPayload ? (
<pre className="surface whitespace-pre-wrap break-words text-[0.72rem] text-slate-200">{JSON.stringify(state, null, 2)}</pre>
) : null}
</div>
);
}
@@ -8,7 +8,6 @@ function roleColors(role) {
switch (role) {
case 'admin':
case 'lockdown':
case 'lockdown-admin':
return 'text-amber-300';
case 'spectator':
return 'text-slate-400';
@@ -108,7 +107,7 @@ function chatRowClass(message) {
return 'surface-muted relative flex flex-wrap items-start gap-0.5 border border-emerald-500/40 bg-emerald-900/15 text-sm';
}
const isAdmin =
message.role === 'admin' || message.role === 'lockdown' || message.role === 'lockdown-admin';
message.role === 'admin' || message.role === 'lockdown';
return `surface-muted relative flex flex-wrap items-start gap-0.5 text-sm ${
isAdmin
? 'border border-amber-400/30'
@@ -7,7 +7,7 @@ export default function FloatingFullscreenButton({ side = 'right', onClick }) {
<button
type="button"
onClick={onClick}
className={`fixed bottom-2 ${sideClass} z-30 flex h-10 w-10 items-center justify-center rounded-full border border-cyan-200/50 bg-slate-900/70 text-cyan-100 shadow-lg backdrop-blur-sm transition hover:bg-slate-800/80 active:scale-95`}
className={`fixed bottom-2 ${sideClass} z-30 flex h-10 w-10 items-center justify-center rounded-full border border-slate-700 bg-black text-slate-200 transition hover:bg-zinc-900 active:scale-95`}
aria-label="Enter fullscreen"
title="Enter fullscreen"
>
@@ -6,14 +6,14 @@ export default function FullscreenPrompt({ visible, mode, onEnterFullscreen, onD
const isIOSMode = mode === 'pwa-hint';
return (
<div className="fixed inset-0 z-40 flex items-end justify-center p-2 pointer-events-none sm:items-center">
<div className="pointer-events-auto w-full max-w-sm rounded-lg border border-cyan-500/40 bg-zinc-950/95 shadow-xl">
<div className="space-y-0.5 p-4 text-sm text-slate-100">
<h2 className="text-base font-semibold text-white">Better in fullscreen</h2>
<div className="fixed inset-0 z-40 flex items-center justify-center p-1 pointer-events-none bg-black/20">
<div className="pointer-events-auto w-full max-w-sm surface center">
<div className="space-y-0.5 p-1 text-sm text-slate-100">
<h2 className="text-base font-semibold text-white border-b border-slate-700">Better in fullscreen!</h2>
{isIOSMode ? (
<p className="text-slate-300">
For fullscreen on iOS, open Safari&apos;s share menu and pick <strong>Add to Home Screen</strong>. Launching from
the home screen removes the browser chrome.
For fullscreen on iOS, open Safari's share menu and pick <strong>Add to Home Screen</strong>. Launching from
the home screen then makes it fullscreen.
</p>
) : (
<p className="text-slate-300">
@@ -21,6 +21,8 @@ export default function FullscreenPrompt({ visible, mode, onEnterFullscreen, onD
via the system back or home gesture.
</p>
)}
<p className="text-xs border-t border-b border-slate-700 p-0.5 text-blue-300 text-center">This will only show once just to let you know. There is a fullscreen button in the bottom right for later use.</p>
<div className="flex justify-end gap-0.5 pt-1 text-sm">
<button type="button" className="rounded border border-slate-600 px-3 py-1 text-slate-200" onClick={onDismiss}>
{isIOSMode ? 'Got it' : 'Not now'}
@@ -8,8 +8,8 @@ import SocialButton from '../SocialButton/index.jsx';
import ChatPanel from '../ChatPanel/index.jsx';
import NicknameForm from '../NicknameForm/index.jsx';
const PRIVILEGED_ROLES = new Set(['admin', 'lockdown', 'lockdown-admin']);
const LOCKDOWN_ROLES = new Set(['lockdown', 'lockdown-admin']);
const PRIVILEGED_ROLES = new Set(['admin', 'lockdown']);
const LOCKDOWN_ROLES = new Set(['lockdown']);
const RESTRICTED_MODES = new Set(['admin', 'lockdown']);
function getModeDetails(mode = 'admin') {
@@ -70,7 +70,7 @@ export default function ModeGateOverlay() {
const details = getModeDetails(mode);
return (
<div className="pointer-events-auto fixed inset-0 z-50 flex items-center justify-center bg-black/85 px-0.5 py-0.5">
<div className="pointer-events-auto fixed inset-0 z-50 flex items-center justify-center bg-black px-0.5 py-0.5">
<div className="surface w-full max-w-md space-y-0.5 text-slate-100 shadow-2xl">
<div className="space-y-0.5">
<p className="text-lg font-semibold">{details.title}</p>
@@ -0,0 +1,127 @@
import { useMemo } from 'react';
import { useControlSystem } from '../../controls/index.js';
import { formatKeyLabel } from '../../controls/keymapUtils.js';
import NicknameForm from '../NicknameForm/index.jsx';
import SocialButton from '../SocialButton/index.jsx';
import KeyPill from '../vip/VipAudioUploadCard/KeyPill.jsx';
import { useSessionSelector } from '../../context/SessionContext.jsx';
function ControlRow({ label, keyLabel }) {
return (
<div className="surface-muted flex items-center justify-between gap-0.5 px-0.5 py-0.35 text-[0.8rem] text-slate-200">
<span>{label}</span>
<KeyPill label={keyLabel} />
</div>
);
}
function DesktopQuickstart({ keymap }) {
return (
<div className="space-y-0.5">
<p className="text-sm text-slate-200">1. Press "Start Driving" put your rover into driving mode.</p>
<div className="space-y-0.5">
<p className="text-sm text-slate-200">2. Use the drive controls to move your rover:</p>
<div className="space-y-0.5">
<ControlRow label="Forward" keyLabel={formatKeyLabel(keymap?.driveForward?.[0])} />
<ControlRow label="Backward" keyLabel={formatKeyLabel(keymap?.driveBackward?.[0])} />
<ControlRow label="Turn Left" keyLabel={formatKeyLabel(keymap?.driveLeft?.[0])} />
<ControlRow label="Turn Right" keyLabel={formatKeyLabel(keymap?.driveRight?.[0])} />
<ControlRow label="Move faster" keyLabel={formatKeyLabel(keymap?.boostModifier?.[0])} />
<ControlRow label="Move slower" keyLabel={formatKeyLabel(keymap?.slowModifier?.[0])} />
</div>
</div>
<p className="text-sm text-slate-200">3. When done, please dock your rover! Line up with the dock and press "Dock and Charge".</p>
</div>
);
}
function MobileQuickstart() {
return (
<div className="space-y-0.5 text-sm text-slate-200">
<p>1. Press "Start Driving" put your rover into driving mode.</p>
<p>2. Touch and hold in Joystick area to move.</p>
<p>3. Use the other column for motor, horn, and camera controls.</p>
<p>4. When done, please dock your rover! Line up with the dock and press "Dock and Charge".</p>
</div>
);
}
export default function QuickstartOverlay({
visible,
layout,
showOnLoad,
onToggleShowOnLoad,
onOpenHelp,
onClose,
}) {
const { state } = useControlSystem();
const isDesktop = layout === 'desktop';
const discordUrl = useSessionSelector((sessionState) => {
const socials = sessionState.session?.socials || [];
const entry = socials.find((item) => {
const key = String(item?.id || item?.label || '').toLowerCase();
return key === 'discord';
});
return entry?.url || sessionState.session?.discord?.invite || null;
});
const keymap = useMemo(() => state?.keymap || {}, [state?.keymap]);
if (!visible) return null;
const handleCheckbox = (event) => {
const keepShowing = !event.target.checked;
onToggleShowOnLoad?.(keepShowing);
};
return (
<div className="fixed inset-0 z-40 flex items-end justify-center bg-black/75 p-0.5 items-center">
<div className="pointer-events-auto surface w-full max-w-3xl overflow-hidden shadow-2xl">
<div className="flex items-center justify-between border-b border-slate-700 px-0.5 py-0.35 text-sm text-slate-200">
<span className="font-semibold text-xl">Welcome! To get started:</span>
{/* <button type="button" onClick={onClose} className="button-dark px-1 py-0.25 text-[0.8rem]">
Close
</button> */}
</div>
<div className={`grid gap-0.5 p-0.5 ${isDesktop ? 'md:grid-cols-[minmax(0,1.5fr)_minmax(0,1fr)]' : 'grid-cols-1'}`}>
<section className="space-y-0.5 border-b border-slate-700">
{isDesktop ? <DesktopQuickstart keymap={keymap} /> : <MobileQuickstart />}
</section>
{/* {!isDesktop? <div className='w-full h-1 bg-blue-500'></div> : null} */}
<section className="space-y-0.5">
<p className='text-left'>Next...</p>
<div className="surface space-y-0.5 p-0.5 border-b border-slate-700">
<p className="text-xl font-semibold text-slate-200">Set your nickname</p>
<p className="text-sm font-semibold text-slate-200">Nicknames are assigned randomly by default, you can change yours here.</p>
<NicknameForm compact />
</div>
<div className="surface p-0.5">
<p className="text-xl font-semibold text-slate-200">Join our Discord server!</p>
<p className="text-sm font-semibold text-slate-200">We have an active and welcoming community :3</p>
<SocialButton id="discord" label="Join Discord" url={discordUrl} />
</div>
</section>
</div>
<div className="flex flex-wrap items-center justify-between gap-0.5 border-t border-slate-700 px-0.5 py-0.35 text-[0.8rem]">
<label className="flex items-center gap-0.5 text-slate-300">
<input
type="checkbox"
checked={!showOnLoad}
onChange={handleCheckbox}
className="accent-cyan-500"
/>
<span>Don&apos;t show again</span>
</label>
<div className="flex items-center gap-0.5">
{/* <button type="button" onClick={onOpenHelp} className="button-dark px-1 py-0.25">
Open full Help
</button> */}
<button type="button" onClick={onClose} className="button-dark px-1 py-0.25 text-2xl bg-green-600 hover:bg-green-400 hover:border-green-100 border-green-400">
Got it! Let me in!
</button>
</div>
</div>
</div>
</div>
);
}
@@ -10,7 +10,6 @@ function roleColors(role) {
switch (role) {
case 'admin':
case 'lockdown':
case 'lockdown-admin':
return 'text-amber-300';
case 'spectator':
return 'text-slate-400';
@@ -26,7 +26,6 @@ function roleColors(role) {
switch (role) {
case 'admin':
case 'lockdown':
case 'lockdown-admin':
return 'text-amber-300';
case 'spectator':
return 'text-slate-400';
@@ -56,7 +55,7 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
const canRequest = useMemo(() => role && role !== 'spectator', [role]);
const adminCapable = useMemo(
() => role === 'admin' || role === 'lockdown' || role === 'lockdown-admin',
() => role === 'admin' || role === 'lockdown',
[role],
);
const hasDeadlines = useMemo(
+2 -3
View File
@@ -29,7 +29,6 @@ function roleColors(role) {
switch (role) {
case 'admin':
case 'lockdown':
case 'lockdown-admin':
return 'text-amber-300';
case 'spectator':
return 'text-slate-400';
@@ -121,7 +120,7 @@ export default function UserListPanel({
) : (
sorted.map((user) => {
const isAdmin =
user.role === 'admin' || user.role === 'lockdown' || user.role === 'lockdown-admin';
user.role === 'admin' || user.role === 'lockdown';
return (
<div
key={user.socketId}
@@ -245,7 +244,7 @@ export default function UserListPanel({
const isNext = Boolean(nextId && socketId === nextId && !isCurrent);
const isSelf = Boolean(selfId && socketId === selfId);
const isAdmin =
user.role === 'admin' || user.role === 'lockdown' || user.role === 'lockdown-admin';
user.role === 'admin' || user.role === 'lockdown';
const highlightClass = isCurrent
? 'bg-sky-600 text-white ring-2 ring-amber-300 animate-pulse'
: isNext
+2 -2
View File
@@ -72,10 +72,10 @@ export default function VipLiftCard({ lift, onUp, onDown, fullWidth = false }) {
<div className="absolute inset-0 z-20 flex items-center justify-center rounded-md bg-slate-950/80 px-1.5 text-center">
<div className="space-y-0.25">
<p className="text-sm font-semibold text-slate-100">
{busy ? 'Motion in progress' : 'Motion cooldown active'}
{busy ? 'Preparing to move' : 'Moving!'}
</p>
<p className="text-xs text-slate-300">
Controls are disabled while the lift is moving, otherwise it's tiny brain would get confused.
Controls are disabled while the lift is moving, otherwise its tiny brain would get confused.
</p>
{!busy && cooldownActive ? (
<p className="text-xs text-slate-400">About {cooldownSeconds}s remaining.</p>
+9
View File
@@ -13,6 +13,7 @@ const INITIAL_STATE = {
adminLogs: [],
llmCommentaryState: null,
llmCommentaryStatus: null,
overseerControlState: null,
alerts: [],
};
@@ -128,6 +129,10 @@ export function SessionProvider({ children }) {
],
}));
}
function handleOverseerState(payload = null) {
const state = payload && typeof payload === 'object' ? payload : null;
setState((prev) => ({ ...prev, overseerControlState: state }));
}
function handleNeatoLidar(payload = null) {
const next = payload && typeof payload === 'object' ? payload : null;
setState((prev) => ({ ...prev, neatoLidar: next }));
@@ -139,6 +144,7 @@ export function SessionProvider({ children }) {
socket.on('adminlog:init', handleAdminLogInit);
socket.on('adminlog:entry', handleAdminLogEntry);
socket.on('llm:state', handleLlmState);
socket.on('overseer:state', handleOverseerState);
socket.on('alert:new', handleAlertNew);
return () => {
socket.off('session:sync', handleSession);
@@ -148,6 +154,7 @@ export function SessionProvider({ children }) {
socket.off('adminlog:init', handleAdminLogInit);
socket.off('adminlog:entry', handleAdminLogEntry);
socket.off('llm:state', handleLlmState);
socket.off('overseer:state', handleOverseerState);
socket.off('alert:new', handleAlertNew);
};
}, [setState, socket]);
@@ -204,6 +211,8 @@ export function SessionProvider({ children }) {
emitWithAck('session:privateSafety:set', { roverId, safety }),
llmControl: (action, controls = {}) =>
emitWithAck('llm:control', { controls: { action, ...controls } }),
overseerControl: (action, controls = {}) =>
emitWithAck('overseer:control', { controls: { action, ...controls } }),
pushAlert: (alert) =>
setState((prev) => ({
...prev,
+1 -1
View File
@@ -131,7 +131,7 @@ export function useOvercurrentLimiter(roverId, options = {}) {
return { motors, groups };
}, [overcurrentFlags]);
const adminImmune = role === 'admin' || role === 'lockdown' || role === 'lockdown-admin';
const adminImmune = role === 'admin' || role === 'lockdown';
return useMemo(
() => ({
+4 -11
View File
@@ -1,5 +1,5 @@
// Hook: useVideoRequests
// Purpose: Coordinates client-side video stream request intents and authorization timing. Scope: Provides reusable request helpers for rover and room video consumers.
// Purpose: Coordinates client-side video stream request intents and authorization timing. Scope: Provides reusable request helpers for rover video consumers.
import { useEffect, useMemo, useRef, useState } from 'react';
import { useSocket } from '../context/SocketContext.jsx';
@@ -13,12 +13,9 @@ function normalizeEntry(entry) {
if (typeof entry === 'object') {
if (entry.type && entry.id) {
const id = String(entry.id);
let key = entry.key;
if (!key) {
key = entry.type === 'room' ? `room:${id}` : id;
}
const key = entry.key || id;
return {
type: entry.type,
type: 'rover',
id,
key,
};
@@ -27,10 +24,6 @@ function normalizeEntry(entry) {
const id = String(entry.roverId);
return { type: 'rover', id, key: entry.key || id };
}
if (entry.roomCameraId) {
const id = String(entry.roomCameraId);
return { type: 'room', id, key: entry.key || `room:${id}` };
}
}
return null;
}
@@ -87,7 +80,7 @@ export function useVideoRequests(sourceList = [], options = {}) {
let cancelled = false;
function requestEntry(entry) {
const payload = entry.type === 'room' ? { roomCameraId: entry.id } : { roverId: entry.id };
const payload = { roverId: entry.id };
socket.emit('video:request', payload, (resp = {}) => {
if (cancelled) return;
setSources((prev) => ({ ...prev, [entry.key]: resp }));
+21 -5
View File
@@ -14,9 +14,15 @@ body {
@apply bg-black text-slate-100;
}
html,
body {
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.55) transparent;
}
* {
scrollbar-width: thin;
scrollbar-color: #3b82f6 rgba(255, 255, 255, 0.08);
scrollbar-color: rgba(148, 163, 184, 0.55) transparent;
}
@keyframes spin {
@@ -29,19 +35,29 @@ body {
}
*::-webkit-scrollbar {
width: 1px;
height: 1px;
width: 2px;
height: 2px;
}
*::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.08);
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: #3b82f6;
background-color: rgba(148, 163, 184, 0.55);
border-radius: 9999px;
}
@supports (scrollbar-width: thin) {
@media (pointer: coarse) {
html,
body,
* {
scrollbar-width: none;
}
}
}
@layer components {
.panel {
@apply bg-black text-white p-0 rounded-md;