Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions BOTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Bots

Bots are persistent, top-level chats, not project sessions or temporary subagents.
Create one below **New project**, pick a unique username, and describe its role in
chat. The bot can save that role with `bot_manage`. Its model, conversation,
tools, services, browser, and queue use the existing session harness.

## Collaboration

- A leading `@username` in a session sends that request and a bounded slice of
session context to the bot's own chat. Its attributed answer appears back in
the source session. Mid-sentence mentions do not redirect a user's request.
- Agents use `bot_invoke` to delegate explicitly. The response is persisted in
the caller's transcript and a continuation is queued for the caller.
- `project_list` and `session_manage` discover projects, create/read/update/delete
project sessions, and send prompts to them. New sessions honor workstream
isolation. Busy sessions cannot be deleted through these tools.
- `queue_manage` creates, lists, reads, edits/retries, and deletes pending
messages in any session or bot chat. It supports delayed delivery.
- Handoffs and continuations carry a maximum eight-hop budget. A result does
not recursively reply to its sender. Each target has a maximum 100 queued
messages and at most one active turn; automation runs at most four targets
concurrently.

## Scheduling

`bot_schedule` and bot settings support multiple jobs per bot:

- Intervals of at least one minute.
- Five-field cron expressions with an explicit IANA timezone.
- Explicit epoch-millisecond timestamps.
- Optional remaining-run limits and pause/resume.

The main process atomically enqueues each due prompt and advances its schedule.
No renderer needs to be open. **Roxy must still be running; this is not an OS or
cloud scheduler.** Missed interval/cron beats coalesce into one delivery after
startup. Explicit timestamps remain distinct deliveries.
Pausing affects future beats; already queued messages remain editable in the
queue. Deleting a schedule cancels its not-yet-started pending deliveries.

The main process owns queue consumption for desktop, phone, bots, and scheduled
jobs. Failed requests remain in the queue with an error and block later work
until edited/retried or removed. Stop pauses draining. Interrupted deliveries
are marked failed on startup rather than replaying potentially non-idempotent
tool actions. There is no exactly-once guarantee for external side effects.

New bots get private working folders under the app's user-data directory, with
the full existing harness and its normal tool restrictions. They coordinate
project work through sessions rather than silently borrowing the currently open
project. Migrated loops retain their working directory so existing tasks keep
working, but appear in the top-level bot navigation.

## Migration And Checks

Schema v24 migrates existing loops to bots and interval jobs, retaining chat IDs,
transcripts, inference settings, workspace paths, pending messages, enabled
state, and schedule times. Colliding legacy names get unique usernames. The old
loop scheduler, tools, IPC, and navigation are removed.

The useful ideas from PR #91 are preserved: persistent identities, standing
roles, leading-mention routing, and attributed handoffs. Channel membership and
prose-scanning automatic handoffs are deliberately not required. Bots always
run in their own conversation; explicit tools handle agent-to-agent delegation.

Run `npm run smoke:bots` for scheduling/mention unit checks and isolated Electron
runtime tests covering migration, CRUD, queue ownership, failures, handoffs, and
the real harness with a deterministic model transport. No live model credentials
are used by these tests.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ roxy/
│ ├── main/ # Electron main process (Node.js)
│ │ ├── index.ts # App lifecycle, window creation, service startup
│ │ ├── harness/ # The agent loop: agent.ts (loop + tool schemas), tools.ts (dispatch)
│ │ ├── services/ # llm.ts, aisdk.ts, mcp.ts, lsp.ts, skills.ts, browser.ts, loops.ts, …
│ │ ├── services/ # llm.ts, aisdk.ts, mcp.ts, lsp.ts, skills.ts, browser.ts, automation.ts, …
│ │ ├── db/ # better-sqlite3 store: schema, migrations, repo
│ │ └── ipc/ # ipcMain handlers wiring the renderer to the harness/services
│ ├── preload/ # Secure bridge between main and renderer (window.api)
Expand Down Expand Up @@ -108,8 +108,8 @@ The main process runs a single provider-agnostic agent loop; the renderer only s
([`services/mcp.ts`](src/main/services/mcp.ts)), language-server diagnostics fed back after edits
([`services/lsp.ts`](src/main/services/lsp.ts)), and on-demand `SKILL.md` skills
([`services/skills.ts`](src/main/services/skills.ts)). Roxy's own differentiators — the persistent
browser toolset ([`services/browser.ts`](src/main/services/browser.ts)) and recurring "loops"
([`services/loops.ts`](src/main/services/loops.ts)) run through the same loop.
browser toolset ([`services/browser.ts`](src/main/services/browser.ts)) and persistent [bots](BOTS.md)
([`services/automation.ts`](src/main/services/automation.ts)) run through the same harness.

