overseer v2 3

This commit is contained in:
legop3
2026-05-03 23:20:09 -04:00
parent e97b623b43
commit 5b48b10971
16 changed files with 196 additions and 118 deletions
+2 -2
View File
@@ -8,13 +8,13 @@ const { broadcastMessage, getRecentMessages } = require('./broadcast');
const { createHandlers } = require('./handlers');
const { registerChatSocketHooks } = require('./socketHooks');
function sendSystemMessage(text) {
function sendSystemMessage(text, options = {}) {
const normalized = normalizeUserText(text);
const clean = normalized.trim();
if (!clean) return null;
const safe = clean.length > 256 ? `${clean.slice(0, 253)}...` : clean;
const message = buildMessage(null, safe, {
nickname: 'The Overseer',
nickname: String(options.nickname || 'The Overseer'),
role: 'user',
fromDiscord: false,
system: true,
@@ -51,7 +51,8 @@ function buildModelMessages({ systemPrompt, stateUpdate, transcriptRows, availab
});
messages.push({
role: 'user',
content: 'Respond with one line: SKIP, CHAT, ACTION, or ACTION+CHAT.',
content:
'Respond with JSON only: {"decision":"SKIP|CHAT|ACTION|ACTION+CHAT","chat":"optional text","actions":[{"tool":"tool_id","args":{}}]}. Use action tool ids only.',
});
return messages;
}
@@ -5,11 +5,14 @@ const logger = require('../../globals/logger').child('overseerControl');
const { loadConfig } = require('../../helpers/configLoader');
const { getRole, roleEvents } = require('../roleService');
const { getMode } = require('../modeManager');
const { getState: getHomeAssistantState, homeAssistantEvents } = require('../homeAssistantService');
const { getState: getNeatoState, neatoEvents } = require('../neatoService');
const { getState: getLiftState, liftEvents } = require('../liftService');
const homeAssistantService = require('../homeAssistantService');
const neatoService = require('../neatoService');
const liftService = require('../liftService');
const { getState: getHomeAssistantState, homeAssistantEvents } = homeAssistantService;
const { getState: getNeatoState, neatoEvents } = neatoService;
const { getState: getLiftState, liftEvents } = liftService;
const roverManager = require('../roverManager');
const { getRecentMessages } = require('../chatService');
const { getRecentMessages, sendSystemMessage } = require('../chatService');
const {
PROMPT_PATH,
DEFAULT_NAME,
@@ -20,18 +23,9 @@ const {
MAX_BOT_CONTEXT,
normalizeMs,
} = require('./constants');
const {
isAdminRole,
buildAdminState,
normalizeDecision,
buildFailureInfo,
} = require('./runtimeHelpers');
const {
toStateUpdate,
buildToolState,
buildConversation,
buildModelMessages,
} = require('./contextBuilder');
const { isAdminRole, buildAdminState, parseOverseerOutput, buildFailureInfo } = require('./runtimeHelpers');
const { toStateUpdate, buildToolState, buildConversation, buildModelMessages } = require('./contextBuilder');
const { executeToolAction } = require('./tools');
const config = loadConfig();
const overseerConfig = config.overseerControl || {};
@@ -47,12 +41,12 @@ const ollamaClient = ollamaUrl ? new Ollama({ host: ollamaUrl }) : null;
const runtime = {
timer: null,
inFlight: false,
running: false,
tickCount: 0,
lastModelAt: 0,
generationCount: 0,
generationTotalMs: 0,
runHistory: [],
memoryStore: ['', '', ''],
};
let status = {
@@ -83,6 +77,9 @@ let status = {
lastModelOutputAt: null,
lastModelRawOutput: null,
lastDecision: null,
lastChatDraft: null,
lastRequestedActions: null,
lastActionResults: null,
lastOutcome: null,
lastReason: null,
lastError: null,
@@ -95,11 +92,7 @@ let status = {
};
function updateStatus(patch = {}) {
status = {
...status,
...patch,
updatedAt: Date.now(),
};
status = { ...status, ...patch, updatedAt: Date.now() };
const payload = buildAdminState(status, runtime.runHistory);
io.sockets.sockets.forEach((socket) => {
if (!isAdminRole(getRole(socket))) return;
@@ -131,26 +124,16 @@ function computeTriggerReason() {
const last = recent[recent.length - 1];
if (last && Date.now() - Number(last.ts || 0) < 5000) {
const txt = String(last.text || '').toLowerCase();
if (txt.includes(name.toLowerCase()) || txt.includes('overseer') || txt.includes('bot')) {
return 'direct_address';
}
if (txt.includes(name.toLowerCase()) || txt.includes('overseer') || txt.includes('bot')) return 'direct_address';
return 'chat_activity';
}
if (!runtime.lastModelAt || Date.now() - runtime.lastModelAt >= heartbeatMs) {
return 'heartbeat';
}
if (!runtime.lastModelAt || Date.now() - runtime.lastModelAt >= heartbeatMs) return 'heartbeat';
return null;
}
async function runDecision(triggerReason) {
const runId = runtime.tickCount;
updateStatus({
phase: 'context_build',
currentRunId: runId,
lastTriggerReason: triggerReason,
lastError: null,
lastErrorDetails: null,
});
updateStatus({ phase: 'context_build', currentRunId: runId, lastTriggerReason: triggerReason, lastError: null, lastErrorDetails: null });
const mode = getMode();
const homeAssistantState = getHomeAssistantState();
@@ -162,19 +145,11 @@ async function runDecision(triggerReason) {
const toolState = buildToolState({ mode, homeAssistantState, neatoState, liftState });
const human = getRecentMessages(MAX_CHAT_CONTEXT, { includeSystem: false });
const bots = getRecentMessages(100, { includeSystem: true })
.filter((entry) => entry?.system)
.slice(-MAX_BOT_CONTEXT);
const bots = getRecentMessages(100, { includeSystem: true }).filter((entry) => entry?.system).slice(-MAX_BOT_CONTEXT);
const transcriptRows = buildConversation({ recentMessages: [...human, ...bots].slice(-(MAX_CHAT_CONTEXT + MAX_BOT_CONTEXT)), name });
const systemPrompt = await readPrompt();
const modelMessages = buildModelMessages({
systemPrompt,
stateUpdate,
transcriptRows,
availableTools: toolState.available,
blockedTools: toolState.blocked,
});
const modelMessages = buildModelMessages({ systemPrompt, stateUpdate, transcriptRows, availableTools: toolState.available, blockedTools: toolState.blocked });
updateStatus({
phase: 'awaiting_model',
@@ -187,40 +162,73 @@ async function runDecision(triggerReason) {
lastModelInputAt: Date.now(),
});
let decision = 'SKIP';
let rawOutput = '';
let parsed = { decision: 'SKIP', chat: null, actions: [], raw: '' };
const generationStart = Date.now();
if (ollamaClient && model) {
const payload = await ollamaClient.chat({
model,
stream: false,
keep_alive: -1,
options: {
temperature: 0.4,
top_p: 0.9,
},
options: { temperature: 0.4, top_p: 0.9 },
messages: modelMessages,
});
const parsed = normalizeDecision(payload?.message?.content || '');
rawOutput = parsed.raw;
decision = parsed.decision;
parsed = parseOverseerOutput(payload?.message?.content || '');
}
const generationMs = Math.max(0, Date.now() - generationStart);
runtime.generationCount += 1;
runtime.generationTotalMs += generationMs;
const avgGenerationMs = Math.round(runtime.generationTotalMs / runtime.generationCount);
runtime.lastModelAt = Date.now();
const outcome = observeOnly ? 'observed' : 'pending_execution';
const actionResults = [];
let outcome = observeOnly ? 'observed' : 'executed';
let reason = observeOnly ? 'observe-only mode' : null;
if (!observeOnly) {
if ((parsed.decision === 'CHAT' || parsed.decision === 'ACTION+CHAT') && parsed.chat) {
sendSystemMessage(parsed.chat, { nickname: name });
actionResults.push({ kind: 'chat', ok: true });
}
if (parsed.decision === 'ACTION' || parsed.decision === 'ACTION+CHAT') {
for (const action of parsed.actions) {
const isAvailable = toolState.available.some((signature) => signature.startsWith(`${action.tool}(`));
if (!isAvailable) {
actionResults.push({ kind: 'tool', tool: action.tool, ok: false, error: 'tool unavailable or blocked' });
continue;
}
try {
const result = await executeToolAction(action, {
sendSystemMessage,
name,
memoryStore: runtime.memoryStore,
neatoService,
liftService,
homeAssistantService,
actor: 'overseerControl',
});
if (action.tool === 'memory_write' && Array.isArray(result?.slots)) {
runtime.memoryStore = result.slots;
}
actionResults.push({ kind: 'tool', tool: action.tool, ok: true, result });
} catch (err) {
actionResults.push({ kind: 'tool', tool: action.tool, ok: false, error: err.message });
}
}
}
}
updateStatus({
phase: 'decision_recorded',
lastModelOutputAt: Date.now(),
lastModelRawOutput: rawOutput,
lastDecision: decision,
lastModelRawOutput: parsed.raw,
lastDecision: parsed.decision,
lastChatDraft: parsed.chat,
lastRequestedActions: parsed.actions,
lastActionResults: actionResults,
lastOutcome: outcome,
lastReason: observeOnly ? 'observe-only mode' : null,
lastReason: reason,
lastGenerationMs: generationMs,
avgGenerationMs,
generationCount: runtime.generationCount,
@@ -230,7 +238,10 @@ async function runDecision(triggerReason) {
runId,
at: Date.now(),
triggerReason,
decision,
decision: parsed.decision,
chatDraft: parsed.chat,
requestedActions: parsed.actions,
actionResults,
outcome,
observeOnly,
generationMs,
@@ -241,24 +252,15 @@ async function runDecision(triggerReason) {
async function tick() {
runtime.tickCount += 1;
runtime.inFlight = true;
updateStatus({
inFlight: true,
tickCount: runtime.tickCount,
lastTickAt: Date.now(),
phase: 'gate_check',
});
updateStatus({ inFlight: true, tickCount: runtime.tickCount, lastTickAt: Date.now(), phase: 'gate_check' });
try {
const triggerReason = computeTriggerReason();
if (!triggerReason) {
updateStatus({
phase: 'idle',
lastOutcome: 'skipped',
lastReason: 'gate not triggered',
});
return;
updateStatus({ phase: 'idle', lastOutcome: 'skipped', lastReason: 'gate not triggered' });
} else {
await runDecision(triggerReason);
}
await runDecision(triggerReason);
} catch (err) {
const failure = buildFailureInfo(err);
updateStatus({
@@ -271,12 +273,7 @@ async function tick() {
});
} finally {
runtime.inFlight = false;
updateStatus({
inFlight: false,
currentRunId: null,
phase: 'idle',
nextRunAt: Date.now() + gateIntervalMs,
});
updateStatus({ inFlight: false, currentRunId: null, phase: 'idle', nextRunAt: Date.now() + gateIntervalMs });
runtime.timer = setTimeout(tick, gateIntervalMs);
}
}
@@ -290,51 +287,33 @@ function clearHistory() {
runtime.runHistory = [];
runtime.generationCount = 0;
runtime.generationTotalMs = 0;
updateStatus({
lastReason: 'admin requested clear history',
lastOutcome: 'cleared',
});
runtime.memoryStore = ['', '', ''];
updateStatus({ lastReason: 'admin requested clear history', lastOutcome: 'cleared' });
}
io.on('connection', (socket) => {
emitStateToSocket(socket);
socket.on('overseer:control', ({ controls } = {}, cb = () => {}) => {
if (!isAdminRole(getRole(socket))) {
cb({ error: 'Not authorized' });
return;
}
if (!isAdminRole(getRole(socket))) return cb({ error: 'Not authorized' });
const action = controls?.action || null;
if (action === 'clearHistory') {
clearHistory();
cb({ success: true, state: buildAdminState(status, runtime.runHistory) });
return;
return cb({ success: true, state: buildAdminState(status, runtime.runHistory) });
}
cb({ error: 'Unknown overseer control action' });
return cb({ error: 'Unknown overseer control action' });
});
});
roleEvents.on('change', ({ socket }) => {
emitStateToSocket(socket);
});
homeAssistantEvents.on('update', () => {
updateStatus({ phase: status.phase });
});
neatoEvents.on('update', () => {
updateStatus({ phase: status.phase });
});
liftEvents.on('update', () => {
updateStatus({ phase: status.phase });
});
roverManager.managerEvents.on('rover', () => {
updateStatus({ phase: status.phase });
});
roleEvents.on('change', ({ socket }) => emitStateToSocket(socket));
homeAssistantEvents.on('update', () => updateStatus({ phase: status.phase }));
neatoEvents.on('update', () => updateStatus({ phase: status.phase }));
liftEvents.on('update', () => updateStatus({ phase: status.phase }));
roverManager.managerEvents.on('rover', () => updateStatus({ phase: status.phase }));
if (!enabled) {
logger.info('overseerControl disabled');
updateStatus({ running: false, lastReason: 'overseerControl.enabled is false' });
} else {
runtime.running = true;
updateStatus({ running: true, lastReason: observeOnly ? 'observe-only mode' : null });
runtime.timer = setTimeout(tick, gateIntervalMs);
logger.info('overseerControl enabled', { model, ollamaUrl, gateIntervalMs, heartbeatMs, observeOnly });
@@ -37,6 +37,9 @@ function buildAdminState(status, runHistory) {
output: {
raw: status.lastModelRawOutput,
normalized: status.lastDecision,
chat: status.lastChatDraft,
actions: status.lastRequestedActions,
actionResults: status.lastActionResults,
outputAt: status.lastModelOutputAt,
outcome: status.lastOutcome,
reason: status.lastReason,
@@ -61,16 +64,34 @@ function buildAdminState(status, runHistory) {
};
}
function normalizeDecision(rawContent = '') {
function parseOverseerOutput(rawContent = '') {
const raw = typeof rawContent === 'string' ? rawContent : '';
const trimmed = raw.trim();
if (!trimmed) return { raw, decision: 'SKIP' };
if (!trimmed) return { raw, decision: 'SKIP', chat: null, actions: [] };
try {
const parsed = JSON.parse(trimmed);
const decision = String(parsed?.decision || 'SKIP').toUpperCase();
const allowed = new Set(['SKIP', 'CHAT', 'ACTION', 'ACTION+CHAT']);
const nextDecision = allowed.has(decision) ? decision : 'SKIP';
const chat = typeof parsed?.chat === 'string' && parsed.chat.trim() ? parsed.chat.trim() : null;
const actions = Array.isArray(parsed?.actions)
? parsed.actions
.map((entry) => ({
tool: String(entry?.tool || '').trim(),
args: entry?.args && typeof entry.args === 'object' ? entry.args : {},
}))
.filter((entry) => entry.tool.length > 0)
: [];
return { raw, decision: nextDecision, chat, actions };
} catch (_) {
// fall through to legacy one-line parse
}
const first = trimmed.split(/\r?\n/).map((line) => line.trim()).find(Boolean) || '';
const upper = first.toUpperCase();
if (['SKIP', 'CHAT', 'ACTION', 'ACTION+CHAT'].includes(upper)) {
return { raw, decision: upper };
return { raw, decision: upper, chat: null, actions: [] };
}
return { raw, decision: 'CHAT' };
return { raw, decision: 'CHAT', chat: first, actions: [] };
}
function buildFailureInfo(err) {
@@ -88,6 +109,6 @@ function buildFailureInfo(err) {
module.exports = {
isAdminRole,
buildAdminState,
normalizeDecision,
parseOverseerOutput,
buildFailureInfo,
};
@@ -1,6 +1,13 @@
module.exports = {
id: 'chat_say',
signature: 'chat_say(text)',
availability() {
return { available: true, reason: null };
},
async execute({ args = {}, sendSystemMessage, name }) {
const text = String(args?.text || '').trim();
if (!text) throw new Error('chat_say requires args.text');
sendSystemMessage(text, { nickname: name });
return { ok: true };
},
};
@@ -1,4 +1,5 @@
module.exports = {
id: 'ha_set_entity',
signature: 'ha_set_entity(entity_id, state)',
availability(ctx = {}) {
const mode = String(ctx.mode || '');
@@ -11,4 +12,12 @@ module.exports = {
if (!ctx.homeAssistantState?.connected) return { available: false, reason: 'unavailable' };
return { available: true, reason: null };
},
async execute({ args = {}, homeAssistantService }) {
const entityId = String(args?.entity_id || args?.entityId || '').trim();
if (!entityId) throw new Error('ha_set_entity requires args.entity_id');
const state = String(args?.state || '').toLowerCase();
if (state !== 'on' && state !== 'off') throw new Error('ha_set_entity requires args.state of on/off');
await homeAssistantService.setEntityState(entityId, state, { source: 'overseerControl' });
return { ok: true };
},
};
@@ -21,6 +21,7 @@ const TOOL_DEFINITIONS = [
neatoClearErrors,
haSetEntity,
];
const TOOL_BY_ID = new Map(TOOL_DEFINITIONS.map((tool) => [tool.id, tool]));
function evaluateTools(context = {}) {
const available = [];
@@ -36,7 +37,20 @@ function evaluateTools(context = {}) {
return { available, blocked };
}
async function executeToolAction(action = {}, context = {}) {
const toolId = String(action?.tool || '').trim();
const tool = TOOL_BY_ID.get(toolId);
if (!tool) {
throw new Error(`Unknown tool: ${toolId}`);
}
if (typeof tool.execute !== 'function') {
throw new Error(`Tool ${toolId} is not executable`);
}
return tool.execute({ ...context, args: action?.args || {} });
}
module.exports = {
TOOL_DEFINITIONS,
evaluateTools,
executeToolAction,
};
@@ -1,4 +1,5 @@
module.exports = {
id: 'lift_down',
signature: 'lift_down()',
availability(ctx = {}) {
const mode = String(ctx.mode || '');
@@ -9,4 +10,8 @@ module.exports = {
if (ctx.liftState?.busy) return { available: false, reason: 'busy' };
return { available: true, reason: null };
},
async execute({ liftService, actor = 'overseerControl' }) {
const resp = await liftService.moveDown(actor);
return { ok: true, resp };
},
};
@@ -1,4 +1,5 @@
module.exports = {
id: 'lift_up',
signature: 'lift_up()',
availability(ctx = {}) {
const mode = String(ctx.mode || '');
@@ -9,4 +10,8 @@ module.exports = {
if (ctx.liftState?.busy) return { available: false, reason: 'busy' };
return { available: true, reason: null };
},
async execute({ liftService, actor = 'overseerControl' }) {
const resp = await liftService.moveUp(actor);
return { ok: true, resp };
},
};
@@ -1,6 +1,10 @@
module.exports = {
id: 'memory_read',
signature: 'memory_read()',
availability() {
return { available: true, reason: null };
},
async execute({ memoryStore }) {
return { ok: true, slots: memoryStore || ['', '', ''] };
},
};
@@ -1,6 +1,17 @@
module.exports = {
id: 'memory_write',
signature: 'memory_write(slot, text)',
availability() {
return { available: true, reason: null };
},
async execute({ args = {}, memoryStore }) {
const slot = Math.max(1, Math.min(3, Number(args?.slot) || 0));
if (!slot) throw new Error('memory_write requires args.slot 1..3');
const text = String(args?.text || '').trim();
if (!text) throw new Error('memory_write requires args.text');
const next = Array.isArray(memoryStore) ? [...memoryStore] : ['', '', ''];
while (next.length < 3) next.push('');
next[slot - 1] = text.slice(0, 180);
return { ok: true, slots: next };
},
};
@@ -1,4 +1,5 @@
module.exports = {
id: 'neato_clear_errors',
signature: 'neato_clear_errors()',
availability(ctx = {}) {
const mode = String(ctx.mode || '');
@@ -8,4 +9,8 @@ module.exports = {
if (!ctx.neatoState?.connected) return { available: false, reason: 'unavailable' };
return { available: true, reason: null };
},
async execute({ neatoService }) {
await neatoService.clearErrors();
return { ok: true };
},
};
@@ -1,4 +1,5 @@
module.exports = {
id: 'neato_locate',
signature: 'neato_locate()',
availability(ctx = {}) {
const mode = String(ctx.mode || '');
@@ -8,4 +9,8 @@ module.exports = {
if (!ctx.neatoState?.connected) return { available: false, reason: 'unavailable' };
return { available: true, reason: null };
},
async execute({ neatoService }) {
await neatoService.locateRobot();
return { ok: true };
},
};
@@ -1,4 +1,5 @@
module.exports = {
id: 'neato_send_home',
signature: 'neato_send_home()',
availability(ctx = {}) {
const mode = String(ctx.mode || '');
@@ -8,4 +9,8 @@ module.exports = {
if (!ctx.neatoState?.connected) return { available: false, reason: 'unavailable' };
return { available: true, reason: null };
},
async execute({ neatoService }) {
await neatoService.sendHome();
return { ok: true };
},
};
@@ -1,4 +1,5 @@
module.exports = {
id: 'neato_start',
signature: 'neato_start()',
availability(ctx = {}) {
const mode = String(ctx.mode || '');
@@ -8,4 +9,8 @@ module.exports = {
if (!ctx.neatoState?.connected) return { available: false, reason: 'unavailable' };
return { available: true, reason: null };
},
async execute({ neatoService }) {
await neatoService.startCleaning();
return { ok: true };
},
};