mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 10:00:46 -04:00
commentary
This commit is contained in:
@@ -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.
|
- 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: 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`).
|
- 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
|
## WebUI frontend
|
||||||
### BIGGEST OFFENDERS
|
### BIGGEST OFFENDERS
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
// Purpose: Defines the llm Commentary Service module and the helpers/state used by this service unit.
|
// 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.
|
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
|
||||||
const fsp = require('fs/promises');
|
const fsp = require('fs/promises');
|
||||||
const path = require('path');
|
|
||||||
const { Ollama } = require('ollama');
|
const { Ollama } = require('ollama');
|
||||||
const io = require('../../globals/io');
|
const io = require('../../globals/io');
|
||||||
const logger = require('../../globals/logger').child('llmCommentary');
|
const logger = require('../../globals/logger').child('llmCommentary');
|
||||||
@@ -12,23 +11,33 @@ const roverManager = require('../roverManager');
|
|||||||
const { getActiveDrivers } = require('../turnService');
|
const { getActiveDrivers } = require('../turnService');
|
||||||
const { getNickname } = require('../nicknameService');
|
const { getNickname } = require('../nicknameService');
|
||||||
const { getRecentMessages, sendSystemMessage } = require('../chatService');
|
const { getRecentMessages, sendSystemMessage } = require('../chatService');
|
||||||
|
const {
|
||||||
const PROMPT_PATH = path.join(__dirname, '..', '..', '..', 'prompts', 'commentary_system.txt');
|
PROMPT_PATH,
|
||||||
const DEFAULT_FREQUENCY_MS = 0;
|
JITTER_MS,
|
||||||
const MIN_FREQUENCY_MS = 0;
|
MAX_ROVERS,
|
||||||
const JITTER_MS = 0;
|
MAX_CHAT_MESSAGES,
|
||||||
const MAX_ROVERS = 6;
|
MAX_BOT_MESSAGES,
|
||||||
const MAX_CHAT_MESSAGES = 4;
|
ACTIVITY_WINDOW_MS,
|
||||||
const MAX_BOT_MESSAGES = 1;
|
ACTIVITY_BUCKET_MS,
|
||||||
const SKIP_TOKEN = 'SKIP';
|
ACTIVITY_SCORE_WINDOW_MS,
|
||||||
const ACTIVITY_WINDOW_MS = 60000;
|
SELF_TALK_WINDOW_MS,
|
||||||
const ACTIVITY_BUCKET_MS = 1000;
|
MAX_CONTEXT_EVENTS,
|
||||||
const ACTIVITY_SCORE_WINDOW_MS = 30000;
|
MAX_RUN_HISTORY,
|
||||||
const SELF_TALK_WINDOW_MS = 30 * 60 * 1000;
|
MAX_ROVER_EVENTS,
|
||||||
const MAX_CONTEXT_EVENTS = 8;
|
POST_COOLDOWN_MS,
|
||||||
const MAX_RUN_HISTORY = 100;
|
normalizeFrequencyMs,
|
||||||
const MAX_ROVER_EVENTS = 400;
|
} = require('./constants');
|
||||||
const POST_COOLDOWN_MS = 10000;
|
const {
|
||||||
|
parseModelOutput,
|
||||||
|
normalizeDuplicateKey,
|
||||||
|
buildModelMessages,
|
||||||
|
} = require('./formatters');
|
||||||
|
const {
|
||||||
|
isAdminRole,
|
||||||
|
buildAdminState,
|
||||||
|
buildFailureInfo,
|
||||||
|
} = require('./runtimeHelpers');
|
||||||
|
const { createSnapshotEngine } = require('./snapshotEngine');
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const commentaryConfig = config.llmCommentary || {};
|
const commentaryConfig = config.llmCommentary || {};
|
||||||
@@ -49,17 +58,23 @@ let contextResetAt = Date.now();
|
|||||||
let clearCount = 0;
|
let clearCount = 0;
|
||||||
let runHistory = [];
|
let runHistory = [];
|
||||||
let currentRun = null;
|
let currentRun = null;
|
||||||
const roverActivity = new Map(); // roverId -> { buckets: Map(bucketTs -> { distanceMm, turnDeg, bumps }), bumpLeftActive, bumpRightActive }
|
const snapshotEngine = createSnapshotEngine({
|
||||||
const roverMajorEvents = []; // [{ ts, type: 'event', event_type, rover_id, driver_nickname, summary }]
|
io,
|
||||||
const lastSensorFlagsByRover = new Map(); // roverId -> { docked, charging, battery_low, wheels_off_ground }
|
roverManager,
|
||||||
const lastRoverStateById = new Map(); // roverId -> compact rover state used as prev_state
|
getActiveDrivers,
|
||||||
|
getNickname,
|
||||||
function normalizeFrequencyMs(value) {
|
getRecentMessages,
|
||||||
if (!Number.isFinite(value)) return DEFAULT_FREQUENCY_MS;
|
MAX_ROVERS,
|
||||||
// If frequency is configured as a small integer, treat it as seconds for convenience.
|
MAX_CHAT_MESSAGES,
|
||||||
const parsed = value > 0 && value < 1000 ? value * 1000 : value;
|
ACTIVITY_WINDOW_MS,
|
||||||
return Math.max(MIN_FREQUENCY_MS, Math.floor(parsed));
|
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));
|
const frequencyMs = normalizeFrequencyMs(Number(commentaryConfig.frequency ?? commentaryConfig.frequencyMs));
|
||||||
let status = {
|
let status = {
|
||||||
@@ -105,62 +120,6 @@ let status = {
|
|||||||
updatedAt: Date.now(),
|
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 = {}) {
|
function updatePhase(phase, patch = {}) {
|
||||||
updateStatus({
|
updateStatus({
|
||||||
phase,
|
phase,
|
||||||
@@ -222,16 +181,16 @@ function finalizeRunRecord({ outcome, reason, errors } = {}) {
|
|||||||
function isAdminSocket(socket) {
|
function isAdminSocket(socket) {
|
||||||
if (!socket) return false;
|
if (!socket) return false;
|
||||||
const role = getRole(socket);
|
const role = getRole(socket);
|
||||||
return role === 'admin' || role === 'lockdown' || role === 'lockdown-admin';
|
return isAdminRole(role);
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitStatusToSocket(socket) {
|
function emitStatusToSocket(socket) {
|
||||||
if (!socket || !isAdminSocket(socket)) return;
|
if (!socket || !isAdminSocket(socket)) return;
|
||||||
socket.emit('llm:state', buildAdminState());
|
socket.emit('llm:state', buildAdminState(status, runHistory));
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitStatusToAdmins() {
|
function emitStatusToAdmins() {
|
||||||
const payload = buildAdminState();
|
const payload = buildAdminState(status, runHistory);
|
||||||
io.sockets.sockets.forEach((socket) => {
|
io.sockets.sockets.forEach((socket) => {
|
||||||
if (!isAdminSocket(socket)) return;
|
if (!isAdminSocket(socket)) return;
|
||||||
socket.emit('llm:state', payload);
|
socket.emit('llm:state', payload);
|
||||||
@@ -251,679 +210,6 @@ function updateStatus(patch = {}) {
|
|||||||
emitStatusToAdmins();
|
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() {
|
async function readSystemPrompt() {
|
||||||
const prompt = await fsp.readFile(PROMPT_PATH, 'utf8');
|
const prompt = await fsp.readFile(PROMPT_PATH, 'utf8');
|
||||||
const trimmed = prompt.trim();
|
const trimmed = prompt.trim();
|
||||||
@@ -938,48 +224,6 @@ async function readSystemPrompt() {
|
|||||||
return trimmed;
|
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) {
|
async function generateCommentary(messages) {
|
||||||
if (!ollamaClient) {
|
if (!ollamaClient) {
|
||||||
throw new Error('Ollama client unavailable');
|
throw new Error('Ollama client unavailable');
|
||||||
@@ -1042,7 +286,7 @@ async function runTick() {
|
|||||||
}
|
}
|
||||||
inFlight = true;
|
inFlight = true;
|
||||||
try {
|
try {
|
||||||
const snapshot = buildSnapshot();
|
const snapshot = snapshotEngine.buildSnapshot();
|
||||||
const snapshotSummary = {
|
const snapshotSummary = {
|
||||||
activeDrivers: snapshot?.run_meta?.active_driver_count || 0,
|
activeDrivers: snapshot?.run_meta?.active_driver_count || 0,
|
||||||
rovers: snapshot?.current_snapshot?.rovers?.length || 0,
|
rovers: snapshot?.current_snapshot?.rovers?.length || 0,
|
||||||
@@ -1068,7 +312,7 @@ async function runTick() {
|
|||||||
lastInfoSnapshot: snapshot,
|
lastInfoSnapshot: snapshot,
|
||||||
});
|
});
|
||||||
const systemPrompt = await readSystemPrompt();
|
const systemPrompt = await readSystemPrompt();
|
||||||
const snapshotForSend = refreshFinalSnapshotForSend(snapshot);
|
const snapshotForSend = snapshotEngine.refreshFinalSnapshotForSend(snapshot);
|
||||||
const modelMessages = buildModelMessages(systemPrompt, snapshotForSend);
|
const modelMessages = buildModelMessages(systemPrompt, snapshotForSend);
|
||||||
const modelInputAt = Date.now();
|
const modelInputAt = Date.now();
|
||||||
patchCurrentRun({
|
patchCurrentRun({
|
||||||
@@ -1275,7 +519,7 @@ io.on('connection', (socket) => {
|
|||||||
const command = controls?.action || null;
|
const command = controls?.action || null;
|
||||||
if (command === 'clearHistory') {
|
if (command === 'clearHistory') {
|
||||||
clearRuntimeHistory();
|
clearRuntimeHistory();
|
||||||
cb({ success: true, state: buildAdminState() });
|
cb({ success: true, state: buildAdminState(status, runHistory) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
cb({ error: 'Unknown llm control action' });
|
cb({ error: 'Unknown llm control action' });
|
||||||
@@ -1286,10 +530,10 @@ roleEvents.on('change', ({ socket }) => {
|
|||||||
emitStatusToSocket(socket);
|
emitStatusToSocket(socket);
|
||||||
});
|
});
|
||||||
|
|
||||||
roverManager.managerEvents.on('sensor', onSensorEvent);
|
roverManager.managerEvents.on('sensor', snapshotEngine.onSensorEvent);
|
||||||
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
|
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
|
||||||
if (action === 'removed' && roverId) {
|
if (action === 'removed' && roverId) {
|
||||||
roverActivity.delete(String(roverId));
|
snapshotEngine.removeRover(roverId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
roverManager.managerEvents.on('driver', ({ action } = {}) => {
|
roverManager.managerEvents.on('driver', ({ action } = {}) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user