### Remote Workspace

Expand Down
43 changes: 43 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"smoke:cliproxy": "esbuild test/cliproxy.ts --bundle --platform=node --format=cjs --packages=external --outfile=test/.out/cliproxy.cjs && electron test/.out/cliproxy.cjs",
"worktree:setup": "npm ci --prefer-offline --no-audit --no-fund && electron-builder install-app-deps",
"smoke:store": "node test/store-guard.mjs",
"smoke:bots": "esbuild test/bots-shared.ts --bundle --platform=node --format=cjs --packages=external --outfile=test/.out/bots-shared.cjs && node test/.out/bots-shared.cjs && esbuild src/preload/index.ts --bundle --platform=node --format=cjs --packages=external --outfile=test/.out/bots-preload.cjs && esbuild test/bots.ts --bundle --platform=node --format=cjs --packages=external --outfile=test/.out/bots.cjs && electron test/.out/bots.cjs",
"smoke:cookies": "esbuild test/cookies.ts --bundle --platform=node --format=cjs --packages=external --outfile=test/.out/cookies.cjs && electron test/.out/cookies.cjs",
"smoke:i18n": "esbuild test/i18n.ts --bundle --platform=node --format=cjs --outfile=test/.out/i18n.cjs && node test/.out/i18n.cjs",
"i18n:translate": "node script/i18n-translate.mjs",
Expand All @@ -65,10 +66,12 @@
"ai": "^5.0.210",
"better-sqlite3": "^12.11.1",
"clsx": "^2.1.1",
"cron-parser": "^5.10.0",
"d3-scale": "^4.0.2",
"d3-shape": "^3.2.0",
"diff": "^9.0.0",
"electron-updater": "^6.3.9",
"facehash": "^0.1.0",
"i18next": "^25.10.10",
"lucide-react": "^1.21.0",
"morphicons": "^1.7.0",
Expand Down
177 changes: 177 additions & 0 deletions src/main/db/bots.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { randomUUID } from 'node:crypto'
import { botUsername, nextBotRun, type Bot, type BotJob, type BotJobInput } from '../../shared/bots'
import { getDb } from './database'
import * as repo from './repo'

const BOT_COLUMNS = 'id, username, instructions, chat_id AS chatId, created_at AS createdAt'
const JOB_COLUMNS = `id, bot_id AS botId, name, prompt, schedule, enabled,
next_run_at AS nextRunAt, last_run_at AS lastRunAt, remaining_runs AS remainingRuns, created_at AS createdAt`

export function listBots(): Bot[] {
return getDb().prepare(`SELECT ${BOT_COLUMNS} FROM bots ORDER BY created_at, id`).all() as Bot[]
}

export function getBot(id: string): Bot | undefined {
return getDb()
.prepare(`SELECT ${BOT_COLUMNS} FROM bots WHERE id = ? OR username = ?`)
.get(id, id.replace(/^@/, '')) as Bot | undefined
}

export function chatBot(chatId: string): Bot | undefined {
return getDb().prepare(`SELECT ${BOT_COLUMNS} FROM bots WHERE chat_id = ?`).get(chatId) as
| Bot
| undefined
}

export function createBot(username: string, instructions = ''): Bot {
if (typeof username !== 'string' || typeof instructions !== 'string')
throw new Error('Bot username and instructions must be text')
username = botUsername(username)
if (getBot(username)) throw new Error('That bot username is already taken')
return getDb().transaction(() => {
const chat = repo.createChat({ title: username, kind: 'bot' })
const bot: Bot = {
id: randomUUID(),
username,
instructions,
chatId: chat.id,
createdAt: Date.now()
}
getDb()
.prepare(
'INSERT INTO bots(id, username, instructions, chat_id, created_at) VALUES (?, ?, ?, ?, ?)'
)
.run(bot.id, bot.username, bot.instructions, bot.chatId, bot.createdAt)
return bot
})()
}

export function updateBot(id: string, patch: { username?: string; instructions?: string }): Bot {
const bot = getBot(id)
if (!bot) throw new Error('Bot not found')
const username = patch.username === undefined ? bot.username : botUsername(patch.username)
const other = getBot(username)
if (other && other.id !== bot.id) throw new Error('That bot username is already taken')
if (patch.instructions !== undefined && typeof patch.instructions !== 'string')
throw new Error('Instructions must be text')
return getDb().transaction(() => {
getDb()
.prepare('UPDATE bots SET username = ?, instructions = ? WHERE id = ?')
.run(username, patch.instructions ?? bot.instructions, bot.id)
if (username !== bot.username) repo.renameChat(bot.chatId, username)
return getBot(bot.id)!
})()
}

