Files
MultiRoombaRover/server/src/services/overseerControlService/index.js
T
2026-09-14 17:56:53 -04:00

857 lines
30 KiB
JavaScript

const fsp = require('fs/promises');
const { Ollama } = require('ollama');
const io = require('../../globals/io');
const logger = require('../../globals/logger').child('overseerControl');
const { loadConfig, registerConfigurationHandler } = require('../../configuration');
const { getRole, roleEvents } = require('../roleService');
const { getMode, MODES, modeEvents } = require('../modeManager');
const { verificationEvents } = require('../verificationService');
const homeAssistantService = require('../homeAssistantService');
const neatoService = require('../neatoService');
const liftService = require('../liftService');
const buttonBoxService = require('../buttonBoxService');
const { getState: getHomeAssistantState, homeAssistantEvents } = homeAssistantService;
const { getState: getNeatoState, neatoEvents } = neatoService;
const { getState: getLiftState, liftEvents } = liftService;
const roverManager = require('../roverManager');
const { getRecentMessages, sendSystemMessage } = require('../chatService');
const { isPublicChatTargetId } = require('../chatService/contextBuilders');
const { subscribe } = require('../eventBus');
const {
PROMPT_PATH,
DEFAULT_NAME,
DEFAULT_GATE_INTERVAL_MS,
MAX_RUN_HISTORY,
MAX_CHAT_CONTEXT,
MAX_BOT_CONTEXT,
normalizeMs,
} = require('./constants');
const { isAdminRole, buildAdminState, buildFailureInfo } = require('./runtimeHelpers');
const { toStateUpdate, buildToolState, buildConversation, buildModelMessages } = require('./contextBuilder');
const { buildOllamaTools, executeToolAction } = require('./tools');
const { loadMemory, saveMemory, createDefaultMemory, summarizeMemory } = require('./memoryStore');
const RUN_MODE_AUTONOMOUS = 'autonomous';
const RUN_MODE_DIRECT_ADDRESS = 'directAddress';
const RUN_MODES = new Set([RUN_MODE_AUTONOMOUS, RUN_MODE_DIRECT_ADDRESS]);
let enabled;
let observeOnly;
let name;
let runMode;
let autonomousMode;
let directAddressMode;
let model;
let ollamaUrl;
let gateIntervalMs;
let postToolsOnlyMessages;
let tiebreakerEnable;
let runWhileNoPeopleOnline;
let profileImageUrl;
let ollamaClient;
let normalizedConfiguredName;
function normalizeNameMentionText(value) {
return String(value || '')
.toLowerCase()
// Mention detection is intentionally literal to the configured name. The
// only normalization here is presentation-level cleanup so punctuation and
// repeated whitespace do not decide whether the configured name appears.
.replace(/[^a-z0-9]+/g, ' ')
.trim()
.replace(/\s+/g, ' ');
}
function applyOverseerConfig(overseerConfig = {}) {
enabled = Boolean(overseerConfig.enabled);
observeOnly = overseerConfig.observeOnly !== false;
name = String(overseerConfig.name || DEFAULT_NAME).trim() || DEFAULT_NAME;
const configuredRunMode = String(overseerConfig.mode || RUN_MODE_AUTONOMOUS).trim();
runMode = RUN_MODES.has(configuredRunMode) ? configuredRunMode : RUN_MODE_AUTONOMOUS;
autonomousMode = runMode === RUN_MODE_AUTONOMOUS;
directAddressMode = runMode === RUN_MODE_DIRECT_ADDRESS;
model = String(overseerConfig.model || '').trim();
ollamaUrl = String(overseerConfig.ollamaServer || '').trim();
gateIntervalMs = normalizeMs(Number(overseerConfig.gateIntervalMs), DEFAULT_GATE_INTERVAL_MS);
postToolsOnlyMessages = Boolean(overseerConfig.postToolsOnlyMessages);
tiebreakerEnable = Boolean(overseerConfig.tiebreakerEnable);
runWhileNoPeopleOnline = Boolean(overseerConfig.runWhileNoPeopleOnline);
profileImageUrl = String(overseerConfig.profileImageUrl || '').trim() || null;
ollamaClient = ollamaUrl ? new Ollama({ host: ollamaUrl }) : null;
normalizedConfiguredName = normalizeNameMentionText(name);
}
applyOverseerConfig(loadConfig().overseerControl || {});
const runtime = {
timer: null,
inFlight: false,
tickCount: 0,
generationCount: 0,
generationTotalMs: 0,
runHistory: [],
liveToolCalls: [],
pendingDirectAddressRun: false,
memoryStore: loadMemory(),
contextResetAt: Date.now(),
};
let status = {
enabled,
runMode,
observeOnly,
name,
model,
ollamaUrl,
promptPath: PROMPT_PATH,
gateIntervalMs,
postToolsOnlyMessages,
tiebreakerEnable,
runWhileNoPeopleOnline,
running: false,
inFlight: false,
phase: 'idle',
phaseAt: Date.now(),
tickCount: 0,
currentRunId: null,
nextRunAt: null,
lastTickAt: null,
lastTriggerReason: null,
lastSystemPrompt: null,
lastStateUpdate: null,
lastTranscript: null,
lastAvailableTools: null,
lastBlockedTools: null,
lastModelMessages: null,
lastModelInputAt: null,
lastModelOutputAt: null,
lastModelRawOutput: null,
lastDecision: null,
lastChatDraft: null,
lastRequestedActions: null,
lastActionResults: null,
lastLiveToolCalls: null,
lastOutcome: null,
lastReason: null,
voteStatus: null,
lastError: null,
lastErrorDetails: null,
lastFailedAt: null,
lastGenerationMs: null,
avgGenerationMs: null,
generationCount: 0,
updatedAt: Date.now(),
};
function buildVoteStatus() {
const sockets = Array.from(io.sockets.sockets.values());
let yesCount = 0;
let noCount = 0;
const votesByIdentity = new Map();
const isEligibleVoter = (socket) => getRole(socket) !== 'spectator';
sockets.forEach((socket) => {
if (!isEligibleVoter(socket)) return;
const identityKey = String(socket?.data?.userId || '').trim() || String(socket?.data?.cookieUserId || '').trim() || `socket:${socket.id}`;
const pref = typeof socket?.data?.overseerEnabled === 'boolean' ? socket.data.overseerEnabled : true;
const prev = votesByIdentity.get(identityKey);
if (typeof prev === 'boolean') {
// If one tab says "no", treat that user as "no" to avoid accidental override by stale tabs.
votesByIdentity.set(identityKey, prev && pref);
return;
}
votesByIdentity.set(identityKey, pref);
});
votesByIdentity.forEach((pref) => {
if (pref) yesCount += 1;
else noCount += 1;
});
const eligibleCount = votesByIdentity.size;
const onlineCount = yesCount + noCount;
let gatePassed = false;
if (eligibleCount === 0) {
gatePassed = runWhileNoPeopleOnline;
} else if (yesCount === noCount) {
gatePassed = tiebreakerEnable;
} else {
gatePassed = yesCount > noCount;
}
return {
// This flag is part of the public vote snapshot because the browser needs
// to distinguish "the service is disabled in server config" from "the
// service is available but currently stopped by votes or lockdown mode."
// Without this explicit value, the UI can only infer stopped/running state
// and will keep showing vote controls that cannot actually start anything.
enabled,
runMode,
// Voting is only meaningful for the autonomous scheduler. Direct-address
// mode is server-controlled and runs only after a user intentionally starts
// a chat message with the configured overseer name, so the public vote UI
// should hide instead of offering controls that do not affect execution.
votingEnabled: Boolean(enabled && autonomousMode),
yesCount,
noCount,
onlineCount,
eligibleCount,
gatePassed,
running: Boolean(enabled && autonomousMode && gatePassed && getMode() !== MODES.LOCKDOWN),
};
}
function updateStatus(patch = {}) {
status = { ...status, ...patch, updatedAt: Date.now() };
const payload = buildAdminState(status, runtime.runHistory);
io.sockets.sockets.forEach((socket) => {
if (!isAdminRole(getRole(socket))) return;
socket.emit('overseer:state', payload);
});
}
function buildMemorySnapshot() {
// The public UI only needs the same compact text summary that the overseer
// already reasons over. Keeping this as text avoids exposing the raw mutable
// store shape as a browser-facing contract.
return {
summary: summarizeMemory(runtime.memoryStore),
updatedAt: Number(runtime.memoryStore?.updatedAt) || Date.now(),
};
}
function emitMemoryToSocket(socket) {
if (!socket) return;
socket.emit('overseer:memory', buildMemorySnapshot());
}
function emitMemoryToAll() {
// Memory writes are rare and user-visible, so broadcast immediately instead
// of waiting for unrelated session sync events to refresh the popup.
io.sockets.sockets.forEach((socket) => emitMemoryToSocket(socket));
}
function pushRun(run = {}) {
runtime.runHistory = [...runtime.runHistory.slice(-(MAX_RUN_HISTORY - 1)), run];
}
function pushLiveToolCall(entry = {}) {
runtime.liveToolCalls = [...runtime.liveToolCalls.slice(-49), { at: Date.now(), ...entry }];
updateStatus({ lastLiveToolCalls: runtime.liveToolCalls });
}
async function readPrompt() {
const raw = await fsp.readFile(PROMPT_PATH, 'utf8');
const prompt = String(raw || '').replace(/<NAME>/g, name).trim();
if (!prompt) throw new Error(`Prompt file empty: ${PROMPT_PATH}`);
return prompt;
}
function buildRosterSummary() {
return roverManager
.getRoster()
.filter((rover) => roverManager.canReplayRoverId(rover?.id))
.map((rover) => {
const roverId = String(rover?.id || '');
const record = roverManager.rovers.get(roverId);
const driverSocketIds = record?.drivers ? Array.from(record.drivers) : [];
const drivers = driverSocketIds
.map((socketId) => {
const socket = io.sockets.sockets.get(socketId);
const nickname = String(socket?.data?.nickname || socket?.data?.user?.username || '').trim();
return nickname || socketId;
})
.filter(Boolean);
const sensors = record?.lastSensor?.decoded || {};
const docked = Boolean(record?.docked || sensors?.chargingSources?.homeBase);
const oiMode = String(sensors?.oiMode?.label || '').toLowerCase();
let statusTag = 'docking';
if (docked) {
statusTag = 'docked';
} else if (oiMode === 'safe' || oiMode === 'full') {
statusTag = 'driving';
} else if (oiMode === 'passive' || oiMode === 'off' || oiMode === 'unknown' || !oiMode) {
statusTag = 'docking';
}
return {
id: roverId || 'unknown',
statusTag,
drivers,
};
});
}
function normalizeToolCalls(payload = null) {
const calls = Array.isArray(payload?.message?.tool_calls) ? payload.message.tool_calls : [];
return calls
.map((call) => {
const fn = call?.function || {};
const tool = String(fn.name || '').trim();
if (!tool) return null;
let args = fn.arguments;
if (typeof args === 'string') {
try {
args = JSON.parse(args);
} catch {
args = {};
}
}
if (!args || typeof args !== 'object') args = {};
return { tool, args };
})
.filter(Boolean);
}
function inferDecision({ toolCalls, chatText }) {
const hasTools = (toolCalls || []).length > 0;
const hasChat = Boolean(String(chatText || '').trim());
if (hasTools && hasChat) return 'ACTION+CHAT';
if (hasTools) return 'ACTION';
if (hasChat) return 'CHAT';
return 'SKIP';
}
function normalizeChatDraft(text) {
const next = String(text || '').trim();
if (!next) return null;
if (next.toUpperCase() === 'SKIP') return null;
return next;
}
function mentionsConfiguredName(text) {
if (!normalizedConfiguredName) return false;
const normalizedMessage = normalizeNameMentionText(text);
if (!normalizedMessage) return false;
// Pad both sides with spaces so a configured name only matches as a whole
// normalized phrase. For example, "The Overseer" matches "hey, The Overseer"
// but not "The Overseerish".
return ` ${normalizedMessage} `.includes(` ${normalizedConfiguredName} `);
}
function summarizeResult(result) {
if (!result || typeof result !== 'object') return null;
if (Object.prototype.hasOwnProperty.call(result, 'ok')) return { ok: Boolean(result.ok) };
return null;
}
function buildToolCallFeedEntries(requestedActions = [], actionResults = []) {
const resultsByTool = new Map();
(actionResults || []).forEach((entry) => {
if (!entry || entry.kind !== 'tool') return;
const key = String(entry.tool || '');
if (!key) return;
if (!resultsByTool.has(key)) resultsByTool.set(key, []);
resultsByTool.get(key).push(entry);
});
return (requestedActions || []).map((action) => {
const tool = String(action?.tool || '').trim() || 'unknown';
const bucket = resultsByTool.get(tool) || [];
const resultEntry = bucket.length ? bucket.shift() : null;
const ok = Boolean(resultEntry?.ok);
const errText = resultEntry?.error ? String(resultEntry.error) : '';
const status = resultEntry
? ok
? 'ok'
: errText.includes('blocked') || errText.includes('unavailable')
? 'blocked'
: 'error'
: 'started';
return {
tool,
status,
args: action?.args && typeof action.args === 'object' ? action.args : {},
result: summarizeResult(resultEntry?.result),
error: errText || null,
};
});
}
function buildRecentEventsSummary() {
const events = (runtime.liveToolCalls || [])
.filter((entry) => entry && (entry.phase === 'ok' || entry.phase === 'error' || entry.phase === 'blocked'))
.slice(-3)
.map((entry) => {
const tool = String(entry.tool || 'unknown');
const phase = String(entry.phase || 'unknown');
const err = entry.error ? ` error=${String(entry.error).slice(0, 60)}` : '';
return `- tool=${tool} phase=${phase}${err}`;
});
if (!events.length) return '- none';
return events.join('\n');
}
async function runDecision(triggerReason) {
const runId = runtime.tickCount;
updateStatus({ phase: 'context_build', currentRunId: runId, lastTriggerReason: triggerReason, lastError: null, lastErrorDetails: null });
const mode = getMode();
const homeAssistantState = getHomeAssistantState();
const neatoState = getNeatoState();
const liftState = getLiftState();
const roster = buildRosterSummary();
const stateUpdate = toStateUpdate({ mode, homeAssistantState, neatoState, liftState, roster, triggerReason });
const toolState = buildToolState({ mode, homeAssistantState, neatoState, liftState });
const recentConversation = getRecentMessages(MAX_CHAT_CONTEXT + MAX_BOT_CONTEXT + 20, { includeSystem: true })
.filter((entry) => Number(entry?.ts || 0) >= runtime.contextResetAt)
.filter((entry) => {
if (!entry?.roverId) return true;
/*
Chat context should preserve every public chat target, including the
PTZ virtual rover. Rover replay visibility alone would drop PTZ because
it is owned by ptzCameraService instead of roverManager.
*/
return isPublicChatTargetId(entry.roverId);
})
.slice(-(MAX_CHAT_CONTEXT + MAX_BOT_CONTEXT));
const conversationMessages = buildConversation({ recentMessages: recentConversation, name });
const systemPrompt = await readPrompt();
const modelMessages = buildModelMessages({
systemPrompt,
stateUpdate,
memorySummary: summarizeMemory(runtime.memoryStore),
recentEvents: buildRecentEventsSummary(),
conversationMessages,
availableTools: toolState.available,
blockedTools: toolState.blocked,
});
const ollamaTools = buildOllamaTools(toolState.availableIds);
updateStatus({
phase: 'awaiting_model',
lastSystemPrompt: systemPrompt,
lastStateUpdate: stateUpdate,
lastTranscript: conversationMessages,
lastAvailableTools: toolState.available,
lastBlockedTools: toolState.blocked,
lastModelMessages: modelMessages,
lastModelInputAt: Date.now(),
});
const generationStart = Date.now();
let payload = null;
if (ollamaClient && model) {
payload = await ollamaClient.chat({
model,
stream: false,
keep_alive: -1,
// options: { temperature: 0.25, top_p: 0.9 },
messages: modelMessages,
tools: ollamaTools,
});
}
const rawOutput = String(payload?.message?.content || '');
const toolCalls = normalizeToolCalls(payload);
const chatDraft = normalizeChatDraft(rawOutput);
const decision = inferDecision({ toolCalls, chatText: chatDraft });
const generationMs = Math.max(0, Date.now() - generationStart);
runtime.generationCount += 1;
runtime.generationTotalMs += generationMs;
const avgGenerationMs = Math.round(runtime.generationTotalMs / runtime.generationCount);
const actionResults = [];
const requestedActions = toolCalls;
let postedChat = false;
let outcome = observeOnly ? 'observed' : 'executed';
const reason = observeOnly ? 'observe-only mode' : null;
if (!observeOnly) {
if (decision === 'ACTION' || decision === 'ACTION+CHAT') {
for (const action of requestedActions) {
pushLiveToolCall({ phase: 'start', tool: action.tool, args: action.args });
if (!toolState.availableIds.includes(action.tool)) {
pushLiveToolCall({ phase: 'blocked', tool: action.tool, error: 'tool unavailable or blocked' });
actionResults.push({ kind: 'tool', tool: action.tool, ok: false, error: 'tool unavailable or blocked' });
continue;
}
try {
const result = await executeToolAction(action.tool, action.args, {
sendSystemMessage,
name,
memoryStore: runtime.memoryStore,
neatoService,
liftService,
homeAssistantService,
buttonBoxService,
actor: 'overseerControl',
});
if (result?.memory && typeof result.memory === 'object') {
runtime.memoryStore = saveMemory(result.memory);
emitMemoryToAll();
}
pushLiveToolCall({ phase: 'ok', tool: action.tool, result });
actionResults.push({ kind: 'tool', tool: action.tool, ok: true, result });
} catch (err) {
pushLiveToolCall({ phase: 'error', tool: action.tool, error: err.message });
actionResults.push({ kind: 'tool', tool: action.tool, ok: false, error: err.message });
}
}
}
const toolCallFeed = buildToolCallFeedEntries(requestedActions, actionResults);
if (toolCallFeed.length > 0) {
if ((decision === 'CHAT' || decision === 'ACTION+CHAT') && chatDraft) {
sendSystemMessage(chatDraft, { nickname: name, bot: true, profileImage: profileImageUrl, toolCalls: toolCallFeed });
postedChat = true;
} else if (postToolsOnlyMessages) {
sendSystemMessage('', { nickname: name, bot: true, profileImage: profileImageUrl, toolCalls: toolCallFeed });
}
} else if ((decision === 'CHAT' || decision === 'ACTION+CHAT') && chatDraft) {
sendSystemMessage(chatDraft, { nickname: name, bot: true, profileImage: profileImageUrl });
postedChat = true;
}
}
updateStatus({
phase: 'decision_recorded',
lastModelOutputAt: Date.now(),
lastModelRawOutput: rawOutput,
lastDecision: decision,
lastChatDraft: chatDraft,
lastRequestedActions: requestedActions,
lastActionResults: actionResults,
lastOutcome: outcome,
lastReason: reason,
lastGenerationMs: generationMs,
avgGenerationMs,
generationCount: runtime.generationCount,
});
pushRun({
runId,
at: Date.now(),
triggerReason,
decision,
chatDraft,
requestedActions,
actionResults,
outcome,
observeOnly,
generationMs,
blockedTools: toolState.blocked,
});
return { postedChat };
}
async function tick() {
// The scheduler is deliberately loop-only. Older versions mixed chat-triggered
// and heartbeat-triggered paths into this same timer, which made it possible
// for one slow model generation to be followed by another run that was still
// reasoning over the same chat message. In the loop model, every completed
// timer means exactly one model request, and the next timer is not installed
// until the current request has completely finished.
const startedAt = Date.now();
// setTimeout handles are one-shot. Clearing the reference at the beginning of
// the callback makes scheduler ownership obvious: if any gate reevaluation
// happens while this tick is running, startScheduler can see that work is
// already in-flight and will not create a competing timer.
runtime.timer = null;
if (runtime.inFlight) {
// This is a defensive guard for unusual event-loop races or manual calls.
// Sending the same model input twice is worse than skipping one interval,
// so a re-entrant tick is ignored and the active tick remains responsible
// for scheduling the next pass.
updateStatus({
phase: 'idle',
lastOutcome: 'skipped',
lastReason: 'loop tick skipped while model request is in flight',
});
return;
}
runtime.tickCount += 1;
runtime.inFlight = true;
updateStatus({
inFlight: true,
tickCount: runtime.tickCount,
lastTickAt: startedAt,
phase: 'loop_tick',
lastTriggerReason: 'loop_tick',
});
try {
await runDecision('loop_tick');
} catch (err) {
const failure = buildFailureInfo(err);
updateStatus({
phase: 'failed',
lastError: failure.message,
lastErrorDetails: failure.details,
lastFailedAt: Date.now(),
lastOutcome: 'failed',
lastReason: 'exception',
});
} finally {
runtime.inFlight = false;
if (status.running) {
const nextRunAt = Date.now() + gateIntervalMs;
updateStatus({ inFlight: false, currentRunId: null, phase: 'idle', nextRunAt });
// The next timer is installed after all async work completes. This keeps
// Ollama calls strictly serialized even when a generation takes longer
// than gateIntervalMs.
runtime.timer = setTimeout(tick, gateIntervalMs);
} else {
updateStatus({ inFlight: false, currentRunId: null, nextRunAt: null });
}
}
}
function getDirectAddressSkipReason() {
if (!enabled) return 'overseerControl.enabled is false';
if (!directAddressMode) return 'overseerControl.mode is not directAddress';
if (getMode() === MODES.LOCKDOWN) return 'paused during lockdown';
return null;
}
async function runDirectAddressCycle(triggerReason = 'direct_address') {
const skipReason = getDirectAddressSkipReason();
if (skipReason) {
updateStatus({
lastOutcome: 'skipped',
lastReason: skipReason,
});
return;
}
if (runtime.inFlight) {
// Direct-address mode should not create overlapping Ollama requests. If a
// user addresses the overseer while a generation is active, remember that a
// follow-up pass is needed and collapse any additional mentions into that
// single pending pass. This keeps chat responsive without stampeding the
// local model server.
runtime.pendingDirectAddressRun = true;
updateStatus({
lastOutcome: 'queued',
lastReason: 'direct-address request queued while model request is in flight',
});
return;
}
do {
runtime.pendingDirectAddressRun = false;
const startedAt = Date.now();
runtime.tickCount += 1;
runtime.inFlight = true;
updateStatus({
running: true,
inFlight: true,
tickCount: runtime.tickCount,
lastTickAt: startedAt,
phase: 'direct_address_tick',
lastTriggerReason: triggerReason,
nextRunAt: null,
});
try {
await runDecision(triggerReason);
} catch (err) {
const failure = buildFailureInfo(err);
updateStatus({
phase: 'failed',
lastError: failure.message,
lastErrorDetails: failure.details,
lastFailedAt: Date.now(),
lastOutcome: 'failed',
lastReason: 'exception',
});
} finally {
runtime.inFlight = false;
updateStatus({
running: false,
inFlight: false,
currentRunId: null,
nextRunAt: null,
phase: getDirectAddressSkipReason() ? 'paused' : 'idle',
});
}
} while (runtime.pendingDirectAddressRun && !getDirectAddressSkipReason());
}
function emitStateToSocket(socket) {
if (!socket || !isAdminRole(getRole(socket))) return;
socket.emit('overseer:state', buildAdminState(status, runtime.runHistory));
}
function clearHistory(reason = 'admin requested clear history', options = {}) {
const resetPersistentMemory = options?.resetPersistentMemory !== false;
const sendConfirmation = options?.sendConfirmation === true;
runtime.contextResetAt = Date.now();
runtime.runHistory = [];
runtime.liveToolCalls = [];
runtime.generationCount = 0;
runtime.generationTotalMs = 0;
if (resetPersistentMemory) {
runtime.memoryStore = saveMemory(createDefaultMemory());
emitMemoryToAll();
}
updateStatus({
phase: 'idle',
currentRunId: null,
lastSystemPrompt: null,
lastStateUpdate: null,
lastTranscript: null,
lastAvailableTools: null,
lastBlockedTools: null,
lastModelMessages: null,
lastModelInputAt: null,
lastModelOutputAt: null,
lastModelRawOutput: null,
lastDecision: null,
lastChatDraft: null,
lastRequestedActions: null,
lastActionResults: null,
lastLiveToolCalls: [],
lastOutcome: 'cleared',
lastReason: reason,
});
if (sendConfirmation) {
sendSystemMessage('Context cleared.', { nickname: name, bot: true, profileImage: profileImageUrl });
}
}
function stopScheduler(reason = 'paused') {
if (runtime.timer) {
clearTimeout(runtime.timer);
runtime.timer = null;
}
updateStatus({
running: false,
// Stopping the scheduler cancels future work, but an already-started model
// request may still be finishing. Preserve the in-flight flag/current run
// so admin state does not briefly claim the service is idle while a direct
// or autonomous decision is still awaiting Ollama.
inFlight: runtime.inFlight,
currentRunId: runtime.inFlight ? status.currentRunId : null,
nextRunAt: null,
phase: runtime.inFlight ? status.phase : 'paused',
lastOutcome: 'paused',
lastReason: reason,
});
}
function startScheduler(reason = null) {
// Gate changes can happen while a loop iteration is still waiting on Ollama.
// In that case we mark the service as running again, but let the active tick's
// finally block install the next timer. That preserves a single owner for
// scheduling and prevents overlapping model requests.
updateStatus({ running: true, phase: runtime.inFlight ? status.phase : 'idle', lastReason: reason });
if (runtime.timer || runtime.inFlight) return;
runtime.timer = setTimeout(tick, gateIntervalMs);
}
function evaluateSchedulerGate(reason = 'gate reevaluated') {
const voteStatus = buildVoteStatus();
updateStatus({ voteStatus });
if (!enabled) {
stopScheduler('overseerControl.enabled is false');
return;
}
if (directAddressMode) {
stopScheduler('direct-address mode waits for configured name mention');
return;
}
if (getMode() === MODES.LOCKDOWN) {
stopScheduler('paused during lockdown');
return;
}
if (!voteStatus.gatePassed) {
stopScheduler('paused by user vote');
return;
}
startScheduler(reason);
}
io.on('connection', (socket) => {
emitStateToSocket(socket);
emitMemoryToSocket(socket);
evaluateSchedulerGate('online vote update');
socket.on('disconnect', () => evaluateSchedulerGate('online vote update'));
socket.on('overseer:control', ({ controls } = {}, cb = () => {}) => {
if (!isAdminRole(getRole(socket))) return cb({ error: 'Not authorized' });
const action = controls?.action || null;
if (action === 'clearHistory') {
clearHistory();
return cb({ success: true, state: buildAdminState(status, runtime.runHistory) });
}
return cb({ error: 'Unknown overseer control action' });
});
});
roleEvents.on('change', ({ socket }) => {
emitStateToSocket(socket);
evaluateSchedulerGate('online vote update');
});
subscribe('chat:message', ({ payload } = {}) => {
const text = String(payload?.text || '').trim();
if (payload?.bot) return;
if (text === 'CLEAR') {
clearHistory('chat CLEAR command', { resetPersistentMemory: false, sendConfirmation: true });
return;
}
if (!directAddressMode) return;
if (!mentionsConfiguredName(text)) return;
void runDirectAddressCycle('direct_address_chat');
});
verificationEvents.on('change', () => evaluateSchedulerGate('online vote update'));
homeAssistantEvents.on('update', () => updateStatus({ phase: status.phase }));
neatoEvents.on('update', () => updateStatus({ phase: status.phase }));
liftEvents.on('update', () => updateStatus({ phase: status.phase }));
roverManager.managerEvents.on('rover', () => updateStatus({ phase: status.phase }));
modeEvents.on('change', (mode) => {
if (!enabled) return;
if (mode === MODES.LOCKDOWN) {
stopScheduler('paused during lockdown');
logger.info('overseerControl paused due to lockdown mode');
return;
}
evaluateSchedulerGate(observeOnly ? 'observe-only mode' : null);
});
registerConfigurationHandler('overseerControl', (overseerConfig = {}) => {
// All scheduler and model parameters are one runtime unit. Cancel the old
// cadence, replace them atomically, and let the normal vote/mode gate decide
// whether the newly configured scheduler should run.
stopScheduler('configuration changed');
applyOverseerConfig(overseerConfig);
updateStatus({
enabled,
runMode,
observeOnly,
name,
model,
ollamaUrl,
gateIntervalMs,
postToolsOnlyMessages,
tiebreakerEnable,
runWhileNoPeopleOnline,
});
evaluateSchedulerGate('configuration changed');
});
if (!enabled) {
logger.info('overseerControl disabled');
updateStatus({ running: false, lastReason: 'overseerControl.enabled is false' });
} else {
if (getMode() === MODES.LOCKDOWN) {
stopScheduler('paused during lockdown');
logger.info('overseerControl paused on startup due to lockdown mode');
} else if (directAddressMode) {
evaluateSchedulerGate('direct-address mode');
logger.info('overseerControl direct-address mode enabled', { model, ollamaUrl, observeOnly, name });
} else {
evaluateSchedulerGate(observeOnly ? 'observe-only mode' : null);
logger.info('overseerControl enabled', { model, ollamaUrl, gateIntervalMs, observeOnly, runMode });
}
}
module.exports = {
getVoteStatus: () => status.voteStatus || buildVoteStatus(),
};