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