export function removeBot(id: string): void {
const bot = getBot(id)
if (!bot) throw new Error('Bot not found')
repo.removeChat(bot.chatId)
}

export function listJobs(botId?: string): BotJob[] {
const rows = (
botId
? getDb()
.prepare(`SELECT ${JOB_COLUMNS} FROM bot_jobs WHERE bot_id = ? ORDER BY created_at, id`)
.all(botId)
: getDb().prepare(`SELECT ${JOB_COLUMNS} FROM bot_jobs ORDER BY created_at, id`).all()
) as (Omit<BotJob, 'schedule' | 'enabled'> & { schedule: string; enabled: number })[]
return rows.map((row) => ({ ...row, schedule: JSON.parse(row.schedule), enabled: !!row.enabled }))
}

export function saveJob(input: BotJobInput, id?: string): BotJob {
const bot = getBot(input.botId)
if (!bot) throw new Error('Bot not found')
const old = id ? listJobs(bot.id).find((job) => job.id === id) : undefined
if (id && !old) throw new Error('Schedule not found for this bot')
if (!input.name?.trim() || !input.prompt?.trim())
throw new Error('A schedule needs a name and prompt')
const remainingRuns =
input.remainingRuns === undefined ? (old?.remainingRuns ?? null) : input.remainingRuns
if (
remainingRuns !== null &&
(!Number.isSafeInteger(remainingRuns) ||
remainingRuns < 0 ||
(remainingRuns === 0 && input.enabled !== false))
) {
throw new Error('Run count must be a positive whole number, or null for unlimited')
}
const now = Date.now()
const enabled = input.enabled ?? old?.enabled ?? true
const computedNext = nextBotRun(input.schedule, now)
const next =
old?.enabled && enabled && JSON.stringify(old.schedule) === JSON.stringify(input.schedule)
? old.nextRunAt
: computedNext
if (enabled && next === null) throw new Error('This schedule has no future runs')
const job: BotJob = {
id: id ?? randomUUID(),
botId: bot.id,
name: input.name.trim(),
prompt: input.prompt,
schedule: input.schedule,
enabled,
nextRunAt: next,
lastRunAt: old?.lastRunAt ?? null,
remainingRuns,
createdAt: old?.createdAt ?? now
}
getDb()
.prepare(
`INSERT INTO bot_jobs(id, bot_id, name, prompt, schedule, enabled, next_run_at, last_run_at, remaining_runs, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET name = excluded.name, prompt = excluded.prompt, schedule = excluded.schedule,
enabled = excluded.enabled, next_run_at = excluded.next_run_at, remaining_runs = excluded.remaining_runs`
)
.run(
job.id,
job.botId,
job.name,
job.prompt,
JSON.stringify(job.schedule),
Number(job.enabled),
job.nextRunAt,
job.lastRunAt,
job.remainingRuns,
job.createdAt
)
return job
}

export function removeJob(id: string): void {
getDb().transaction(() => {
getDb().prepare(`DELETE FROM queue WHERE schedule_id = ? AND state = 'pending'`).run(id)
getDb().prepare('DELETE FROM bot_jobs WHERE id = ?').run(id)
})()
}

/** Advance and enqueue in one transaction: a crash cannot consume a beat without a delivery. */
export function enqueueDueJobs(now = Date.now()): string[] {
return getDb().transaction(() => {
const chats = new Set<string>()
for (const job of listJobs()) {
if (!job.enabled || job.nextRunAt === null || job.nextRunAt > now) continue
const bot = getBot(job.botId)
if (!bot) continue
const queued = getDb()
.prepare('SELECT COUNT(*) AS n FROM queue WHERE chat_id = ?')
.get(bot.chatId) as { n: number }
if (queued.n >= 100) continue
const next = nextBotRun(
job.schedule,
job.schedule.kind === 'timestamps' ? job.nextRunAt : now
)
const remaining = job.remainingRuns === null ? null : job.remainingRuns - 1
const item = repo.enqueue(bot.chatId, job.prompt)
getDb().prepare('UPDATE queue SET schedule_id = ? WHERE id = ?').run(job.id, item.id)
getDb()
.prepare(
'UPDATE bot_jobs SET last_run_at = ?, next_run_at = ?, remaining_runs = ?, enabled = ? WHERE id = ?'
)
.run(now, next, remaining, Number(next !== null && remaining !== 0), job.id)
chats.add(bot.chatId)
}
return [...chats]
})()
}
Loading
Loading