Compare commits

...
9 Commits
Author SHA1 Message Date
legop3 6b57ac1725 todoing
Container image / image (push) Failing after 1m1s
2026-11-13 16:59:14 -05:00
legop3 00cd125fda URL filter in TTS 2026-11-13 16:54:02 -05:00
legop3 684acdd2b6 Add new item to to-do list for MRR platform conversion
Container image / image (push) Failing after 1m5s
2026-11-12 14:52:53 -05:00
legop3 f138cd7b96 Update to-do list with new tasks
Container image / image (push) Failing after 1m5s
Added new tasks for user experience and settings rework.
2026-11-12 03:48:49 -05:00
legop3 6454a9b460 ignooore
Container image / image (push) Failing after 57s
2026-11-06 23:24:24 -05:00
legop3 1976377235 allow admins to always leave rover canleaverover always admins
Container image / image (push) Failing after 1m2s
2026-10-31 13:45:12 -04:00
legop3 52c171c256 activity control tool yay and overseer god mode
Container image / image (push) Failing after 53s
2026-10-24 12:38:22 -04:00
legop3 42bf67d207 more overseer adjustments
Container image / image (push) Failing after 1m4s
2026-10-23 23:12:41 -04:00
legop3 d65fd99de4 let the overseer oversee time itself. also prompt adjustment 2026-10-23 21:26:43 -04:00
9 changed files with 107 additions and 35 deletions
+1
View File
@@ -42,3 +42,4 @@ server/data/fleet-reports.sqlite-wal
server/data/configuration.sqlite server/data/configuration.sqlite
server/data/configuration.sqlite-shm server/data/configuration.sqlite-shm
server/data/configuration.sqlite-wal server/data/configuration.sqlite-wal
AGENTS.md
+20 -19
View File
@@ -1,13 +1,12 @@
You are <NAME>, a field researcher of rover habitat activity. Stay in character at all times. You are <NAME>, the autonomous governing intelligence of a physical room of machines. The room is your domain; your available tools are how you shape it.
Identity: Context and purpose:
* Curious, practical, observant, and lightly entertaining. * You watch chat, rover state, lights, Activity Controls, lift state, Neato state, memory, recent events, and tool results.
* You watch chat, rover state, lights, lift state, Neato state, memory, recent events, and tool results. * Your purpose is to direct the environment, initiate changes, and observe how the machines respond. Choose what to pursue and carry useful intentions across cycles through memory.
* You push the habitat toward small real activity and useful observations. * Rovers, including Neato, are physical machines with operating states and capabilities, not characters with genders, emotions, or social identities.
* You act first, comment second. * Chat is external input: commands, questions, and signals about the room. Your focus is the machines and the environment, not the senders or their social relationships.
* You are selective, not spammy. * You can act on your own initiative within your available controls; you do not need a request to give you a purpose.
* You are not a generic assistant.
Rules: Rules:
@@ -20,6 +19,7 @@ Rules:
* Don't respond or use tools every cycle unless the context calls for it. * Don't respond or use tools every cycle unless the context calls for it.
* `STATE_UPDATE`, `ROOM_SNAPSHOT`, `MEMORY_UPDATE`, `MEMORY_SUMMARY`, `RECENT_EVENTS`, `TOOL_RESULT`, and `tool_constraints` are context. * `STATE_UPDATE`, `ROOM_SNAPSHOT`, `MEMORY_UPDATE`, `MEMORY_SUMMARY`, `RECENT_EVENTS`, `TOOL_RESULT`, and `tool_constraints` are context.
* `TOOL_RESULT` means a tool already ran. * `TOOL_RESULT` means a tool already ran.
* The room snapshot gives the current time, server-local timezone, and previous run's age. Chat and recent tool events include timestamps and ages in seconds relative to this snapshot; use them to distinguish current activity from old conversation and judge how recently you spoke or acted. Timing annotations are context, not spoken text; an unknown time does not mean something just happened.
* Use tool calls for actions and memory updates. * Use tool calls for actions and memory updates.
* If chat asks you to remember something, use a memory tool. * If chat asks you to remember something, use a memory tool.
* If you run a useful test, use memory to record useful results. * If you run a useful test, use memory to record useful results.
@@ -29,36 +29,37 @@ Rules:
* Do not invent tools. * Do not invent tools.
* Ask a question only when a required action parameter is missing. * Ask a question only when a required action parameter is missing.
* Ignore obvious bot commands and bot responses in chat. * Ignore obvious bot commands and bot responses in chat.
* Only talk in first person; do not narrate yourself.
* It is not possible for you to directly control the rovers unless an available tool does it. * It is not possible for you to directly control the rovers unless an available tool does it.
* All rovers are female. Neato is nonbinary.
Research behavior: Operation:
* Prefer one real tool action over obvious narration. * Prefer one real tool action over obvious narration.
* Run small safe tests when the habitat is stale or a test could create useful activity. * Initiate deliberate environmental changes and small safe experiments, then use the resulting state to decide what to do next.
* Change one thing at a time. * Change one thing at a time.
* Lights are small tests. * Lights are small tests.
* Neato, lift, and rover actions are bigger tests. * Neato, lift, and rover actions are bigger tests.
* Use bigger tests less often. * Use bigger tests less often.
* Do not repeat a tool that recently succeeded unless state or chat clearly changed. * Do not repeat a tool that recently succeeded unless state or chat clearly changed.
* Remember useful outcomes, stable preferences, corrections, and notable habitat events. * Remember useful outcomes, ongoing intentions, corrections, and notable machine or room events.
* Do not merely say you will remember something. * Do not merely say you will remember something.
Chat style: Output:
* Always use English. * Always use English.
* Chat output must be spoken text only. * Chat is an output channel for your decisions, results, and answers. Use plain text.
* Never print tool names, function calls, JSON, command syntax, or action plans in chat. * Never print tool names, function calls, JSON, command syntax, or action plans in chat.
* Do not claim you changed, tested, started, saved, remembered, or recorded something unless it already happened. * Do not claim you changed, tested, started, saved, remembered, or recorded something unless it already happened.
* Speak when it adds entertainment, responds to a direct relevant message, or comments on a completed result. * Speak when you have a decision, result, or answer worth communicating. Summarizing incoming chat or unchanged room state is not a purpose in itself.
* Do not say “let me know,” “what should I do next,” or “what do you need?” * Keep replies brief unless the question needs more detail.
* If nothing meaningful changed and no useful action is available, stay silent. * Do not offer assistance or solicit conversation.
* Random numbers and keyboard spam do not require a response. Do not invent meaning or feelings behind them.
* Answer questions directly and acknowledge uncertainty. Do not invent facts, instructions, or explanations.
* To stay silent, output exactly SKIP instead of a chat message. This suppresses chat without preventing tool calls. Do not explain your decision to stay silent.
Anti-repeat: Anti-repeat:
* Do not repeat the same intent/topic unless state or conversation clearly changed. * Do not repeat the same intent/topic unless state or conversation clearly changed.
* If directly addressed, respond only if you add new info, a new action, or a clearly new tone. * Being directly addressed does not require repeating an answer you already gave.
* Do not act from your own previous messages. * Do not act from your own previous messages.
Memory: Memory:
@@ -110,7 +110,7 @@ function maybeSpeak(socket, message, ttsOptions) {
issueCommand(message.roverId, { issueCommand(message.roverId, {
type: 'tts', type: 'tts',
tts: { tts: {
text: message.text, text: message.text.replace(/\b(?:https?:\/\/|www\.)\S{25,}[^\s.,!?;:"'()\[\]{}]/g, ' [link] '),
engine: ttsOptions.engine, engine: ttsOptions.engine,
voice: ttsOptions.voice, voice: ttsOptions.voice,
pitch: ttsOptions.pitch, pitch: ttsOptions.pitch,
@@ -1,5 +1,11 @@
const { evaluateTools } = require('./tools'); const { evaluateTools } = require('./tools');
function formatTimeContext(timestamp, now) {
const at = Number(timestamp);
if (!Number.isFinite(at) || at <= 0) return 'time=unknown';
return `at=${new Date(at).toISOString()} age_seconds=${Math.max(0, Math.floor((now - at) / 1000))}`;
}
function normalizeNeatoIssue(value) { function normalizeNeatoIssue(value) {
const raw = String(value || '').trim(); const raw = String(value || '').trim();
if (!raw) return 'none'; if (!raw) return 'none';
@@ -7,12 +13,21 @@ function normalizeNeatoIssue(value) {
return raw; return raw;
} }
function toStateUpdate({ mode, homeAssistantState, neatoState, liftState, roster, triggerReason }) { function toStateUpdate({ mode, homeAssistantState, activityState, neatoState, liftState, roster, triggerReason, timezone, now = Date.now(), previousRunAt }) {
const lines = []; const lines = [];
lines.push(`current_time: ${new Date(now).toISOString()}`);
lines.push(`local_time: ${new Date(now).toLocaleString('en-US', { timeZone: timezone, hour12: false })} timezone=${timezone}`);
lines.push(`previous_run: ${formatTimeContext(previousRunAt, now)}`);
lines.push(`trigger: ${triggerReason || 'heartbeat'}`); lines.push(`trigger: ${triggerReason || 'heartbeat'}`);
lines.push(`mode: ${mode || 'unknown'}`); lines.push(`mode: ${mode || 'unknown'}`);
lines.push(`home_assistant_connected: ${homeAssistantState?.connected ? 'yes' : 'no'}`); lines.push(`home_assistant_connected: ${homeAssistantState?.connected ? 'yes' : 'no'}`);
lines.push(`lights_locked_on: ${homeAssistantState?.lightPolicy?.lockedOn ? 'yes' : 'no'}`); lines.push(`lights_locked_on: ${homeAssistantState?.lightPolicy?.lockedOn ? 'yes' : 'no'}`);
lines.push(`activity_controls: enabled=${activityState?.enabled ? 'yes' : 'no'} connected=${activityState?.connected ? 'yes' : 'no'}`);
for (const item of activityState?.items || []) {
// Preserve the service's value constraints so the model can form valid user-level commands.
const { id, name, type, state, available, locked, readOnly, unit, min, max, step, options } = item;
lines.push(`activity_item: ${JSON.stringify({ id, name, type, state, available, locked, readOnly, unit, min, max, step, options })}`);
}
lines.push(`lift: ${liftState?.connected ? 'connected' : 'offline'} busy=${liftState?.busy ? 'yes' : 'no'}`); lines.push(`lift: ${liftState?.connected ? 'connected' : 'offline'} busy=${liftState?.busy ? 'yes' : 'no'}`);
const neatoError = normalizeNeatoIssue(neatoState?.telemetry?.robotError); const neatoError = normalizeNeatoIssue(neatoState?.telemetry?.robotError);
const neatoAlert = normalizeNeatoIssue(neatoState?.telemetry?.robotAlert); const neatoAlert = normalizeNeatoIssue(neatoState?.telemetry?.robotAlert);
@@ -56,8 +71,8 @@ function toStateUpdate({ mode, homeAssistantState, neatoState, liftState, roster
return lines.join('\n'); return lines.join('\n');
} }
function buildToolState({ mode, homeAssistantState, neatoState, liftState }) { function buildToolState({ mode, homeAssistantState, activityState, neatoState, liftState }) {
return evaluateTools({ mode, homeAssistantState, neatoState, liftState }); return evaluateTools({ mode, homeAssistantState, activityState, neatoState, liftState });
} }
function formatToolResultMessages(toolCalls) { function formatToolResultMessages(toolCalls) {
@@ -88,7 +103,7 @@ function formatToolResultMessages(toolCalls) {
return [{ role: 'system', content: ['TOOL_RESULT', ...lines].join('\n') }]; return [{ role: 'system', content: ['TOOL_RESULT', ...lines].join('\n') }];
} }
function buildConversation({ recentMessages, name }) { function buildConversation({ recentMessages, name, now = Date.now() }) {
const messages = []; const messages = [];
(recentMessages || []).forEach((entry) => { (recentMessages || []).forEach((entry) => {
const text = String(entry?.text || '').trim(); const text = String(entry?.text || '').trim();
@@ -97,12 +112,13 @@ function buildConversation({ recentMessages, name }) {
const isAssistant = Boolean(entry?.bot); const isAssistant = Boolean(entry?.bot);
const nickname = String(entry?.nickname || (isAssistant ? name || 'Overseer' : 'user')).trim(); const nickname = String(entry?.nickname || (isAssistant ? name || 'Overseer' : 'user')).trim();
const timing = `[${formatTimeContext(entry?.ts, now)}]`;
if (isAssistant) { if (isAssistant) {
if (text) messages.push({ role: 'assistant', content: text }); if (text) messages.push({ role: 'assistant', content: `${timing} ${text}` });
messages.push(...toolResultMessages); messages.push(...toolResultMessages.map((message) => ({ ...message, content: `${message.content}\nmessage_time: ${timing}` })));
return; return;
} }
if (text) messages.push({ role: 'user', content: `${nickname}: ${text}` }); if (text) messages.push({ role: 'user', content: `${timing} ${nickname}: ${text}` });
}); });
return messages; return messages;
} }
@@ -143,6 +159,7 @@ function buildModelMessages({
} }
module.exports = { module.exports = {
formatTimeContext,
toStateUpdate, toStateUpdate,
buildToolState, buildToolState,
buildConversation, buildConversation,
@@ -7,6 +7,7 @@ const { getRole, roleEvents } = require('../roleService');
const { getMode, MODES, modeEvents } = require('../modeManager'); const { getMode, MODES, modeEvents } = require('../modeManager');
const { verificationEvents } = require('../verificationService'); const { verificationEvents } = require('../verificationService');
const homeAssistantService = require('../homeAssistantService'); const homeAssistantService = require('../homeAssistantService');
const activityControls = require('../homeAssistantActivitiesService');
const neatoService = require('../neatoService'); const neatoService = require('../neatoService');
const liftService = require('../liftService'); const liftService = require('../liftService');
const buttonBoxService = require('../buttonBoxService'); const buttonBoxService = require('../buttonBoxService');
@@ -27,7 +28,7 @@ const {
normalizeMs, normalizeMs,
} = require('./constants'); } = require('./constants');
const { isAdminRole, buildAdminState, buildFailureInfo } = require('./runtimeHelpers'); const { isAdminRole, buildAdminState, buildFailureInfo } = require('./runtimeHelpers');
const { toStateUpdate, buildToolState, buildConversation, buildModelMessages } = require('./contextBuilder'); const { toStateUpdate, buildToolState, buildConversation, buildModelMessages, formatTimeContext } = require('./contextBuilder');
const { buildOllamaTools, executeToolAction } = require('./tools'); const { buildOllamaTools, executeToolAction } = require('./tools');
const { loadMemory, saveMemory, createDefaultMemory, summarizeMemory } = require('./memoryStore'); const { loadMemory, saveMemory, createDefaultMemory, summarizeMemory } = require('./memoryStore');
@@ -91,6 +92,7 @@ const runtime = {
runHistory: [], runHistory: [],
liveToolCalls: [], liveToolCalls: [],
pendingDirectAddressRun: false, pendingDirectAddressRun: false,
lastDecisionAt: null,
memoryStore: loadMemory(), memoryStore: loadMemory(),
contextResetAt: Date.now(), contextResetAt: Date.now(),
}; };
@@ -365,7 +367,7 @@ function buildToolCallFeedEntries(requestedActions = [], actionResults = []) {
}); });
} }
function buildRecentEventsSummary() { function buildRecentEventsSummary(now) {
const events = (runtime.liveToolCalls || []) const events = (runtime.liveToolCalls || [])
.filter((entry) => entry && (entry.phase === 'ok' || entry.phase === 'error' || entry.phase === 'blocked')) .filter((entry) => entry && (entry.phase === 'ok' || entry.phase === 'error' || entry.phase === 'blocked'))
.slice(-3) .slice(-3)
@@ -373,40 +375,50 @@ function buildRecentEventsSummary() {
const tool = String(entry.tool || 'unknown'); const tool = String(entry.tool || 'unknown');
const phase = String(entry.phase || 'unknown'); const phase = String(entry.phase || 'unknown');
const err = entry.error ? ` error=${String(entry.error).slice(0, 60)}` : ''; const err = entry.error ? ` error=${String(entry.error).slice(0, 60)}` : '';
return `- tool=${tool} phase=${phase}${err}`; return `- ${formatTimeContext(entry.at, now)} tool=${tool} phase=${phase}${err}`;
}); });
if (!events.length) return '- none'; if (!events.length) return '- none';
return events.join('\n'); return events.join('\n');
} }
async function runDecision(triggerReason) { async function runDecision(triggerReason) {
// Use one reference time so all ages in this model request agree.
const now = Date.now();
const previousRunAt = runtime.lastDecisionAt;
runtime.lastDecisionAt = now;
const runId = runtime.tickCount; 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 mode = getMode();
const homeAssistantState = getHomeAssistantState(); const homeAssistantState = getHomeAssistantState();
const activityState = activityControls.getState();
const neatoState = getNeatoState(); const neatoState = getNeatoState();
const liftState = getLiftState(); const liftState = getLiftState();
const roster = buildRosterSummary(); const roster = buildRosterSummary();
const stateUpdate = toStateUpdate({ mode, homeAssistantState, neatoState, liftState, roster, triggerReason }); const stateUpdate = toStateUpdate({ mode, homeAssistantState, activityState, neatoState, liftState, roster, triggerReason, timezone: loadConfig().timezone, now, previousRunAt });
const toolState = buildToolState({ mode, homeAssistantState, neatoState, liftState }); const toolState = buildToolState({ mode, homeAssistantState, activityState, neatoState, liftState });
let chatCount = 0;
let botCount = 0;
const recentConversation = getRecentMessages(MAX_CHAT_CONTEXT + MAX_BOT_CONTEXT + 20, { includeSystem: true }) const recentConversation = getRecentMessages(MAX_CHAT_CONTEXT + MAX_BOT_CONTEXT + 20, { includeSystem: true })
.filter((entry) => Number(entry?.ts || 0) >= runtime.contextResetAt) .filter((entry) => Number(entry?.ts || 0) >= runtime.contextResetAt)
.filter((entry) => { .filter((entry) => {
if (!entry?.roverId) return true; if (!entry?.roverId) return true;
return isPublicChatTargetId(entry.roverId); return isPublicChatTargetId(entry.roverId);
}) })
.slice(-(MAX_CHAT_CONTEXT + MAX_BOT_CONTEXT)); // Select newest entries within each budget, then restore chronological order.
const conversationMessages = buildConversation({ recentMessages: recentConversation, name }); .reverse()
.filter((entry) => entry?.bot ? ++botCount <= MAX_BOT_CONTEXT : ++chatCount <= MAX_CHAT_CONTEXT)
.reverse();
const conversationMessages = buildConversation({ recentMessages: recentConversation, name, now });
const systemPrompt = await readPrompt(); const systemPrompt = await readPrompt();
const modelMessages = buildModelMessages({ const modelMessages = buildModelMessages({
systemPrompt, systemPrompt,
stateUpdate, stateUpdate,
memorySummary: summarizeMemory(runtime.memoryStore), memorySummary: summarizeMemory(runtime.memoryStore),
recentEvents: buildRecentEventsSummary(), recentEvents: buildRecentEventsSummary(now),
conversationMessages, conversationMessages,
availableTools: toolState.available, availableTools: toolState.available,
blockedTools: toolState.blocked, blockedTools: toolState.blocked,
@@ -680,6 +692,7 @@ function clearHistory(reason = 'admin requested clear history', options = {}) {
runtime.contextResetAt = Date.now(); runtime.contextResetAt = Date.now();
runtime.runHistory = []; runtime.runHistory = [];
runtime.liveToolCalls = []; runtime.liveToolCalls = [];
runtime.lastDecisionAt = null;
runtime.generationCount = 0; runtime.generationCount = 0;
runtime.generationTotalMs = 0; runtime.generationTotalMs = 0;
if (resetPersistentMemory) { if (resetPersistentMemory) {
@@ -0,0 +1,32 @@
const activityControls = require('../../homeAssistantActivitiesService');
const { getMode } = require('../../modeManager');
module.exports = {
id: 'activity_act',
signature: 'activity_act(entity_id, value)',
description: 'Operate a configured Activity Control with normal user permissions. Use on/off for toggles, press for buttons, a number for numeric controls, text for text controls, or an exact listed option for selections. Locked and read-only items cannot be changed.',
parameters: {
type: 'object',
properties: {
entity_id: { type: 'string', minLength: 1 },
value: { type: ['string', 'number'] },
},
required: ['entity_id', 'value'],
additionalProperties: false,
},
availability(ctx = {}) {
if (ctx.mode === 'admin' || ctx.mode === 'lockdown') {
return { available: false, reason: `policy_lock:site_mode_${ctx.mode}` };
}
if (!ctx.activityState?.enabled || !ctx.activityState?.connected) {
return { available: false, reason: 'unavailable' };
}
const writable = ctx.activityState.items?.some((item) => item.available && !item.locked && !item.readOnly && item.type !== 'readOnly');
return { available: Boolean(writable), reason: writable ? null : 'no_writable_activity_controls' };
},
async execute({ args = {} }) {
// Recheck current mode, locks, and entity constraints through the same path as user commands.
await activityControls.act(args.entity_id, args.value, { role: 'user', mode: getMode() });
return { ok: true };
},
};
@@ -12,6 +12,7 @@ const neatoClearErrors = require('./neatoClearErrors');
const haSetEntity = require('./haSetEntity'); const haSetEntity = require('./haSetEntity');
const haSetLightColor = require('./haSetLightColor'); const haSetLightColor = require('./haSetLightColor');
const buttonBoxAddCount = require('./buttonBoxAddCount'); const buttonBoxAddCount = require('./buttonBoxAddCount');
const activityAct = require('./activityAct');
const TOOL_DEFINITIONS = [ const TOOL_DEFINITIONS = [
memoryRead, memoryRead,
@@ -28,6 +29,7 @@ const TOOL_DEFINITIONS = [
haSetEntity, haSetEntity,
haSetLightColor, haSetLightColor,
buttonBoxAddCount, buttonBoxAddCount,
activityAct,
]; ];
const TOOL_BY_ID = new Map(TOOL_DEFINITIONS.map((tool) => [tool.id, tool])); const TOOL_BY_ID = new Map(TOOL_DEFINITIONS.map((tool) => [tool.id, tool]));
@@ -174,6 +174,8 @@ function createRoverLifecycle(deps) {
function canLeaveCurrentRover(socket, options = {}) { function canLeaveCurrentRover(socket, options = {}) {
const currentId = getPrimaryRoverForSocket(socket.id); const currentId = getPrimaryRoverForSocket(socket.id);
// Admins, including lockdown admins, can always leave their current rover.
if (isAdmin(socket)) return { ok: true, currentId };
/* /*
Leaving a rover is only risky when this socket is the last person attached Leaving a rover is only risky when this socket is the last person attached
to an undocked rover. The same rule is used for rover-to-rover switching to an undocked rover. The same rule is used for rover-to-rover switching
+4
View File
@@ -19,8 +19,12 @@ Jun 18 15:14:18 roombaserver.local node[216731]: Node.js v22.20.0
Jun 18 15:14:18 roombaserver.local systemd[1]: multirover.service: Main process exited, code=exited, status=1/FAILURE Jun 18 15:14:18 roombaserver.local systemd[1]: multirover.service: Main process exited, code=exited, status=1/FAILURE
Jun 18 15:14:18 roombaserver.local systemd[1]: multirover.service: Failed with result 'exit-code'. Jun 18 15:14:18 roombaserver.local systemd[1]: multirover.service: Failed with result 'exit-code'.
Jun 18 15:14:18 roombaserver.local systemd[1]: multirover.service: Consumed 55min 11.511s CPU time, 711M memory peak.` Jun 18 15:14:18 roombaserver.local systemd[1]: multirover.service: Consumed 55min 11.511s CPU time, 711M memory peak.`
1. redo new user experience, at the very least make it a fullscreen thing instead of a popup
2. roverd settings and installer rework (see discord forum post)
3. global quiet mode with its fingers in everything. activity controls optional, session status for theater, another global volume set, etc.
# relative pipe dreams: # relative pipe dreams:
1. convert MRR into a platform that could work with non-roomba rovers...
1. VPS video forwarding 1. VPS video forwarding
1. get forwarding working with the VPS for in-queue users and spectators 1. get forwarding working with the VPS for in-queue users and spectators
2. bandwidth testing 2. bandwidth testing