diff --git a/rulesdocs/refector_rules_and_tracking.md b/rulesdocs/refector_rules_and_tracking.md index a11d6ed7..1e1ea7c2 100644 --- a/rulesdocs/refector_rules_and_tracking.md +++ b/rulesdocs/refector_rules_and_tracking.md @@ -73,6 +73,9 @@ - Finished `roverManager` decomposition by extracting private access policy, roster lifecycle, and spectator/auto-close orchestration into `roverManager/privateAccess.js`, `roverManager/rosterLifecycle.js`, and `roverManager/spectatorAccess.js`; `roverManager/index.js` is now a thin composition layer. - Hotfix: corrected `llmCommentaryService` prompt file path to `server/prompts/commentary_system.txt` after service folder move. - Hotfix: added `server/src/helpers/dataPaths.js` and rewired data-backed services to resolve canonical + legacy data-file locations safely after folderization (`adminReason`, `audioLevels`, `buttonBox`, `communityGoal`, `discordGuildStore`, `verification`, `replayEngineV2`). +- Began `llmCommentaryService` decomposition by extracting immutable runtime limits/path/frequency normalization to `llmCommentaryService/constants.js` and pure prompt/text output helpers to `llmCommentaryService/formatters.js`. +- Continued `llmCommentaryService` decomposition by extracting admin/runtime projection + failure-normalization helpers to `llmCommentaryService/runtimeHelpers.js`. +- Continued `llmCommentaryService` decomposition by extracting sensor activity aggregation and snapshot assembly to `llmCommentaryService/snapshotEngine.js`; rewired commentary tick/event flow to use the new engine. ## WebUI frontend ### BIGGEST OFFENDERS diff --git a/server/src/services/llmCommentaryService/constants.js b/server/src/services/llmCommentaryService/constants.js new file mode 100644 index 00000000..ac778d53 --- /dev/null +++ b/server/src/services/llmCommentaryService/constants.js @@ -0,0 +1,46 @@ +// llm Commentary Service constants +// Purpose: Centralizes static limits, prompt path, and timing defaults used by commentary runtime. +// Scope: Keeps runtime behavior unchanged by moving immutable values and normalization helper into one module. +const path = require('path'); + +const PROMPT_PATH = path.join(__dirname, '..', '..', '..', 'prompts', 'commentary_system.txt'); +const DEFAULT_FREQUENCY_MS = 0; +const MIN_FREQUENCY_MS = 0; +const JITTER_MS = 0; +const MAX_ROVERS = 6; +const MAX_CHAT_MESSAGES = 4; +const MAX_BOT_MESSAGES = 1; +const ACTIVITY_WINDOW_MS = 60000; +const ACTIVITY_BUCKET_MS = 1000; +const ACTIVITY_SCORE_WINDOW_MS = 30000; +const SELF_TALK_WINDOW_MS = 30 * 60 * 1000; +const MAX_CONTEXT_EVENTS = 8; +const MAX_RUN_HISTORY = 100; +const MAX_ROVER_EVENTS = 400; +const POST_COOLDOWN_MS = 10000; + +function normalizeFrequencyMs(value) { + if (!Number.isFinite(value)) return DEFAULT_FREQUENCY_MS; + // If frequency is configured as a small integer, treat it as seconds for convenience. + const parsed = value > 0 && value < 1000 ? value * 1000 : value; + return Math.max(MIN_FREQUENCY_MS, Math.floor(parsed)); +} + +module.exports = { + PROMPT_PATH, + DEFAULT_FREQUENCY_MS, + MIN_FREQUENCY_MS, + JITTER_MS, + MAX_ROVERS, + MAX_CHAT_MESSAGES, + MAX_BOT_MESSAGES, + ACTIVITY_WINDOW_MS, + ACTIVITY_BUCKET_MS, + ACTIVITY_SCORE_WINDOW_MS, + SELF_TALK_WINDOW_MS, + MAX_CONTEXT_EVENTS, + MAX_RUN_HISTORY, + MAX_ROVER_EVENTS, + POST_COOLDOWN_MS, + normalizeFrequencyMs, +}; diff --git a/server/src/services/llmCommentaryService/formatters.js b/server/src/services/llmCommentaryService/formatters.js new file mode 100644 index 00000000..7460ab93 --- /dev/null +++ b/server/src/services/llmCommentaryService/formatters.js @@ -0,0 +1,178 @@ +// llm Commentary Service formatters +// Purpose: Encodes snapshot/chat/event structures into stable model-facing prompt message text. +// Scope: Keeps runtime behavior unchanged by extracting pure text-formatting and normalization helpers. +function normalizeCommentary(rawText) { + if (typeof rawText !== 'string') return null; + const trimmed = rawText.trim(); + if (!trimmed) return null; + if (/\bSKIP\b/i.test(trimmed)) return null; + const firstLine = trimmed + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean); + if (!firstLine) return null; + if (/\bSKIP\b/i.test(firstLine)) return null; + return firstLine.replace(/\s+/g, ' '); +} + +function parseModelOutput(rawContent) { + const raw = typeof rawContent === 'string' ? rawContent : ''; + const normalized = normalizeCommentary(raw); + return { + raw, + normalized, + skipped: normalized == null, + }; +} + +function normalizeDuplicateKey(text) { + return String(text || '') + .toLowerCase() + .replace(/[^a-z0-9\s]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function encBool(value) { + return value ? '1' : '0'; +} + +function encStatus(value) { + const map = { + charging: 'charging', + docked: 'docked', + driving: 'driving', + 'active-idle': 'active_idle', + idle: 'idle', + unknown: 'unknown', + }; + return map[String(value || 'unknown')] || 'unknown'; +} + +function encActivityBand(value) { + const map = { + idle: 'idle', + low: 'low', + medium: 'medium', + high: 'high', + intense: 'intense', + }; + return map[String(value || 'idle')] || 'idle'; +} + +function encActivityTrend(value) { + const map = { + rising: 'rising', + steady: 'steady', + falling: 'falling', + }; + return map[String(value || 'steady')] || 'steady'; +} + +function formatChatRoverCtx(ctx) { + if (!ctx || typeof ctx !== 'object') return 'none'; + return `st=${encStatus(ctx.status_tag)} bl=${encBool(Boolean(ctx.battery_low))} dk=${encBool(Boolean(ctx.docked))} ab=${encActivityBand(ctx.activity_band)} at=${encActivityTrend(ctx.activity_trend)}`; +} + +function formatChatEventMessage(event) { + const roverId = event.rover_id || 'none'; + if (roverId === 'none') { + return [ + 'CHAT', + `n=${event.nickname || 'unknown'} r=none driver=none`, + `txt: ${event.text || ''}`, + ].join('\n'); + } + return [ + 'CHAT', + `n=${event.nickname || 'unknown'} r=${roverId}`, + `txt: ${event.text || ''}`, + `rn: ${formatChatRoverCtx(event.rover_ctx)}`, + ].join('\n'); +} + +function formatRoverSnapshotLine(rover = {}) { + return `id=${rover.id || 'unknown'} drv=${rover.driver_nickname || 'none'} st=${encStatus(rover.status_tag)} bl=${encBool(Boolean(rover.battery_low))} dk=${encBool(Boolean(rover.docked))} as=${Number(rover.activity_score) || 0} ab=${encActivityBand(rover.activity_band)} at=${encActivityTrend(rover.activity_trend)}`; +} + +function formatEventMessage(event) { + return [ + 'EVENT', + `e=${event.event_type || 'rover_event'} r=${event.rover_id || 'unknown'} d=${event.driver_nickname || 'none'}`, + `s: ${event.summary || ''}`, + ].join('\n'); +} + +function formatSnapshotMessage(event) { + const rovers = Array.isArray(event?.rovers) ? event.rovers : []; + const lines = ['SNAPSHOT', `reason=${event?.reason || 'none'}`]; + rovers.forEach((rover) => { + lines.push(formatRoverSnapshotLine(rover)); + }); + return lines.join('\n'); +} + +function formatSnapshotFinalMessage(currentSnapshot = {}, runMeta = {}) { + const rovers = Array.isArray(currentSnapshot?.rovers) ? currentSnapshot.rovers : []; + const lines = ['SNAPSHOT FINAL']; + lines.push(`skip_streak=${Number(runMeta?.skip_streak) || 0}`); + rovers.forEach((rover) => { + lines.push(formatRoverSnapshotLine(rover)); + }); + if (Array.isArray(currentSnapshot?.chat_recent) && currentSnapshot.chat_recent.length) { + lines.push('chat_recent:'); + currentSnapshot.chat_recent.forEach((entry) => { + lines.push(`- ${entry.nickname || 'unknown'}: ${entry.text || ''}`); + }); + } + return lines.join('\n'); +} + +function buildModelMessages(systemPrompt, snapshot) { + const messages = []; + messages.push({ role: 'system', content: systemPrompt }); + const timeline = Array.isArray(snapshot?.event_stream) ? snapshot.event_stream : []; + timeline.forEach((event) => { + if (!event || typeof event !== 'object') return; + if (event.type === 'bot') { + const text = String(event.text || '').trim(); + if (text) { + messages.push({ role: 'assistant', content: text }); + } + return; + } + if (event.type === 'chat') { + messages.push({ + role: 'user', + content: formatChatEventMessage(event), + }); + return; + } + if (event.type === 'event') { + messages.push({ + role: 'user', + content: formatEventMessage(event), + }); + return; + } + if (event.type === 'snapshot') { + messages.push({ + role: 'user', + content: formatSnapshotMessage(event), + }); + } + }); + // Always end with a full rover snapshot user message. + messages.push({ + role: 'user', + content: formatSnapshotFinalMessage(snapshot?.current_snapshot || {}, snapshot?.run_meta || {}), + }); + return messages; +} + +module.exports = { + normalizeCommentary, + parseModelOutput, + normalizeDuplicateKey, + buildModelMessages, +}; diff --git a/server/src/services/llmCommentaryService/index.js b/server/src/services/llmCommentaryService/index.js index ea5fa128..ef63b390 100644 --- a/server/src/services/llmCommentaryService/index.js +++ b/server/src/services/llmCommentaryService/index.js @@ -2,7 +2,6 @@ // Purpose: Defines the llm Commentary Service module and the helpers/state used by this service unit. // Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary. const fsp = require('fs/promises'); -const path = require('path'); const { Ollama } = require('ollama'); const io = require('../../globals/io'); const logger = require('../../globals/logger').child('llmCommentary'); @@ -12,23 +11,33 @@ const roverManager = require('../roverManager'); const { getActiveDrivers } = require('../turnService'); const { getNickname } = require('../nicknameService'); const { getRecentMessages, sendSystemMessage } = require('../chatService'); - -const PROMPT_PATH = path.join(__dirname, '..', '..', '..', 'prompts', 'commentary_system.txt'); -const DEFAULT_FREQUENCY_MS = 0; -const MIN_FREQUENCY_MS = 0; -const JITTER_MS = 0; -const MAX_ROVERS = 6; -const MAX_CHAT_MESSAGES = 4; -const MAX_BOT_MESSAGES = 1; -const SKIP_TOKEN = 'SKIP'; -const ACTIVITY_WINDOW_MS = 60000; -const ACTIVITY_BUCKET_MS = 1000; -const ACTIVITY_SCORE_WINDOW_MS = 30000; -const SELF_TALK_WINDOW_MS = 30 * 60 * 1000; -const MAX_CONTEXT_EVENTS = 8; -const MAX_RUN_HISTORY = 100; -const MAX_ROVER_EVENTS = 400; -const POST_COOLDOWN_MS = 10000; +const { + PROMPT_PATH, + JITTER_MS, + MAX_ROVERS, + MAX_CHAT_MESSAGES, + MAX_BOT_MESSAGES, + ACTIVITY_WINDOW_MS, + ACTIVITY_BUCKET_MS, + ACTIVITY_SCORE_WINDOW_MS, + SELF_TALK_WINDOW_MS, + MAX_CONTEXT_EVENTS, + MAX_RUN_HISTORY, + MAX_ROVER_EVENTS, + POST_COOLDOWN_MS, + normalizeFrequencyMs, +} = require('./constants'); +const { + parseModelOutput, + normalizeDuplicateKey, + buildModelMessages, +} = require('./formatters'); +const { + isAdminRole, + buildAdminState, + buildFailureInfo, +} = require('./runtimeHelpers'); +const { createSnapshotEngine } = require('./snapshotEngine'); const config = loadConfig(); const commentaryConfig = config.llmCommentary || {}; @@ -49,17 +58,23 @@ let contextResetAt = Date.now(); let clearCount = 0; let runHistory = []; let currentRun = null; -const roverActivity = new Map(); // roverId -> { buckets: Map(bucketTs -> { distanceMm, turnDeg, bumps }), bumpLeftActive, bumpRightActive } -const roverMajorEvents = []; // [{ ts, type: 'event', event_type, rover_id, driver_nickname, summary }] -const lastSensorFlagsByRover = new Map(); // roverId -> { docked, charging, battery_low, wheels_off_ground } -const lastRoverStateById = new Map(); // roverId -> compact rover state used as prev_state - -function normalizeFrequencyMs(value) { - if (!Number.isFinite(value)) return DEFAULT_FREQUENCY_MS; - // If frequency is configured as a small integer, treat it as seconds for convenience. - const parsed = value > 0 && value < 1000 ? value * 1000 : value; - return Math.max(MIN_FREQUENCY_MS, Math.floor(parsed)); -} +const snapshotEngine = createSnapshotEngine({ + io, + roverManager, + getActiveDrivers, + getNickname, + getRecentMessages, + MAX_ROVERS, + MAX_CHAT_MESSAGES, + ACTIVITY_WINDOW_MS, + ACTIVITY_BUCKET_MS, + ACTIVITY_SCORE_WINDOW_MS, + SELF_TALK_WINDOW_MS, + MAX_CONTEXT_EVENTS, + MAX_ROVER_EVENTS, + getContextResetAt: () => contextResetAt, + getSkipStreak: () => skipStreak, +}); const frequencyMs = normalizeFrequencyMs(Number(commentaryConfig.frequency ?? commentaryConfig.frequencyMs)); let status = { @@ -105,62 +120,6 @@ let status = { updatedAt: Date.now(), }; -function buildAdminState() { - return { - runtime: { - running: status.running, - inFlight: status.inFlight, - phase: status.phase, - phaseAt: status.phaseAt, - currentRunId: status.currentRunId, - tickCount: status.tickCount, - lastTickAt: status.lastTickAt, - nextRunAt: status.nextRunAt, - outcome: status.lastOutcome, - reason: status.lastReason, - }, - counters: { - clearCount: status.clearCount, - skipStreak: status.skipStreak, - promptChars: status.lastPromptChars, - snapshotSummary: status.lastSnapshotSummary, - }, - timings: { - lastGenerationMs: status.lastGenerationMs, - avgGenerationMs: status.avgGenerationMs, - generationCount: status.generationCount, - }, - input: { - promptPath: status.promptPath, - systemPrompt: status.lastSystemPrompt, - infoSnapshot: status.lastInfoSnapshot, - modelMessages: status.lastModelMessages, - modelInputAt: status.lastModelInputAt, - modelInputTickId: status.lastModelInputTickId, - }, - output: { - raw: status.lastModelRawOutput, - generated: status.lastGeneratedText, - posted: status.lastPostedText, - postedAt: status.lastPostedAt, - modelOutputAt: status.lastModelOutputAt, - modelOutputTickId: status.lastModelOutputTickId, - }, - errors: { - message: status.lastError, - details: status.lastErrorDetails, - failedAt: status.lastFailedAt, - }, - history: runHistory, - debug: { - status, - }, - controls: { - supportedActions: ['clearHistory'], - }, - }; -} - function updatePhase(phase, patch = {}) { updateStatus({ phase, @@ -222,16 +181,16 @@ function finalizeRunRecord({ outcome, reason, errors } = {}) { function isAdminSocket(socket) { if (!socket) return false; const role = getRole(socket); - return role === 'admin' || role === 'lockdown' || role === 'lockdown-admin'; + return isAdminRole(role); } function emitStatusToSocket(socket) { if (!socket || !isAdminSocket(socket)) return; - socket.emit('llm:state', buildAdminState()); + socket.emit('llm:state', buildAdminState(status, runHistory)); } function emitStatusToAdmins() { - const payload = buildAdminState(); + const payload = buildAdminState(status, runHistory); io.sockets.sockets.forEach((socket) => { if (!isAdminSocket(socket)) return; socket.emit('llm:state', payload); @@ -251,679 +210,6 @@ function updateStatus(patch = {}) { emitStatusToAdmins(); } -function pushRoverMajorEvent(event) { - roverMajorEvents.push(event); - if (roverMajorEvents.length > MAX_ROVER_EVENTS) { - roverMajorEvents.shift(); - } -} - -function pruneActivityBuckets(state, nowMs) { - if (!state?.buckets) return; - const minTs = nowMs - ACTIVITY_WINDOW_MS; - state.buckets.forEach((_, bucketTs) => { - if (bucketTs < minTs) { - state.buckets.delete(bucketTs); - } - }); -} - -function upsertActivityState(roverId) { - if (!roverActivity.has(roverId)) { - roverActivity.set(roverId, { - buckets: new Map(), - bumpLeftActive: false, - bumpRightActive: false, - }); - } - return roverActivity.get(roverId); -} - -function onSensorEvent({ roverId, sensors, batteryState } = {}) { - if (!roverId || !sensors) return; - if (!roverManager.canReplayRoverId(roverId)) return; - const nowMs = Date.now(); - const dockedNow = Boolean(sensors?.chargingSources?.homeBase); - const bucketTs = Math.floor(nowMs / ACTIVITY_BUCKET_MS) * ACTIVITY_BUCKET_MS; - const state = upsertActivityState(String(roverId)); - pruneActivityBuckets(state, nowMs); - if (!state.buckets.has(bucketTs)) { - state.buckets.set(bucketTs, { distanceMm: 0, turnDeg: 0, bumps: 0 }); - } - const bucket = state.buckets.get(bucketTs); - bucket.distanceMm += Math.abs(Number(sensors.distanceMm) || 0); - bucket.turnDeg += Math.abs(Number(sensors.angleDeg) || 0); - const bumpLeftNow = Boolean(sensors?.bumpsAndWheelDrops?.bumpLeft); - const bumpRightNow = Boolean(sensors?.bumpsAndWheelDrops?.bumpRight); - if (!dockedNow) { - if (bumpLeftNow && !state.bumpLeftActive) { - bucket.bumps += 0.5; - } - if (bumpRightNow && !state.bumpRightActive) { - bucket.bumps += 0.5; - } - } - state.bumpLeftActive = bumpLeftNow; - state.bumpRightActive = bumpRightNow; - - const roverKey = String(roverId); - const activeDrivers = getActiveDrivers(); - const driverSocketId = activeDrivers[roverKey] || null; - const driverNickname = driverSocketId ? resolveDriverNickname(driverSocketId) : null; - const docked = dockedNow; - const charging = isChargingFromSensors(sensors); - const wheelsOffGround = Boolean( - sensors?.bumpsAndWheelDrops?.wheelDropLeft && sensors?.bumpsAndWheelDrops?.wheelDropRight, - ); - const batteryLow = Boolean(batteryState?.warnActive || batteryState?.urgentActive); - const prevFlags = lastSensorFlagsByRover.get(roverKey) || null; - const nextFlags = { - docked, - charging, - battery_low: batteryLow, - wheels_off_ground: wheelsOffGround, - }; - if (prevFlags) { - if (prevFlags.docked !== nextFlags.docked) { - pushRoverMajorEvent({ - ts: nowMs, - type: 'event', - event_type: nextFlags.docked ? 'rover_docked' : 'rover_undocked', - rover_id: roverKey, - driver_nickname: driverNickname, - summary: nextFlags.docked ? 'transitioned to docked' : 'transitioned to undocked', - }); - } - if (prevFlags.battery_low !== nextFlags.battery_low) { - pushRoverMajorEvent({ - ts: nowMs, - type: 'event', - event_type: 'battery_low_changed', - rover_id: roverKey, - driver_nickname: driverNickname, - summary: nextFlags.battery_low ? 'battery_low became true' : 'battery_low became false', - }); - } - } - lastSensorFlagsByRover.set(roverKey, nextFlags); -} - -function getActivity30s(roverId, nowMs = Date.now()) { - return getActivityWindow(roverId, ACTIVITY_SCORE_WINDOW_MS, 0, nowMs); -} - -function getActivityWindow(roverId, windowMs, offsetMs = 0, nowMs = Date.now()) { - const state = roverActivity.get(String(roverId)); - if (!state) { - return { distance_m: 0, turn_deg: 0, bumps: 0 }; - } - pruneActivityBuckets(state, nowMs); - const windowStart = nowMs - offsetMs - windowMs; - const windowEnd = nowMs - offsetMs; - let distanceMm = 0; - let turnDeg = 0; - let bumps = 0; - state.buckets.forEach((bucket, bucketTs) => { - if (bucketTs < windowStart || bucketTs > windowEnd) return; - distanceMm += bucket.distanceMm; - turnDeg += bucket.turnDeg; - bumps += bucket.bumps; - }); - return { - distance_m: Math.round((distanceMm / 1000) * 10) / 10, - turn_deg: Math.round(turnDeg), - bumps: Math.round(bumps * 10) / 10, - }; -} - -function computeBaseActivityScore(activity = {}) { - const distanceScore = Math.min(45, Math.max(0, Number(activity.distance_m) || 0) * 25); - const turnScore = Math.min(30, Math.max(0, Number(activity.turn_deg) || 0) / 12); - const bumpScore = Math.min(25, Math.max(0, Number(activity.bumps) || 0) * 12); - return Math.round(Math.min(100, distanceScore + turnScore + bumpScore)); -} - -function computeActivityBand(score) { - if (score >= 75) return 'intense'; - if (score >= 50) return 'high'; - if (score >= 25) return 'medium'; - if (score >= 8) return 'low'; - return 'idle'; -} - -function computeActivityTrend(currentBaseScore, previousBaseScore) { - const delta = Number(currentBaseScore || 0) - Number(previousBaseScore || 0); - if (delta >= 12) return 'rising'; - if (delta <= -12) return 'falling'; - return 'steady'; -} - -function clearRuntimeHistory() { - contextResetAt = Date.now(); - clearCount += 1; - skipStreak = 0; - generationCount = 0; - generationTotalMs = 0; - runHistory = []; - currentRun = null; - roverMajorEvents.length = 0; - lastSensorFlagsByRover.clear(); - roverActivity.clear(); - lastRoverStateById.clear(); - updateStatus({ - lastClearedAt: contextResetAt, - clearCount, - skipStreak, - phase: 'idle', - phaseAt: Date.now(), - currentRunId: null, - lastGenerationMs: null, - avgGenerationMs: null, - generationCount, - lastInfoSnapshot: null, - lastModelMessages: null, - lastModelInputAt: null, - lastModelInputTickId: null, - lastModelRawOutput: null, - lastModelOutputAt: null, - lastModelOutputTickId: null, - lastSnapshotSummary: null, - lastGeneratedText: null, - lastPostedText: null, - lastPostedAt: null, - lastError: null, - lastErrorDetails: null, - lastFailedAt: null, - lastOutcome: 'cleared', - lastReason: 'admin requested clear history', - }); -} - -function buildFailureInfo(err) { - const details = {}; - if (err && typeof err === 'object') { - if (err.name) details.name = String(err.name); - if (err.code != null) details.code = String(err.code); - if (err.errno != null) details.errno = String(err.errno); - if (err.type) details.type = String(err.type); - if (err.status != null) details.status = Number(err.status); - if (err.statusCode != null) details.statusCode = Number(err.statusCode); - if (err.status_code != null) details.status_code = Number(err.status_code); - if (err.error) details.error = typeof err.error === 'string' ? err.error : JSON.stringify(err.error); - if (err.cause) { - if (typeof err.cause === 'string') { - details.cause = err.cause; - } else if (typeof err.cause === 'object') { - details.cause = { - name: err.cause.name || null, - message: err.cause.message || null, - code: err.cause.code || null, - status: err.cause.status ?? err.cause.statusCode ?? null, - }; - } - } - if (err.response && typeof err.response === 'object') { - const response = {}; - if (err.response.status != null) response.status = Number(err.response.status); - if (err.response.statusText) response.statusText = String(err.response.statusText); - if (err.response.url) response.url = String(err.response.url); - if (Object.keys(response).length) { - details.response = response; - } - } - } - - const message = - (err && typeof err === 'object' && typeof err.message === 'string' && err.message.trim()) || - details.error || - String(err || 'Unknown error'); - - const reasonParts = []; - if (details.name) reasonParts.push(details.name); - const code = details.code || details.errno || details.type; - if (code) reasonParts.push(String(code)); - const status = - details.status ?? - details.statusCode ?? - details.status_code ?? - details.response?.status ?? - null; - if (status != null) reasonParts.push(`status ${status}`); - const reason = reasonParts.length ? reasonParts.join(' | ') : 'exception'; - - return { reason, message, details: Object.keys(details).length ? details : null }; -} - -function isChargingFromSensors(sensors = {}) { - const label = String(sensors?.chargingState?.label || '').toLowerCase(); - if (label === 'waiting' || label === 'full charging' || label === 'trickle charging') { - return true; - } - const code = sensors?.chargingState?.code; - return code === 2 || code === 3 || code === 4; -} - -function resolveDriverNickname(socketId) { - if (!socketId) return null; - const socket = io.sockets.sockets.get(socketId); - return getNickname(socket) || socket?.data?.user?.username || socketId.slice(0, 6); -} - -function collectActiveDriverEntries() { - const fromTurns = Object.entries(getActiveDrivers()).filter(([, socketId]) => Boolean(socketId)); - if (fromTurns.length > 0) { - return fromTurns; - } - const fallback = []; - roverManager.rovers.forEach((record, roverId) => { - const socketId = record?.drivers?.values?.().next?.().value || null; - if (socketId) { - fallback.push([String(roverId), socketId]); - } - }); - return fallback; -} - -function detectMessageTopic(text = '') { - const value = String(text).toLowerCase(); - if (!value.trim()) return 'none'; - if (/\b(bump|hit|bonk|crash|slam|collision)\b/.test(value)) return 'bumps'; - if (/\b(wheel.?drop|wheels?.*off.?ground|picked up|lifted)\b/.test(value)) return 'wheels_off_ground'; - if (/\b(dock|docked|undock|charger|charging)\b/.test(value)) return 'dock_charge'; - if (/\b(battery|low power|power)\b/.test(value)) return 'battery'; - if (/\b(chat|everyone|people|crowd)\b/.test(value)) return 'chat'; - if (/\b(move|driv|turn|spin|rolling)\b/.test(value)) return 'movement'; - return 'general'; -} - -function buildLastMessageFocus(lastBotMessage, rovers = []) { - if (!lastBotMessage) return null; - const text = String(lastBotMessage.text || ''); - const textLower = text.toLowerCase(); - let roverId = null; - for (const rover of rovers) { - const id = String(rover?.id || '').toLowerCase(); - const name = String(rover?.name || '').toLowerCase(); - if ((id && textLower.includes(id)) || (name && textLower.includes(name))) { - roverId = rover.id; - break; - } - } - return { - rover_id: roverId, - topic: detectMessageTopic(text), - }; -} - -function compactRoverForContext(rover) { - if (!rover) return null; - return { - id: rover.id, - status_tag: rover.status_tag, - battery_low: rover.battery_low, - docked: rover.docked, - charging: rover.charging, - wheels_off_ground: rover.wheels_off_ground, - contact_state: rover.contact_state || 'clear', - hazard_state: rover.hazard_state || 'normal', - mobility_state: rover.mobility_state || 'normal', - activity_score: rover.activity_score ?? 0, - activity_band: rover.activity_band || 'idle', - activity_trend: rover.activity_trend || 'steady', - }; -} - -function deriveContactState(sensors = {}, activity30s = {}, docked = false) { - if (docked) return 'clear'; - const bumps = Number(activity30s?.bumps) || 0; - const hasBump = bumps >= 0.5 || sensors?.bumpsAndWheelDrops?.bumpLeft || sensors?.bumpsAndWheelDrops?.bumpRight; - if (hasBump) return 'bumps_recent'; - const light = sensors?.lightBumper || {}; - const wallBrush = - Boolean(sensors?.wall) || - Boolean(light.left || light.frontLeft || light.centerLeft || light.centerRight || light.frontRight || light.right); - if (wallBrush) return 'wall_brush'; - return 'clear'; -} - -function deriveHazardState(sensors = {}, docked = false) { - if (docked) return 'normal'; - if (Boolean(sensors?.virtualWall)) return 'virtual_wall_seen'; - if ( - Boolean(sensors?.cliffLeft) || - Boolean(sensors?.cliffFrontLeft) || - Boolean(sensors?.cliffFrontRight) || - Boolean(sensors?.cliffRight) - ) { - return 'cliff_alert'; - } - return 'normal'; -} - -function deriveMobilityState(sensors = {}, wheelsOffGround = false) { - if (wheelsOffGround) return 'wheels_off_ground'; - return 'normal'; -} - -function buildRoversNow(nowMs = Date.now()) { - const activeDrivers = getActiveDrivers(); - const roster = roverManager - .getRoster() - .filter((entry) => roverManager.canReplayRoverId(entry.id)) - .slice(0, MAX_ROVERS); - const nextRoverStateById = new Map(); - const rovers = roster.map((entry) => { - const roverId = String(entry.id); - const record = roverManager.rovers.get(roverId); - const sensors = record?.lastSensor?.decoded || {}; - const batteryState = entry.batteryState || null; - const driverSocketId = activeDrivers[roverId] || null; - const wheelsOffGround = Boolean( - sensors?.bumpsAndWheelDrops?.wheelDropLeft && sensors?.bumpsAndWheelDrops?.wheelDropRight, - ); - const activity30s = getActivity30s(roverId, nowMs); - const previousActivity30s = getActivityWindow( - roverId, - ACTIVITY_SCORE_WINDOW_MS, - ACTIVITY_SCORE_WINDOW_MS, - nowMs, - ); - const charging = isChargingFromSensors(sensors); - const docked = Boolean(sensors?.chargingSources?.homeBase); - const isMoving = activity30s.distance_m > 0.1 || activity30s.turn_deg > 20; - let statusTag = 'idle'; - if (charging) { - statusTag = 'charging'; - } else if (docked) { - statusTag = 'docked'; - } else if (driverSocketId && isMoving) { - statusTag = 'driving'; - } else if (driverSocketId) { - statusTag = 'active-idle'; - } - const rover = { - id: roverId, - name: entry.name || roverId, - driver_nickname: driverSocketId ? resolveDriverNickname(driverSocketId) : null, - docked, - charging, - wheels_off_ground: wheelsOffGround, - battery_low: Boolean(batteryState?.warnActive || batteryState?.urgentActive), - activity_30s: activity30s, - status_tag: statusTag, - contact_state: deriveContactState(sensors, activity30s, docked), - hazard_state: deriveHazardState(sensors, docked), - mobility_state: deriveMobilityState(sensors, wheelsOffGround), - }; - const currentBaseScore = computeBaseActivityScore(activity30s); - const previousBaseScore = computeBaseActivityScore(previousActivity30s); - let activityScore = currentBaseScore; - if (rover.contact_state === 'wall_brush') activityScore += 6; - if (rover.contact_state === 'bumps_recent') activityScore += 12; - if (rover.hazard_state !== 'normal') activityScore += 8; - if (rover.status_tag === 'driving') activityScore += 8; - if (rover.status_tag === 'active-idle') activityScore += 4; - if (rover.charging || rover.docked) activityScore -= 25; - if (rover.wheels_off_ground) activityScore -= 20; - activityScore = Math.max(0, Math.min(100, Math.round(activityScore))); - rover.activity_score = activityScore; - rover.activity_band = computeActivityBand(activityScore); - rover.activity_trend = computeActivityTrend(currentBaseScore, previousBaseScore); - const prev = lastRoverStateById.get(roverId) || null; - const nextState = { - driver_nickname: rover.driver_nickname, - docked: rover.docked, - charging: rover.charging, - wheels_off_ground: rover.wheels_off_ground, - battery_low: rover.battery_low, - activity_30s: rover.activity_30s, - status_tag: rover.status_tag, - activity_score: rover.activity_score, - activity_band: rover.activity_band, - activity_trend: rover.activity_trend, - }; - nextRoverStateById.set(roverId, nextState); - rover.prev_state = prev; - return rover; - }); - lastRoverStateById.clear(); - nextRoverStateById.forEach((value, roverId) => { - lastRoverStateById.set(roverId, value); - }); - return { rovers, activeDrivers, roster }; -} - -function buildSnapshot() { - const now = new Date(); - const nowMs = now.getTime(); - const driverEntries = collectActiveDriverEntries(); - const { rovers } = buildRoversNow(nowMs); - const roverById = new Map(rovers.map((rover) => [String(rover.id), rover])); - const allRecentMessages = getRecentMessages(300, { includeSystem: true }) - .filter((entry) => Number(entry?.ts) >= contextResetAt) - .filter((entry) => { - const roverId = entry?.roverId ? String(entry.roverId) : null; - if (!roverId) return true; - return roverManager.canReplayRoverId(roverId); - }); - const chatRecent = allRecentMessages - .filter((entry) => !entry?.system) - .slice(-MAX_CHAT_MESSAGES) - .map((entry) => ({ - nickname: entry.nickname || entry.socketId?.slice(0, 6) || 'unknown', - text: entry.text || '', - })); - - const botRecentWindow = allRecentMessages - .filter((entry) => Number(entry?.ts) >= contextResetAt) - .filter((entry) => entry?.system); - const lastBotMessage = botRecentWindow.length ? botRecentWindow[botRecentWindow.length - 1] : null; - const botRecent30m = botRecentWindow.filter( - (entry) => nowMs - Number(entry?.ts || 0) <= SELF_TALK_WINDOW_MS, - ); - - const roverEvents = roverMajorEvents.filter( - (entry) => - Number(entry?.ts) >= contextResetAt && - roverManager.canReplayRoverId(entry?.rover_id || ''), - ); - const timelineEntries = [ - ...allRecentMessages.map((entry) => ({ ts: Number(entry?.ts || 0), source: 'chat', entry })), - ...roverEvents.map((entry) => ({ ts: Number(entry?.ts || 0), source: 'event', entry })), - ] - .sort((a, b) => a.ts - b.ts) - .slice(-MAX_CONTEXT_EVENTS); - - const eventStream = timelineEntries.map(({ source, entry }) => { - if (source === 'event') { - return { - type: 'event', - event_type: entry.event_type || 'rover_event', - rover_id: entry.rover_id || null, - driver_nickname: entry.driver_nickname || null, - summary: entry.summary || '', - }; - } - if (entry?.system) { - return { - type: 'bot', - nickname: entry.nickname || 'Rover Bot', - text: entry.text || '', - }; - } - const roverId = entry?.roverId ? String(entry.roverId) : null; - const rover = roverId ? roverById.get(roverId) : null; - const baseCtx = compactRoverForContext(rover) || {}; - const storedCtx = entry?.roverCtx || entry?.rover_ctx || {}; - return { - type: 'chat', - nickname: entry.nickname || entry.socketId?.slice(0, 6) || 'unknown', - text: entry.text || '', - rover_id: roverId, - rover_ctx: { ...baseCtx, ...storedCtx }, - }; - }); - const hasRecentChat = eventStream.some((event) => event.type === 'chat'); - - const currentSnapshot = { - rovers, - }; - if (!hasRecentChat) { - currentSnapshot.chat_recent = chatRecent; - } - - return { - run_meta: { - version: 'commentary_v2', - self_talk_recent_30m: botRecent30m.length, - skip_streak: skipStreak, - last_message_focus: buildLastMessageFocus(lastBotMessage, rovers), - active_driver_count: driverEntries.length, - driving_rovers: driverEntries.map(([roverId]) => String(roverId)), - }, - event_stream: eventStream, - current_snapshot: currentSnapshot, - }; -} - -function refreshFinalSnapshotForSend(snapshot) { - const { rovers } = buildRoversNow(Date.now()); - return { - ...(snapshot || {}), - current_snapshot: { - ...(snapshot?.current_snapshot || {}), - rovers, - }, - }; -} - -function normalizeCommentary(rawText) { - if (typeof rawText !== 'string') return null; - const trimmed = rawText.trim(); - if (!trimmed) return null; - if (/\bSKIP\b/i.test(trimmed)) return null; - const firstLine = trimmed - .split(/\r?\n/) - .map((line) => line.trim()) - .find(Boolean); - if (!firstLine) return null; - if (/\bSKIP\b/i.test(firstLine)) return null; - return firstLine.replace(/\s+/g, ' '); -} - -function parseModelOutput(rawContent) { - const raw = typeof rawContent === 'string' ? rawContent : ''; - const normalized = normalizeCommentary(raw); - return { - raw, - normalized, - skipped: normalized == null, - }; -} - -function normalizeDuplicateKey(text) { - return String(text || '') - .toLowerCase() - .replace(/[^a-z0-9\s]/g, ' ') - .replace(/\s+/g, ' ') - .trim(); -} - -function encBool(value) { - return value ? '1' : '0'; -} - -function encStatus(value) { - const map = { - charging: 'charging', - docked: 'docked', - driving: 'driving', - 'active-idle': 'active_idle', - idle: 'idle', - unknown: 'unknown', - }; - return map[String(value || 'unknown')] || 'unknown'; -} - -function encActivityBand(value) { - const map = { - idle: 'idle', - low: 'low', - medium: 'medium', - high: 'high', - intense: 'intense', - }; - return map[String(value || 'idle')] || 'idle'; -} - -function encActivityTrend(value) { - const map = { - rising: 'rising', - steady: 'steady', - falling: 'falling', - }; - return map[String(value || 'steady')] || 'steady'; -} - -function formatChatRoverCtx(ctx) { - if (!ctx || typeof ctx !== 'object') return 'none'; - return `st=${encStatus(ctx.status_tag)} bl=${encBool(Boolean(ctx.battery_low))} dk=${encBool(Boolean(ctx.docked))} ab=${encActivityBand(ctx.activity_band)} at=${encActivityTrend(ctx.activity_trend)}`; -} - -function formatChatEventMessage(event) { - const roverId = event.rover_id || 'none'; - if (roverId === 'none') { - return [ - 'CHAT', - `n=${event.nickname || 'unknown'} r=none driver=none`, - `txt: ${event.text || ''}`, - ].join('\n'); - } - return [ - 'CHAT', - `n=${event.nickname || 'unknown'} r=${roverId}`, - `txt: ${event.text || ''}`, - `rn: ${formatChatRoverCtx(event.rover_ctx)}`, - ].join('\n'); -} - -function formatRoverSnapshotLine(rover = {}) { - return `id=${rover.id || 'unknown'} drv=${rover.driver_nickname || 'none'} st=${encStatus(rover.status_tag)} bl=${encBool(Boolean(rover.battery_low))} dk=${encBool(Boolean(rover.docked))} as=${Number(rover.activity_score) || 0} ab=${encActivityBand(rover.activity_band)} at=${encActivityTrend(rover.activity_trend)}`; -} - -function formatEventMessage(event) { - return [ - 'EVENT', - `e=${event.event_type || 'rover_event'} r=${event.rover_id || 'unknown'} d=${event.driver_nickname || 'none'}`, - `s: ${event.summary || ''}`, - ].join('\n'); -} - -function formatSnapshotMessage(event) { - const rovers = Array.isArray(event?.rovers) ? event.rovers : []; - const lines = ['SNAPSHOT', `reason=${event?.reason || 'none'}`]; - rovers.forEach((rover) => { - lines.push(formatRoverSnapshotLine(rover)); - }); - return lines.join('\n'); -} - -function formatSnapshotFinalMessage(currentSnapshot = {}, runMeta = {}) { - const rovers = Array.isArray(currentSnapshot?.rovers) ? currentSnapshot.rovers : []; - const lines = ['SNAPSHOT FINAL']; - lines.push(`skip_streak=${Number(runMeta?.skip_streak) || 0}`); - rovers.forEach((rover) => { - lines.push(formatRoverSnapshotLine(rover)); - }); - if (Array.isArray(currentSnapshot?.chat_recent) && currentSnapshot.chat_recent.length) { - lines.push('chat_recent:'); - currentSnapshot.chat_recent.forEach((entry) => { - lines.push(`- ${entry.nickname || 'unknown'}: ${entry.text || ''}`); - }); - } - return lines.join('\n'); -} - async function readSystemPrompt() { const prompt = await fsp.readFile(PROMPT_PATH, 'utf8'); const trimmed = prompt.trim(); @@ -938,48 +224,6 @@ async function readSystemPrompt() { return trimmed; } -function buildModelMessages(systemPrompt, snapshot) { - const messages = []; - messages.push({ role: 'system', content: systemPrompt }); - const timeline = Array.isArray(snapshot?.event_stream) ? snapshot.event_stream : []; - timeline.forEach((event) => { - if (!event || typeof event !== 'object') return; - if (event.type === 'bot') { - const text = String(event.text || '').trim(); - if (text) { - messages.push({ role: 'assistant', content: text }); - } - return; - } - if (event.type === 'chat') { - messages.push({ - role: 'user', - content: formatChatEventMessage(event), - }); - return; - } - if (event.type === 'event') { - messages.push({ - role: 'user', - content: formatEventMessage(event), - }); - return; - } - if (event.type === 'snapshot') { - messages.push({ - role: 'user', - content: formatSnapshotMessage(event), - }); - } - }); - // Always end with a full rover snapshot user message. - messages.push({ - role: 'user', - content: formatSnapshotFinalMessage(snapshot?.current_snapshot || {}, snapshot?.run_meta || {}), - }); - return messages; -} - async function generateCommentary(messages) { if (!ollamaClient) { throw new Error('Ollama client unavailable'); @@ -1042,7 +286,7 @@ async function runTick() { } inFlight = true; try { - const snapshot = buildSnapshot(); + const snapshot = snapshotEngine.buildSnapshot(); const snapshotSummary = { activeDrivers: snapshot?.run_meta?.active_driver_count || 0, rovers: snapshot?.current_snapshot?.rovers?.length || 0, @@ -1068,7 +312,7 @@ async function runTick() { lastInfoSnapshot: snapshot, }); const systemPrompt = await readSystemPrompt(); - const snapshotForSend = refreshFinalSnapshotForSend(snapshot); + const snapshotForSend = snapshotEngine.refreshFinalSnapshotForSend(snapshot); const modelMessages = buildModelMessages(systemPrompt, snapshotForSend); const modelInputAt = Date.now(); patchCurrentRun({ @@ -1275,7 +519,7 @@ io.on('connection', (socket) => { const command = controls?.action || null; if (command === 'clearHistory') { clearRuntimeHistory(); - cb({ success: true, state: buildAdminState() }); + cb({ success: true, state: buildAdminState(status, runHistory) }); return; } cb({ error: 'Unknown llm control action' }); @@ -1286,10 +530,10 @@ roleEvents.on('change', ({ socket }) => { emitStatusToSocket(socket); }); -roverManager.managerEvents.on('sensor', onSensorEvent); +roverManager.managerEvents.on('sensor', snapshotEngine.onSensorEvent); roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => { if (action === 'removed' && roverId) { - roverActivity.delete(String(roverId)); + snapshotEngine.removeRover(roverId); } }); roverManager.managerEvents.on('driver', ({ action } = {}) => { diff --git a/server/src/services/llmCommentaryService/runtimeHelpers.js b/server/src/services/llmCommentaryService/runtimeHelpers.js new file mode 100644 index 00000000..64ba34f4 --- /dev/null +++ b/server/src/services/llmCommentaryService/runtimeHelpers.js @@ -0,0 +1,123 @@ +// llm Commentary Service runtime helpers +// 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'; +} + +function buildAdminState(status, runHistory) { + return { + runtime: { + running: status.running, + inFlight: status.inFlight, + phase: status.phase, + phaseAt: status.phaseAt, + currentRunId: status.currentRunId, + tickCount: status.tickCount, + lastTickAt: status.lastTickAt, + nextRunAt: status.nextRunAt, + outcome: status.lastOutcome, + reason: status.lastReason, + }, + counters: { + clearCount: status.clearCount, + skipStreak: status.skipStreak, + promptChars: status.lastPromptChars, + snapshotSummary: status.lastSnapshotSummary, + }, + timings: { + lastGenerationMs: status.lastGenerationMs, + avgGenerationMs: status.avgGenerationMs, + generationCount: status.generationCount, + }, + input: { + promptPath: status.promptPath, + systemPrompt: status.lastSystemPrompt, + infoSnapshot: status.lastInfoSnapshot, + modelMessages: status.lastModelMessages, + modelInputAt: status.lastModelInputAt, + modelInputTickId: status.lastModelInputTickId, + }, + output: { + raw: status.lastModelRawOutput, + generated: status.lastGeneratedText, + posted: status.lastPostedText, + postedAt: status.lastPostedAt, + modelOutputAt: status.lastModelOutputAt, + modelOutputTickId: status.lastModelOutputTickId, + }, + errors: { + message: status.lastError, + details: status.lastErrorDetails, + failedAt: status.lastFailedAt, + }, + history: runHistory, + debug: { + status, + }, + controls: { + supportedActions: ['clearHistory'], + }, + }; +} + +function buildFailureInfo(err) { + const details = {}; + if (err && typeof err === 'object') { + if (err.name) details.name = String(err.name); + if (err.code != null) details.code = String(err.code); + if (err.errno != null) details.errno = String(err.errno); + if (err.type) details.type = String(err.type); + if (err.status != null) details.status = Number(err.status); + if (err.statusCode != null) details.statusCode = Number(err.statusCode); + if (err.status_code != null) details.status_code = Number(err.status_code); + if (err.error) details.error = typeof err.error === 'string' ? err.error : JSON.stringify(err.error); + if (err.cause) { + if (typeof err.cause === 'string') { + details.cause = err.cause; + } else if (typeof err.cause === 'object') { + details.cause = { + name: err.cause.name || null, + message: err.cause.message || null, + code: err.cause.code || null, + status: err.cause.status ?? err.cause.statusCode ?? null, + }; + } + } + if (err.response && typeof err.response === 'object') { + const response = {}; + if (err.response.status != null) response.status = Number(err.response.status); + if (err.response.statusText) response.statusText = String(err.response.statusText); + if (err.response.url) response.url = String(err.response.url); + if (Object.keys(response).length) { + details.response = response; + } + } + } + + const message = + (err && typeof err === 'object' && typeof err.message === 'string' && err.message.trim()) || + details.error || + String(err || 'Unknown error'); + + const reasonParts = []; + if (details.name) reasonParts.push(details.name); + const code = details.code || details.errno || details.type; + if (code) reasonParts.push(String(code)); + const status = + details.status ?? + details.statusCode ?? + details.status_code ?? + details.response?.status ?? + null; + if (status != null) reasonParts.push(`status ${status}`); + const reason = reasonParts.length ? reasonParts.join(' | ') : 'exception'; + + return { reason, message, details: Object.keys(details).length ? details : null }; +} + +module.exports = { + isAdminRole, + buildAdminState, + buildFailureInfo, +}; diff --git a/server/src/services/llmCommentaryService/snapshotEngine.js b/server/src/services/llmCommentaryService/snapshotEngine.js new file mode 100644 index 00000000..c7a64b04 --- /dev/null +++ b/server/src/services/llmCommentaryService/snapshotEngine.js @@ -0,0 +1,484 @@ +// llm Commentary Service snapshot engine +// Purpose: Tracks rover activity/history and builds model snapshot payloads from live rover/chat state. +// Scope: Keeps runtime behavior unchanged while isolating sensor aggregation and snapshot assembly logic. +function createSnapshotEngine(deps) { + const { + io, + roverManager, + getActiveDrivers, + getNickname, + getRecentMessages, + MAX_ROVERS, + MAX_CHAT_MESSAGES, + ACTIVITY_WINDOW_MS, + ACTIVITY_BUCKET_MS, + ACTIVITY_SCORE_WINDOW_MS, + SELF_TALK_WINDOW_MS, + MAX_CONTEXT_EVENTS, + MAX_ROVER_EVENTS, + getContextResetAt, + getSkipStreak, + } = deps; + + const roverActivity = new Map(); + const roverMajorEvents = []; + const lastSensorFlagsByRover = new Map(); + const lastRoverStateById = new Map(); + + function pushRoverMajorEvent(event) { + roverMajorEvents.push(event); + if (roverMajorEvents.length > MAX_ROVER_EVENTS) { + roverMajorEvents.shift(); + } + } + + function pruneActivityBuckets(state, nowMs) { + if (!state?.buckets) return; + const minTs = nowMs - ACTIVITY_WINDOW_MS; + state.buckets.forEach((_, bucketTs) => { + if (bucketTs < minTs) { + state.buckets.delete(bucketTs); + } + }); + } + + function upsertActivityState(roverId) { + if (!roverActivity.has(roverId)) { + roverActivity.set(roverId, { + buckets: new Map(), + bumpLeftActive: false, + bumpRightActive: false, + }); + } + return roverActivity.get(roverId); + } + + function isChargingFromSensors(sensors = {}) { + const label = String(sensors?.chargingState?.label || '').toLowerCase(); + if (label === 'waiting' || label === 'full charging' || label === 'trickle charging') { + return true; + } + const code = sensors?.chargingState?.code; + return code === 2 || code === 3 || code === 4; + } + + function resolveDriverNickname(socketId) { + if (!socketId) return null; + const socket = io.sockets.sockets.get(socketId); + return getNickname(socket) || socket?.data?.user?.username || socketId.slice(0, 6); + } + + function onSensorEvent({ roverId, sensors, batteryState } = {}) { + if (!roverId || !sensors) return; + if (!roverManager.canReplayRoverId(roverId)) return; + const nowMs = Date.now(); + const dockedNow = Boolean(sensors?.chargingSources?.homeBase); + const bucketTs = Math.floor(nowMs / ACTIVITY_BUCKET_MS) * ACTIVITY_BUCKET_MS; + const state = upsertActivityState(String(roverId)); + pruneActivityBuckets(state, nowMs); + if (!state.buckets.has(bucketTs)) { + state.buckets.set(bucketTs, { distanceMm: 0, turnDeg: 0, bumps: 0 }); + } + const bucket = state.buckets.get(bucketTs); + bucket.distanceMm += Math.abs(Number(sensors.distanceMm) || 0); + bucket.turnDeg += Math.abs(Number(sensors.angleDeg) || 0); + const bumpLeftNow = Boolean(sensors?.bumpsAndWheelDrops?.bumpLeft); + const bumpRightNow = Boolean(sensors?.bumpsAndWheelDrops?.bumpRight); + if (!dockedNow) { + if (bumpLeftNow && !state.bumpLeftActive) bucket.bumps += 0.5; + if (bumpRightNow && !state.bumpRightActive) bucket.bumps += 0.5; + } + state.bumpLeftActive = bumpLeftNow; + state.bumpRightActive = bumpRightNow; + + const roverKey = String(roverId); + const activeDrivers = getActiveDrivers(); + const driverSocketId = activeDrivers[roverKey] || null; + const driverNickname = driverSocketId ? resolveDriverNickname(driverSocketId) : null; + const docked = dockedNow; + const charging = isChargingFromSensors(sensors); + const wheelsOffGround = Boolean( + sensors?.bumpsAndWheelDrops?.wheelDropLeft && sensors?.bumpsAndWheelDrops?.wheelDropRight, + ); + const batteryLow = Boolean(batteryState?.warnActive || batteryState?.urgentActive); + const prevFlags = lastSensorFlagsByRover.get(roverKey) || null; + const nextFlags = { + docked, + charging, + battery_low: batteryLow, + wheels_off_ground: wheelsOffGround, + }; + if (prevFlags) { + if (prevFlags.docked !== nextFlags.docked) { + pushRoverMajorEvent({ + ts: nowMs, + type: 'event', + event_type: nextFlags.docked ? 'rover_docked' : 'rover_undocked', + rover_id: roverKey, + driver_nickname: driverNickname, + summary: nextFlags.docked ? 'transitioned to docked' : 'transitioned to undocked', + }); + } + if (prevFlags.battery_low !== nextFlags.battery_low) { + pushRoverMajorEvent({ + ts: nowMs, + type: 'event', + event_type: 'battery_low_changed', + rover_id: roverKey, + driver_nickname: driverNickname, + summary: nextFlags.battery_low ? 'battery_low became true' : 'battery_low became false', + }); + } + } + lastSensorFlagsByRover.set(roverKey, nextFlags); + } + + function getActivityWindow(roverId, windowMs, offsetMs = 0, nowMs = Date.now()) { + const state = roverActivity.get(String(roverId)); + if (!state) return { distance_m: 0, turn_deg: 0, bumps: 0 }; + pruneActivityBuckets(state, nowMs); + const windowStart = nowMs - offsetMs - windowMs; + const windowEnd = nowMs - offsetMs; + let distanceMm = 0; + let turnDeg = 0; + let bumps = 0; + state.buckets.forEach((bucket, bucketTs) => { + if (bucketTs < windowStart || bucketTs > windowEnd) return; + distanceMm += bucket.distanceMm; + turnDeg += bucket.turnDeg; + bumps += bucket.bumps; + }); + return { + distance_m: Math.round((distanceMm / 1000) * 10) / 10, + turn_deg: Math.round(turnDeg), + bumps: Math.round(bumps * 10) / 10, + }; + } + + function getActivity30s(roverId, nowMs = Date.now()) { + return getActivityWindow(roverId, ACTIVITY_SCORE_WINDOW_MS, 0, nowMs); + } + + function computeBaseActivityScore(activity = {}) { + const distanceScore = Math.min(45, Math.max(0, Number(activity.distance_m) || 0) * 25); + const turnScore = Math.min(30, Math.max(0, Number(activity.turn_deg) || 0) / 12); + const bumpScore = Math.min(25, Math.max(0, Number(activity.bumps) || 0) * 12); + return Math.round(Math.min(100, distanceScore + turnScore + bumpScore)); + } + + function computeActivityBand(score) { + if (score >= 75) return 'intense'; + if (score >= 50) return 'high'; + if (score >= 25) return 'medium'; + if (score >= 8) return 'low'; + return 'idle'; + } + + function computeActivityTrend(currentBaseScore, previousBaseScore) { + const delta = Number(currentBaseScore || 0) - Number(previousBaseScore || 0); + if (delta >= 12) return 'rising'; + if (delta <= -12) return 'falling'; + return 'steady'; + } + + function detectMessageTopic(text = '') { + const value = String(text).toLowerCase(); + if (!value.trim()) return 'none'; + if (/\b(bump|hit|bonk|crash|slam|collision)\b/.test(value)) return 'bumps'; + if (/\b(wheel.?drop|wheels?.*off.?ground|picked up|lifted)\b/.test(value)) return 'wheels_off_ground'; + if (/\b(dock|docked|undock|charger|charging)\b/.test(value)) return 'dock_charge'; + if (/\b(battery|low power|power)\b/.test(value)) return 'battery'; + if (/\b(chat|everyone|people|crowd)\b/.test(value)) return 'chat'; + if (/\b(move|driv|turn|spin|rolling)\b/.test(value)) return 'movement'; + return 'general'; + } + + function buildLastMessageFocus(lastBotMessage, rovers = []) { + if (!lastBotMessage) return null; + const text = String(lastBotMessage.text || ''); + const textLower = text.toLowerCase(); + let roverId = null; + for (const rover of rovers) { + const id = String(rover?.id || '').toLowerCase(); + const name = String(rover?.name || '').toLowerCase(); + if ((id && textLower.includes(id)) || (name && textLower.includes(name))) { + roverId = rover.id; + break; + } + } + return { + rover_id: roverId, + topic: detectMessageTopic(text), + }; + } + + function compactRoverForContext(rover) { + if (!rover) return null; + return { + id: rover.id, + status_tag: rover.status_tag, + battery_low: rover.battery_low, + docked: rover.docked, + charging: rover.charging, + wheels_off_ground: rover.wheels_off_ground, + contact_state: rover.contact_state || 'clear', + hazard_state: rover.hazard_state || 'normal', + mobility_state: rover.mobility_state || 'normal', + activity_score: rover.activity_score ?? 0, + activity_band: rover.activity_band || 'idle', + activity_trend: rover.activity_trend || 'steady', + }; + } + + function deriveContactState(sensors = {}, activity30s = {}, docked = false) { + if (docked) return 'clear'; + const bumps = Number(activity30s?.bumps) || 0; + const hasBump = bumps >= 0.5 || sensors?.bumpsAndWheelDrops?.bumpLeft || sensors?.bumpsAndWheelDrops?.bumpRight; + if (hasBump) return 'bumps_recent'; + const light = sensors?.lightBumper || {}; + const wallBrush = + Boolean(sensors?.wall) || + Boolean(light.left || light.frontLeft || light.centerLeft || light.centerRight || light.frontRight || light.right); + if (wallBrush) return 'wall_brush'; + return 'clear'; + } + + function deriveHazardState(sensors = {}, docked = false) { + if (docked) return 'normal'; + if (Boolean(sensors?.virtualWall)) return 'virtual_wall_seen'; + if ( + Boolean(sensors?.cliffLeft) || + Boolean(sensors?.cliffFrontLeft) || + Boolean(sensors?.cliffFrontRight) || + Boolean(sensors?.cliffRight) + ) { + return 'cliff_alert'; + } + return 'normal'; + } + + function deriveMobilityState(sensors = {}, wheelsOffGround = false) { + if (wheelsOffGround) return 'wheels_off_ground'; + return 'normal'; + } + + function buildRoversNow(nowMs = Date.now()) { + const activeDrivers = getActiveDrivers(); + const roster = roverManager + .getRoster() + .filter((entry) => roverManager.canReplayRoverId(entry.id)) + .slice(0, MAX_ROVERS); + const nextRoverStateById = new Map(); + const rovers = roster.map((entry) => { + const roverId = String(entry.id); + const record = roverManager.rovers.get(roverId); + const sensors = record?.lastSensor?.decoded || {}; + const batteryState = entry.batteryState || null; + const driverSocketId = activeDrivers[roverId] || null; + const wheelsOffGround = Boolean( + sensors?.bumpsAndWheelDrops?.wheelDropLeft && sensors?.bumpsAndWheelDrops?.wheelDropRight, + ); + const activity30s = getActivity30s(roverId, nowMs); + const previousActivity30s = getActivityWindow( + roverId, + ACTIVITY_SCORE_WINDOW_MS, + ACTIVITY_SCORE_WINDOW_MS, + nowMs, + ); + const charging = isChargingFromSensors(sensors); + const docked = Boolean(sensors?.chargingSources?.homeBase); + const isMoving = activity30s.distance_m > 0.1 || activity30s.turn_deg > 20; + let statusTag = 'idle'; + if (charging) statusTag = 'charging'; + else if (docked) statusTag = 'docked'; + else if (driverSocketId && isMoving) statusTag = 'driving'; + else if (driverSocketId) statusTag = 'active-idle'; + const rover = { + id: roverId, + name: entry.name || roverId, + driver_nickname: driverSocketId ? resolveDriverNickname(driverSocketId) : null, + docked, + charging, + wheels_off_ground: wheelsOffGround, + battery_low: Boolean(batteryState?.warnActive || batteryState?.urgentActive), + activity_30s: activity30s, + status_tag: statusTag, + contact_state: deriveContactState(sensors, activity30s, docked), + hazard_state: deriveHazardState(sensors, docked), + mobility_state: deriveMobilityState(sensors, wheelsOffGround), + }; + const currentBaseScore = computeBaseActivityScore(activity30s); + const previousBaseScore = computeBaseActivityScore(previousActivity30s); + let activityScore = currentBaseScore; + if (rover.contact_state === 'wall_brush') activityScore += 6; + if (rover.contact_state === 'bumps_recent') activityScore += 12; + if (rover.hazard_state !== 'normal') activityScore += 8; + if (rover.status_tag === 'driving') activityScore += 8; + if (rover.status_tag === 'active-idle') activityScore += 4; + if (rover.charging || rover.docked) activityScore -= 25; + if (rover.wheels_off_ground) activityScore -= 20; + activityScore = Math.max(0, Math.min(100, Math.round(activityScore))); + rover.activity_score = activityScore; + rover.activity_band = computeActivityBand(activityScore); + rover.activity_trend = computeActivityTrend(currentBaseScore, previousBaseScore); + const prev = lastRoverStateById.get(roverId) || null; + const nextState = { + driver_nickname: rover.driver_nickname, + docked: rover.docked, + charging: rover.charging, + wheels_off_ground: rover.wheels_off_ground, + battery_low: rover.battery_low, + activity_30s: rover.activity_30s, + status_tag: rover.status_tag, + activity_score: rover.activity_score, + activity_band: rover.activity_band, + activity_trend: rover.activity_trend, + }; + nextRoverStateById.set(roverId, nextState); + rover.prev_state = prev; + return rover; + }); + lastRoverStateById.clear(); + nextRoverStateById.forEach((value, roverId) => { + lastRoverStateById.set(roverId, value); + }); + return { rovers }; + } + + function collectActiveDriverEntries() { + const fromTurns = Object.entries(getActiveDrivers()).filter(([, socketId]) => Boolean(socketId)); + if (fromTurns.length > 0) return fromTurns; + const fallback = []; + roverManager.rovers.forEach((record, roverId) => { + const socketId = record?.drivers?.values?.().next?.().value || null; + if (socketId) fallback.push([String(roverId), socketId]); + }); + return fallback; + } + + function buildSnapshot() { + const now = new Date(); + const nowMs = now.getTime(); + const driverEntries = collectActiveDriverEntries(); + const { rovers } = buildRoversNow(nowMs); + const roverById = new Map(rovers.map((rover) => [String(rover.id), rover])); + const contextResetAt = getContextResetAt(); + const allRecentMessages = getRecentMessages(300, { includeSystem: true }) + .filter((entry) => Number(entry?.ts) >= contextResetAt) + .filter((entry) => { + const roverId = entry?.roverId ? String(entry.roverId) : null; + if (!roverId) return true; + return roverManager.canReplayRoverId(roverId); + }); + const chatRecent = allRecentMessages + .filter((entry) => !entry?.system) + .slice(-MAX_CHAT_MESSAGES) + .map((entry) => ({ + nickname: entry.nickname || entry.socketId?.slice(0, 6) || 'unknown', + text: entry.text || '', + })); + + const botRecentWindow = allRecentMessages + .filter((entry) => Number(entry?.ts) >= contextResetAt) + .filter((entry) => entry?.system); + const lastBotMessage = botRecentWindow.length ? botRecentWindow[botRecentWindow.length - 1] : null; + const botRecent30m = botRecentWindow.filter( + (entry) => nowMs - Number(entry?.ts || 0) <= SELF_TALK_WINDOW_MS, + ); + + const roverEvents = roverMajorEvents.filter( + (entry) => + Number(entry?.ts) >= contextResetAt && + roverManager.canReplayRoverId(entry?.rover_id || ''), + ); + const timelineEntries = [ + ...allRecentMessages.map((entry) => ({ ts: Number(entry?.ts || 0), source: 'chat', entry })), + ...roverEvents.map((entry) => ({ ts: Number(entry?.ts || 0), source: 'event', entry })), + ] + .sort((a, b) => a.ts - b.ts) + .slice(-MAX_CONTEXT_EVENTS); + + const eventStream = timelineEntries.map(({ source, entry }) => { + if (source === 'event') { + return { + type: 'event', + event_type: entry.event_type || 'rover_event', + rover_id: entry.rover_id || null, + driver_nickname: entry.driver_nickname || null, + summary: entry.summary || '', + }; + } + if (entry?.system) { + return { + type: 'bot', + nickname: entry.nickname || 'Rover Bot', + text: entry.text || '', + }; + } + const roverId = entry?.roverId ? String(entry.roverId) : null; + const rover = roverId ? roverById.get(roverId) : null; + const baseCtx = compactRoverForContext(rover) || {}; + const storedCtx = entry?.roverCtx || entry?.rover_ctx || {}; + return { + type: 'chat', + nickname: entry.nickname || entry.socketId?.slice(0, 6) || 'unknown', + text: entry.text || '', + rover_id: roverId, + rover_ctx: { ...baseCtx, ...storedCtx }, + }; + }); + const hasRecentChat = eventStream.some((event) => event.type === 'chat'); + + const currentSnapshot = { rovers }; + if (!hasRecentChat) { + currentSnapshot.chat_recent = chatRecent; + } + + return { + run_meta: { + version: 'commentary_v2', + self_talk_recent_30m: botRecent30m.length, + skip_streak: getSkipStreak(), + last_message_focus: buildLastMessageFocus(lastBotMessage, rovers), + active_driver_count: driverEntries.length, + driving_rovers: driverEntries.map(([roverId]) => String(roverId)), + }, + event_stream: eventStream, + current_snapshot: currentSnapshot, + }; + } + + function refreshFinalSnapshotForSend(snapshot) { + const { rovers } = buildRoversNow(Date.now()); + return { + ...(snapshot || {}), + current_snapshot: { + ...(snapshot?.current_snapshot || {}), + rovers, + }, + }; + } + + function resetHistory() { + roverMajorEvents.length = 0; + lastSensorFlagsByRover.clear(); + roverActivity.clear(); + lastRoverStateById.clear(); + } + + function removeRover(roverId) { + roverActivity.delete(String(roverId)); + } + + return { + onSensorEvent, + buildSnapshot, + refreshFinalSnapshotForSend, + resetHistory, + removeRover, + }; +} + +module.exports = { + createSnapshotEngine, +};