mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
485 lines
18 KiB
JavaScript
485 lines
18 KiB
JavaScript
// 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?.bot)
|
|
.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?.bot);
|
|
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?.bot) {
|
|
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,
|
|
};
|