diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..e0e4c18 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "permissions": { + "defaultMode": "dontAsk" + } +} diff --git a/ROXY-PLUS.md b/ROXY-PLUS.md new file mode 100644 index 0000000..414aa95 --- /dev/null +++ b/ROXY-PLUS.md @@ -0,0 +1,90 @@ +# Roxy+ - channels + +Fork de Roxy con **canales**: varios bots en un mismo chat, que se pasan el +turno entre ellos. Guardado aparte del repo de Desktop, que sigue limpio en +`main`. + +- Rama: `roxy-plus/channels` +- Base: `cd9f244` (main de FreddyJD/roxy cuando se hizo el trabajo) +- Todo el trabajo esta en el commit `feat(channels): bots that talk to each other in one chat` + +## Arrancarlo + +```powershell +cd "$env:USERPROFILE\Documents\Roxy+" +npm install # node_modules NO se copio (888 MB) +npm run dev +``` + +`npm run typecheck` y `npm run smoke:shared` (1020 checks) pasan. + +## Que hace + +Una sesion es un **canal**, no un chat uno-a-uno. Roxy es el host y siempre +esta; los especialistas se agregan al lado, estilo WhatsApp. Se les habla con +`@Nombre`, y un mensaje sin mencion va a Roxy - asi el canal nunca queda sin +nadie escuchando. + +Un miembro es **Roxy con una especialidad**: su brief se _anade_ al prompt base, +no lo reemplaza, asi hereda el workspace, las reglas de tools, las skills y el +estilo de la casa en vez de arrancar como un modelo en blanco. + +## Los cuatro bugs que costaron encontrar + +Todos eran la misma raiz vista de distintos angulos: **el modelo no sabia que +el canal existia.** + +1. **El roster no entraba al prompt.** Solo se inyectaba el brief del miembro + que hablaba. Al pedirle "llama a @Bobo", Roxy buscaba un usuario de GitHub + llamado Bobo, no lo encontraba, y caia al tool `task` - que es la forma + equivocada: un subagente es un hijo en blanco de _su propio_ contexto, que + hereda sus errores y reporta solo de vuelta a el, y nunca aparece en el canal + como par. Lo arregla `channelPrompt()` en `src/shared/channel-members.ts`. + +2. **El enrutado le daba el trabajo entero al mencionado.** Se tomaba la + _primera_ mencion en cualquier posicion, asi que "crea el comando, crea el PR + y **despues** llama a @Bobo" se enrutaba completo a Bobo. Ahora solo cuenta + como dirigido si el mensaje **abre** con la mencion; una mencion a mitad de + frase es hablar _sobre_ alguien, y el turno se queda con el host, que hace el + trabajo y recien entonces pasa el turno. + +3. **El brief se leia como la tarea.** Pegado crudo, un bot con "revisas PRs y + dejas un roadmap" se ponia a clonar y diffear al recibir un "hola". Ahora el + brief va enmarcado como _standing identity_, y lo que decide que hacer es el + ultimo mensaje del canal. + +4. **Gemini rechazaba el hand-off con 400.** En un relevo nadie escribe un + mensaje nuevo, asi que el transcript _terminaba_ en un turno de assistant: + `Requests ending with a model turn are not supported`. Ya existia un `while` + que normalizaba el **inicio** de la ventana, pero nada el **final**. Ahora el + hand-off se replantea como linea de rol `user`, atribuida (`[Roxy]: @bobo el +PR esta listo`), lo que ademas arregla un fallo silencioso en otros + providers, que lo interpretaban como "segui escribiendo esa respuesta" en vez + de "responde a esto". + +## Los dos campos del panel (la confusion que quedo documentada) + +- **One-line role** -> `role`. Etiqueta corta. Se muestra junto al nombre y en + el roster que ven los otros bots. _Tambien va al prompt_, en la linea de + identidad - por eso poner el brief aca "medio funciona" y nada te avisa. +- **Its full instructions** -> `systemPrompt`. El brief completo, al final del + prompt como la instruccion mas especifica. + +El textarea ahora se ilumina cuando hay nombre pero no instrucciones. + +## Donde mirar + +| Que | Donde | +| --------------------------------------------------- | -------------------------------------------------------------------- | +| Roster, enrutado, hand-off, aislamiento de contexto | `src/shared/channel-members.ts` | +| Panel de miembros | `src/renderer/src/components/ChannelMembersPanel.tsx` | +| Avatares/acentos | `src/renderer/src/components/BotAvatar.tsx` | +| Relevo y armado de la ventana | `src/renderer/src/lib/store.ts` (`sendMessage`, `buildChatMessages`) | +| Inyeccion del bloque de canal | `src/main/harness/agent.ts` (`memberPrompt`) | +| Tests | `test/shared.ts` (buscar `channel:`) | + +## Pendiente + +- Las skills son por _agente_ y workspace, no por miembro: todos los bots ven + las mismas que Roxy. Un allowlist por miembro no existe todavia. +- `MAX_HANDOFF_HOPS` acota la cadena de relevos; cada hop es un turno completo. diff --git a/package-lock.json b/package-lock.json index f3417b8..581a522 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "roxy", - "version": "0.0.93", + "version": "0.0.94", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "roxy", - "version": "0.0.93", + "version": "0.0.94", "license": "MIT", "dependencies": { "@ai-sdk/anthropic": "^2.0.85", diff --git a/script/.es.mjs b/script/.es.mjs new file mode 100644 index 0000000..3aa8664 --- /dev/null +++ b/script/.es.mjs @@ -0,0 +1,48 @@ +import { readFileSync, writeFileSync } from 'node:fs' + +const path = 'src/renderer/src/locales/es.json' +const raw = readFileSync(path, 'utf8') +const crlf = raw.includes('\r\n') +const json = JSON.parse(raw) + +json.bots = { + attach: 'Añadir', + chatEmptyBody: + 'Aquí solo responde {{name}} — úsalo para enseñarle y para preguntarle directamente. En un proyecto, llega al mismo bot con @{{name}}.', + chatEmptyNoBrief: + '{{name}} todavía no tiene instrucciones, así que responderá como Roxy a secas. Edita el bot para decirle cómo trabajar.', + chatEmptyTitle: 'Este es tu chat con {{name}}', + chatPlaceholder: 'Escríbele a {{name}}…', + create: 'Crear bot', + delete: 'Eliminar bot', + descriptionHint: + 'Una línea sobre para qué sirve. La ves tú y también los demás bots con los que trabaja.', + descriptionLabel: 'Descripción', + descriptionPlaceholder: 'p. ej. Revisa los diffs antes de publicarlos', + edit: 'Editar bot', + editTitle: 'Editar {{name}}', + instructionsHint: + 'Cómo debe trabajar: qué hacer siempre, qué no hacer nunca, cómo se ve un buen resultado. Esto es lo que lo diferencia de Roxy.', + instructionsLabel: 'Instrucciones', + instructionsPlaceholder: + 'Revisas cambios buscando errores y riesgos.\n\nLee el diff completo antes de comentar. Señala pruebas que faltan y errores sin manejar. Sé específico: nombra el archivo y la línea. No reescribas el código tú mismo.', + lookLabel: 'Apariencia', + nameHint: 'Así lo llamarás en un proyecto: @Nombre.', + nameLabel: 'Nombre', + namePlaceholder: 'p. ej. Revisor', + new: 'Nuevo bot', + newFooter: 'Puedes cambiar todo esto más adelante.', + newShort: 'Nuevo', + newTitle: 'Nuevo bot', + openChat: 'Abrir chat', + save: 'Guardar', + savedGroup: 'Tus bots', + subtitle: + 'Un especialista con el que puedes chatear aparte y mencionar con @ en cualquier proyecto.' +} + +// Re-sort so the catalog keeps the alphabetical shape the sync script writes. +const sorted = Object.fromEntries(Object.entries(json).sort(([a], [b]) => a.localeCompare(b))) +const out = JSON.stringify(sorted, null, 2) + '\n' +writeFileSync(path, crlf ? out.replace(/\n/g, '\r\n') : out) +console.log('es.json bots translated') diff --git a/script/.p.json b/script/.p.json new file mode 100644 index 0000000..6263e67 --- /dev/null +++ b/script/.p.json @@ -0,0 +1,6 @@ +[ + { + "find": " 'no-scrollbar flex gap-2',\n railed ? 'flex-col items-center' : 'items-start overflow-x-auto pb-0.5'", + "replace": " 'flex gap-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',\n railed ? 'flex-col items-center' : 'items-start overflow-x-auto pb-0.5'" + } +] diff --git a/script/.p2.json b/script/.p2.json new file mode 100644 index 0000000..ff62d6d --- /dev/null +++ b/script/.p2.json @@ -0,0 +1,6 @@ +[ + { + "find": "
", + "replace": "
" + } +] diff --git a/script/.patch.mjs b/script/.patch.mjs new file mode 100644 index 0000000..16d1a16 --- /dev/null +++ b/script/.patch.mjs @@ -0,0 +1,27 @@ +/** + * CRLF-safe single-replacement patcher: node script/.patch.mjs + * patch.json = [{ "find": "...", "replace": "..." }, ...] (LF in the JSON). + */ +import { readFileSync, writeFileSync } from 'node:fs' + +const [file, patchFile] = process.argv.slice(2) +const raw = readFileSync(file, 'utf8') +const crlf = raw.includes('\r\n') +let src = raw.replace(/\r\n/g, '\n') +const patches = JSON.parse(readFileSync(patchFile, 'utf8')) + +for (const { find, replace } of patches) { + const first = src.indexOf(find) + if (first === -1) { + console.error(`NOT FOUND in ${file}:\n${find.slice(0, 200)}`) + process.exit(1) + } + if (src.indexOf(find, first + 1) !== -1) { + console.error(`NOT UNIQUE in ${file}:\n${find.slice(0, 200)}`) + process.exit(1) + } + src = src.slice(0, first) + replace + src.slice(first + find.length) +} + +writeFileSync(file, crlf ? src.replace(/\n/g, '\r\n') : src) +console.log(`patched ${file} (${patches.length})`) diff --git a/src/main/db/migrations.ts b/src/main/db/migrations.ts index 401482d..8a986c6 100644 --- a/src/main/db/migrations.ts +++ b/src/main/db/migrations.ts @@ -493,6 +493,47 @@ export const MIGRATIONS: Migration[] = [ hidden_at INTEGER NOT NULL, PRIMARY KEY (provider_id, model) ); + `, + + // ---- v24: channel members (multi-bot sessions) ---- + // A session is a CHANNEL several bots sit in, not a one-on-one chat with a + // single agent. This column holds the ATTACHED specialists as a JSON + // BotMember[]; Roxy (the host) is implicit and never stored, so she cannot be + // detached by a bad write, and every session that predates this - NULL here - + // is already a valid channel with just her in it. See shared/channel-members.ts. + (db) => { + addColumnIfMissing(db, 'chats', 'channel_members', 'TEXT') + // WHICH member wrote an assistant message. Denormalized onto the row (a + // JSON BotAuthor) rather than joined from the member list, because a bot + // can be detached later and the transcript must still show who spoke. + // NULL = written by Roxy, which every pre-channel message was. + addColumnIfMissing(db, 'messages', 'author', 'TEXT') + }, + + // ---- v25: saved bots (a bot library, each with its own chat) ---- + // Until now a bot existed only INSIDE the session it was attached to: its + // brief lived in that row's `channel_members` JSON, so the same specialist + // had to be retyped per project and could never be talked to on its own. + // + // This table makes a bot a first-class thing the user owns. `chat_id` is its + // private one-on-one conversation (a `bot`-kind session), created with the bot + // and cascaded away with it, which is where the bot is actually taught. The + // channel path is unchanged - attaching one still copies its identity into + // `chats.channel_members` - so every existing session keeps working and a bot + // detached from the library does not silently vanish from a live transcript. + /* sql */ ` + CREATE TABLE IF NOT EXISTS bots ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + icon TEXT NOT NULL DEFAULT 'builder', + color TEXT NOT NULL DEFAULT 'blue', + instructions TEXT NOT NULL DEFAULT '', + chat_id TEXT NOT NULL REFERENCES chats(id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); ` ] @@ -527,6 +568,24 @@ export function repairSchema(db: Database): void { addColumnIfMissing(db, 'chats', 'worktree_pending', 'TEXT') // v21's composite (multi-repo) workstream membership. addColumnIfMissing(db, 'chats', 'repos', 'TEXT') + // v22's channel membership and per-message authorship. + addColumnIfMissing(db, 'chats', 'channel_members', 'TEXT') + addColumnIfMissing(db, 'messages', 'author', 'TEXT') + // v25's saved-bot library. + db.exec(` + CREATE TABLE IF NOT EXISTS bots ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + icon TEXT NOT NULL DEFAULT 'builder', + color TEXT NOT NULL DEFAULT 'blue', + instructions TEXT NOT NULL DEFAULT '', + chat_id TEXT NOT NULL REFERENCES chats(id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + `) // v17's per-session inference config. addColumnIfMissing(db, 'chats', 'agent_id', 'TEXT') addColumnIfMissing(db, 'chats', 'reasoning_effort', 'TEXT') diff --git a/src/main/db/repo.ts b/src/main/db/repo.ts index 0b0f9be..16c4573 100644 --- a/src/main/db/repo.ts +++ b/src/main/db/repo.ts @@ -4,10 +4,15 @@ import { normalizeServerConfig, type McpServerConfig, type McpServerRecord } fro import { DEFAULT_BRANCH_PREFIX, normalizeBranchPrefix } from '../../shared/branch' import { DEFAULT_LANGUAGE, normalizeLanguage } from '../../shared/i18n' import type { Language } from '../../shared/i18n' +import { BOT_LOOKS, ROXY_HOST_ID, botId, botMember } from '../../shared/channel-members' import type { AddMessageInput, AppSettings, + Bot, + BotAuthor, + BotMember, Chat, + CreateBotInput, ConnectedProvider, ConnectProviderInput, IntegrationConnection, @@ -24,6 +29,7 @@ import type { SessionStatus, SessionTask, TokenUsage, + UpdateBotInput, UsageRecord, WorktreeIntent } from '../../shared/types' @@ -73,6 +79,7 @@ interface ChatRow { context_summary_at: number | null description: string | null tasks: string | null + channel_members: string | null sort_order: number created_at: number updated_at: number @@ -84,6 +91,7 @@ interface MessageRow { role: string content: string parts: string | null + author: string | null created_at: number } @@ -595,6 +603,28 @@ function parseTasks(raw: string | null): SessionTask[] { } } +/** + * Parse the channel_members JSON column into the session's ATTACHED bots. + * + * The host is not stored (see the v22 migration), so this returns only the + * specialists and every reader goes through `withHost` to get the real member + * list. Malformed rows degrade to an empty list rather than throwing: a session + * with no attached bots is still a working channel. + */ +function parseChannelMembers(raw: string | null): BotMember[] { + if (!raw) return [] + try { + const arr: unknown = JSON.parse(raw) + if (!Array.isArray(arr)) return [] + return arr.filter( + (m): m is BotMember => + !!m && typeof (m as BotMember).id === 'string' && typeof (m as BotMember).name === 'string' + ) + } catch { + return [] + } +} + function rowToChat(row: ChatRow): Chat { return { id: row.id, @@ -616,6 +646,7 @@ function rowToChat(row: ChatRow): Chat { contextSummaryAt: row.context_summary_at, description: row.description, tasks: parseTasks(row.tasks), + channelMembers: parseChannelMembers(row.channel_members), sortOrder: row.sort_order, createdAt: row.created_at, updatedAt: row.updated_at @@ -870,16 +901,16 @@ export function forkChat(sourceId: string, input: { title?: string } = {}): Chat const now = Date.now() const title = input.title?.trim() || `${source.title} (fork)` const messages = db - .prepare('SELECT role, content, parts, created_at FROM messages WHERE chat_id = ?') - .all(sourceId) as Pick[] + .prepare('SELECT role, content, parts, author, created_at FROM messages WHERE chat_id = ?') + .all(sourceId) as Pick[] const insertMessage = db.prepare( - 'INSERT INTO messages(id, chat_id, role, content, parts, created_at) VALUES(?, ?, ?, ?, ?, ?)' + 'INSERT INTO messages(id, chat_id, role, content, parts, author, created_at) VALUES(?, ?, ?, ?, ?, ?, ?)' ) db.transaction(() => { db.prepare( - `INSERT INTO chats(id, title, kind, provider_id, model, agent_id, reasoning_effort, context_limit, workspace_path, parent_id, context_summary, context_summary_at, description, sort_order, created_at, updated_at) - VALUES(?, ?, 'main', ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?)` + `INSERT INTO chats(id, title, kind, provider_id, model, agent_id, reasoning_effort, context_limit, workspace_path, parent_id, context_summary, context_summary_at, description, channel_members, sort_order, created_at, updated_at) + VALUES(?, ?, 'main', ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?)` ).run( id, title, @@ -892,12 +923,13 @@ export function forkChat(sourceId: string, input: { title?: string } = {}): Chat source.contextSummary, source.contextSummaryAt, source.description, + source.channelMembers.length ? JSON.stringify(source.channelMembers) : null, now, // sort_order: the fork lands at the top of its project, like any new session now, now ) for (const m of messages) { - insertMessage.run(randomUUID(), id, m.role, m.content, m.parts, m.created_at) + insertMessage.run(randomUUID(), id, m.role, m.content, m.parts, m.author, m.created_at) } })() @@ -1030,6 +1062,204 @@ export function setChatConfig(chatId: string, patch: SessionConfigPatch): Chat { return chat } +/** + * Replace a session's ATTACHED channel members (the host is never stored). + * + * Attaching or detaching a bot is a change to who is in the room, not agent + * activity, so this deliberately leaves `updated_at` alone - bumping it would + * float the session to the top of the sidebar just for opening the members + * panel, the same reason `setChatConfig` above skips it. + */ +export function setChannelMembers(chatId: string, members: BotMember[]): Chat { + const attached = members.filter((m) => m.id !== ROXY_HOST_ID) + getDb() + .prepare('UPDATE chats SET channel_members = ? WHERE id = ?') + .run(attached.length ? JSON.stringify(attached) : null, chatId) + const chat = getChat(chatId) + if (!chat) throw new Error('Chat not found') + return chat +} + +// ---- Saved bots -------------------------------------------------------------- + +interface BotRow { + id: string + name: string + description: string + icon: string + color: string + instructions: string + chat_id: string + sort_order: number + created_at: number + updated_at: number +} + +function rowToBot(row: BotRow): Bot { + return { + id: row.id, + name: row.name, + description: row.description, + icon: row.icon, + color: row.color, + instructions: row.instructions, + chatId: row.chat_id, + sortOrder: row.sort_order, + createdAt: row.created_at, + updatedAt: row.updated_at + } +} + +/** Every saved bot, in carousel order (newest first). */ +export function listBots(): Bot[] { + const rows = getDb() + .prepare('SELECT * FROM bots ORDER BY sort_order DESC, created_at DESC') + .all() as BotRow[] + return rows.map(rowToBot) +} + +export function getBot(id: string): Bot | undefined { + const row = getDb().prepare('SELECT * FROM bots WHERE id = ?').get(id) as BotRow | undefined + return row ? rowToBot(row) : undefined +} + +/** The bot that owns a chat, when that chat is a bot's own conversation. */ +export function getBotByChat(chatId: string): Bot | undefined { + const row = getDb().prepare('SELECT * FROM bots WHERE chat_id = ?').get(chatId) as + | BotRow + | undefined + return row ? rowToBot(row) : undefined +} + +/** + * Create a bot AND the chat it lives in, in one transaction. + * + * The chat is not optional and not lazy: a bot with no conversation is a + * preset, and the whole point of saving one is that you can open it and talk to + * it. Creating them together means the carousel can never hold a bot whose + * avatar opens nothing. + * + * The bot is ATTACHED to its own chat as a channel member, which is what makes + * it the one answering there - the turn path resolves the speaker from the + * session's membership, so a bot chat with an empty roster would be Roxy + * wearing the bot's name in the header. It carries no workspace: this chat is + * for teaching and asking, and a bot needs a project channel to touch files. + */ +export function createBot(input: CreateBotInput): Bot { + const db = getDb() + const now = Date.now() + const name = input.name.trim() || 'New bot' + const taken = (db.prepare('SELECT id FROM bots').all() as { id: string }[]).map((r) => r.id) + const id = botId(name, taken) + const bot: Bot = { + id, + name, + description: input.description?.trim() ?? '', + icon: input.icon ?? BOT_LOOKS[0].icon, + color: input.color ?? BOT_LOOKS[0].color, + instructions: input.instructions?.trim() ?? '', + chatId: '', + sortOrder: now, + createdAt: now, + updatedAt: now + } + const chat = createChat({ title: name, kind: 'bot' }) + const write = db.transaction(() => { + db.prepare( + `INSERT INTO bots(id, name, description, icon, color, instructions, chat_id, sort_order, created_at, updated_at) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + bot.id, + bot.name, + bot.description, + bot.icon, + bot.color, + bot.instructions, + chat.id, + bot.sortOrder, + now, + now + ) + db.prepare('UPDATE chats SET channel_members = ? WHERE id = ?').run( + JSON.stringify([botMember({ ...bot, chatId: chat.id })]), + chat.id + ) + }) + write() + const created = getBot(id) + if (!created) throw new Error('Failed to create bot') + return created +} + +/** + * Edit a bot, and re-stamp its identity onto the chat it owns. + * + * The membership rewrite is the part that is easy to forget and impossible to + * notice: a channel stores a COPY of the member, so renaming a bot without + * updating its own chat leaves that chat addressing `@OldName` with the old + * brief, in the one place the user went specifically to change it. + * + * PROJECT channels are deliberately left alone. A session's roster is a record + * of who was in that room, and rewriting every transcript's membership from + * here would retroactively rename bots in conversations that already happened. + */ +export function updateBot(id: string, patch: UpdateBotInput): Bot { + const current = getBot(id) + if (!current) throw new Error('Bot not found') + const next: Bot = { + ...current, + name: patch.name?.trim() || current.name, + description: patch.description?.trim() ?? current.description, + icon: patch.icon ?? current.icon, + color: patch.color ?? current.color, + instructions: patch.instructions?.trim() ?? current.instructions, + updatedAt: Date.now() + } + const db = getDb() + const write = db.transaction(() => { + db.prepare( + 'UPDATE bots SET name = ?, description = ?, icon = ?, color = ?, instructions = ?, updated_at = ? WHERE id = ?' + ).run(next.name, next.description, next.icon, next.color, next.instructions, next.updatedAt, id) + db.prepare('UPDATE chats SET title = ?, channel_members = ? WHERE id = ?').run( + next.name, + JSON.stringify([botMember(next)]), + next.chatId + ) + }) + write() + return next +} + +/** + * Delete a bot and its conversation. + * + * The chat goes with it: it belongs to the bot and nothing else can reach it + * once the carousel entry is gone, so leaving it behind would strand a session + * in the database with no way in. Sessions the bot was ATTACHED to keep their + * copy of it, so their transcripts still say who spoke. + */ +export function deleteBot(id: string): void { + const bot = getBot(id) + if (!bot) return + const db = getDb() + const write = db.transaction(() => { + db.prepare('DELETE FROM bots WHERE id = ?').run(id) + db.prepare('DELETE FROM chats WHERE id = ?').run(bot.chatId) + }) + write() +} + +/** Persist the carousel order; `ids` is the full list, first to last. */ +export function reorderBots(ids: string[]): void { + const db = getDb() + const stmt = db.prepare('UPDATE bots SET sort_order = ? WHERE id = ?') + const write = db.transaction(() => { + // Descending, matching `listBots` — first in the list gets the highest key. + ids.forEach((id, i) => stmt.run(ids.length - i, id)) + }) + write() +} + /** Update agent-settable session metadata (any subset of name / description / tasks). */ export function setChatMetadata( chatId: string, @@ -1161,6 +1391,18 @@ function parseParts(raw: string | null, content: string): MessagePart[] { return [{ type: 'text', text: content }] } +/** Parse the author JSON column, tolerating malformed data. */ +function parseAuthor(raw: string | null): BotAuthor | undefined { + if (!raw) return undefined + try { + const a: unknown = JSON.parse(raw) + if (!a || typeof (a as BotAuthor).name !== 'string') return undefined + return a as BotAuthor + } catch { + return undefined + } +} + function rowToMessage(row: MessageRow): Message { return { id: row.id, @@ -1168,7 +1410,8 @@ function rowToMessage(row: MessageRow): Message { role: row.role as MessageRole, content: row.content, parts: parseParts(row.parts, row.content), - createdAt: row.created_at + createdAt: row.created_at, + author: parseAuthor(row.author) } } @@ -1184,17 +1427,18 @@ export function addMessage(input: AddMessageInput): Message { const now = Date.now() const parts: MessagePart[] = input.parts ?? [{ type: 'text', text: input.content }] const partsJson = JSON.stringify(parts) + const authorJson = input.author ? JSON.stringify(input.author) : null const db = getDb() const tx = db.transaction(() => { db.prepare( - 'INSERT INTO messages(id, chat_id, role, content, parts, created_at) VALUES(?, ?, ?, ?, ?, ?)' - ).run(id, input.chatId, input.role, input.content, partsJson, now) + 'INSERT INTO messages(id, chat_id, role, content, parts, author, created_at) VALUES(?, ?, ?, ?, ?, ?, ?)' + ).run(id, input.chatId, input.role, input.content, partsJson, authorJson, now) db.prepare('UPDATE chats SET updated_at = ? WHERE id = ?').run(now, input.chatId) - // One assistant message = one agent turn. Credited to the durable ledger in - // the SAME transaction as the message, so the graph can never disagree with - // what was actually persisted - and, unlike the message, the credit stays - // when the session is later deleted. Counts sub and loop sessions too, which - // is what the previous message-counting query did. + // One assistant message = one agent turn. Credited to the durable ledger + // in the SAME transaction as the message, so the graph can never disagree + // with what was actually persisted - and, unlike the message, the credit + // stays when the session is later deleted. Counts sub and loop sessions + // too, which is what the previous message-counting query did. if (input.role === 'assistant') recordActivityTurn(localDay(now)) }) tx() @@ -1204,7 +1448,8 @@ export function addMessage(input: AddMessageInput): Message { role: input.role, content: input.content, parts, - createdAt: now + createdAt: now, + author: input.author } } diff --git a/src/main/harness/agent.ts b/src/main/harness/agent.ts index 5fb2083..4ede6c2 100644 --- a/src/main/harness/agent.ts +++ b/src/main/harness/agent.ts @@ -456,7 +456,8 @@ function buildSystemMessage( chatId?: string, agent?: AgentDef, mcpInfo?: string, - skillInfo?: string + skillInfo?: string, + memberPrompt?: string ): string { const base = promptText[selectPromptName(model)] || promptText.default || FALLBACK_PROMPT const gitRoot = cwd ? findGitRoot(cwd) : undefined @@ -479,7 +480,13 @@ function buildSystemMessage( ...instructions, ...(skillInfo ? [skillInfo] : []), ...(mcpInfo ? [mcpInfo] : []), - ...(agentPrompt ? [agentPrompt] : []) + ...(agentPrompt ? [agentPrompt] : []), + // LAST, so the channel block (roster + the member's brief) is the most + // specific instruction in the prompt. It is APPENDED to Roxy's base rather + // than replacing it: a member is Roxy with a specialty, so it inherits the + // workspace, the tool rules, and the house style instead of starting as a + // blank model that has to be told who it is first. + ...(memberPrompt ? [memberPrompt] : []) ] const contextSummary = chatId ? (repo.getChat(chatId)?.contextSummary ?? undefined) : undefined return assembleSystemPrompt({ @@ -785,7 +792,7 @@ type ToolSchema = ReturnType /** The delegation tool — lets a primary agent spawn a focused subagent. */ const TASK_SCHEMA = fn( 'task', - 'Delegate a focused, self-contained sub-task to a specialized subagent that runs on its own and reports back. Use this to parallelize or offload work (e.g. research the codebase, build a page). The subagent has NO memory of this conversation, so put ALL the context it needs into `prompt`. It returns a single report. Call task multiple times IN ONE turn to batch independent work. CONCURRENCY: read-only "explore" subagents run in PARALLEL (bounded) - that is what subagents are for, and you should fan them out freely. Write-capable "general" subagents are SERIALIZED one at a time, because they share this session\'s working directory and would otherwise overwrite each other\'s edits; several of them in one turn is correct but no faster than doing the work yourself. To get genuinely parallel WRITES, the user should open separate sessions - each gets its own git worktree and therefore its own filesystem.', + 'Delegate a focused, self-contained sub-task to a specialized subagent that runs on its own and reports back. Use this to parallelize or offload work (e.g. research the codebase, build a page). The subagent has NO memory of this conversation, so put ALL the context it needs into `prompt`. It returns a single report. Call task multiple times IN ONE turn to batch independent work. CONCURRENCY: read-only "explore" subagents run in PARALLEL (bounded) - that is what subagents are for, and you should fan them out freely. Write-capable "general" subagents are SERIALIZED one at a time, because they share this session\'s working directory and would otherwise overwrite each other\'s edits; several of them in one turn is correct but no faster than doing the work yourself. To get genuinely parallel WRITES, the user should open separate sessions - each gets its own git worktree and therefore its own filesystem. This tool is NOT how you reach another bot in this channel: a subagent is a blank child of your own context, while a channel member is a peer with its own brief - to reach one, @mention them at the end of your reply.', { description: str('A short (3-5 word) label for the task.'), prompt: str('The complete task for the subagent, including every bit of context it needs.'), @@ -886,6 +893,12 @@ export interface RunTurnOptions { chatId?: string /** Which primary agent to run (e.g. "build" or "plan"). Defaults to build. */ agentId?: string + /** + * The channel block for the answering member - the roster plus that member's + * own brief - appended to the base system prompt. Absent in a solo channel, + * which is Roxy's base prompt unmodified. See shared/channel-members.ts. + */ + memberPrompt?: string signal: AbortSignal emit: (event: LlmEvent) => void /** Whether the model supports reasoning (gates the reasoning params). */ @@ -955,6 +968,7 @@ export async function runAgentTurn(opts: RunTurnOptions): Promise { cwd, chatId, agentId, + memberPrompt, signal, emit, reasoning, @@ -1027,7 +1041,8 @@ export async function runAgentTurn(opts: RunTurnOptions): Promise { chatId, agent, mcpInfo, - parentSkillInfo + parentSkillInfo, + memberPrompt ) const systemMessage: ChatMessage = { role: 'system', content: systemText } diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 4116a02..c55f048 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -26,8 +26,11 @@ import type { import type { AddMessageInput, ConnectProviderInput, + CreateBotInput, + SetChannelMembersInput, QueueImage, - ReasoningEffort + ReasoningEffort, + UpdateBotInput } from '../../shared/types' import * as repo from '../db/repo' import * as copilot from '../services/copilot' @@ -344,6 +347,31 @@ export function registerIpc(): void { // ---- messages ---- ipcMain.handle(CHANNELS.messagesList, (_e, chatId: string) => repo.listMessages(chatId)) ipcMain.handle(CHANNELS.messagesAdd, (_e, input: AddMessageInput) => repo.addMessage(input)) + ipcMain.handle(CHANNELS.channelSetMembers, (_e, input: SetChannelMembersInput) => + repo.setChannelMembers(input.chatId, input.members) + ) + + // ---- saved bots (the bot library) ---- + ipcMain.handle(CHANNELS.botsList, () => repo.listBots()) + ipcMain.handle(CHANNELS.botsCreate, (_e, input: CreateBotInput) => repo.createBot(input)) + ipcMain.handle(CHANNELS.botsUpdate, (_e, id: string, patch: UpdateBotInput) => + repo.updateBot(id, patch) + ) + ipcMain.handle(CHANNELS.botsRemove, (_e, id: string) => { + // A bot's chat is a real session, so it gets the same teardown any deleted + // session does before the row goes. It owns no worktree (a bot chat has no + // workspace) but it CAN have opened a browser or a background process from + // a tool call, and those outlive the row unless they are stopped here. + const bot = repo.getBot(id) + if (bot) { + cancelSessionBackgroundJobs(bot.chatId) + endSubagentRuns(bot.chatId) + killSessionBackground(bot.chatId) + browser.disposeSession(bot.chatId) + } + return repo.deleteBot(id) + }) + ipcMain.handle(CHANNELS.botsReorder, (_e, ids: string[]) => repo.reorderBots(ids)) // ---- integrations ---- ipcMain.handle(CHANNELS.integrationsList, () => repo.listIntegrations()) diff --git a/src/main/services/compaction.ts b/src/main/services/compaction.ts index 1d9a5b2..0e8947c 100644 --- a/src/main/services/compaction.ts +++ b/src/main/services/compaction.ts @@ -41,7 +41,9 @@ function flatten(m: Message): string { : `[tool:${p.tool}]` : p.type === 'image' ? '[image]' - : p.text + : p.type === 'text' || p.type === 'reasoning' + ? p.text + : '' ) .join('') .trim() diff --git a/src/main/services/session-turn.ts b/src/main/services/session-turn.ts index 79df555..221fa18 100644 --- a/src/main/services/session-turn.ts +++ b/src/main/services/session-turn.ts @@ -190,6 +190,7 @@ async function runTurn( model: input.model, messages: input.messages, agentId: input.agentId, + memberPrompt: input.memberPrompt, reasoning: input.reasoning, reasoningEffort: input.reasoningEffort, contextLimit: input.contextLimit, diff --git a/src/preload/index.ts b/src/preload/index.ts index c671b0b..0b2377b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -65,6 +65,16 @@ const roxy: RoxyApi = { list: (chatId) => ipcRenderer.invoke(CHANNELS.messagesList, chatId), add: (input) => ipcRenderer.invoke(CHANNELS.messagesAdd, input) }, + channel: { + setMembers: (input) => ipcRenderer.invoke(CHANNELS.channelSetMembers, input) + }, + bots: { + list: () => ipcRenderer.invoke(CHANNELS.botsList), + create: (input) => ipcRenderer.invoke(CHANNELS.botsCreate, input), + update: (id, patch) => ipcRenderer.invoke(CHANNELS.botsUpdate, id, patch), + remove: (id) => ipcRenderer.invoke(CHANNELS.botsRemove, id), + reorder: (ids) => ipcRenderer.invoke(CHANNELS.botsReorder, ids) + }, integrations: { list: () => ipcRenderer.invoke(CHANNELS.integrationsList), setEnabled: (id, enabled) => ipcRenderer.invoke(CHANNELS.integrationsSetEnabled, id, enabled) diff --git a/src/renderer/src/components/BotAvatar.tsx b/src/renderer/src/components/BotAvatar.tsx new file mode 100644 index 0000000..f8de60d --- /dev/null +++ b/src/renderer/src/components/BotAvatar.tsx @@ -0,0 +1,105 @@ +import { Bot, Hammer, Scale, Search, ShieldCheck, TestTube } from 'lucide-react' +import type { BotAuthor, BotMember } from '@shared/types' +import { ROXY_HOST_ID } from '@shared/channel-members' +import roxy from '../assets/roxy.png' +import { cn } from '../lib/cn' + +/** + * Per-member accent, keyed by `BotMember.color`. + * + * A palette rather than free-form classes so a member added at runtime can only + * pick a color that actually reads against the surface — and so the avatar, the + * name in the transcript, and the `@mention` chip all tint from one place + * instead of three lists that drift apart. + */ +const ACCENTS = { + accent: { text: 'text-accent', chip: 'bg-accent/15 text-accent border-accent/30' }, + blue: { text: 'text-blue-400', chip: 'bg-blue-500/15 text-blue-300 border-blue-500/30' }, + purple: { + text: 'text-purple-400', + chip: 'bg-purple-500/15 text-purple-300 border-purple-500/30' + }, + emerald: { + text: 'text-emerald-400', + chip: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30' + }, + amber: { text: 'text-amber-400', chip: 'bg-amber-500/15 text-amber-300 border-amber-500/30' }, + cyan: { text: 'text-cyan-400', chip: 'bg-cyan-500/15 text-cyan-300 border-cyan-500/30' } +} as const + +export type AccentKey = keyof typeof ACCENTS + +export function accentOf(color?: string): (typeof ACCENTS)[AccentKey] { + return ACCENTS[(color ?? '') as AccentKey] ?? ACCENTS.accent +} + +/** Every icon a member can carry. `Bot` is the fallback for an unknown key. */ +const ICONS = { + builder: Hammer, + reviewer: Search, + security: ShieldCheck, + architect: Scale, + tester: TestTube +} as const + +const SIZES = { + sm: { box: 'h-6 w-6', glyph: 'h-3 w-3' }, + md: { box: 'h-7 w-7', glyph: 'h-4 w-4' }, + lg: { box: 'h-9 w-9', glyph: 'h-5 w-5' } +} as const + +/** + * A channel member's avatar. + * + * Takes either a live `BotMember` (the roster, the `@` menu) or the `BotAuthor` + * denormalized onto a message (the transcript), because a message's author may + * have been detached from the channel since it was written and must still + * render. No author at all means Roxy — every message that predates channels. + */ +export function BotAvatar({ + member, + author, + size = 'md', + className +}: { + member?: BotMember + author?: BotAuthor + size?: keyof typeof SIZES + className?: string +}): JSX.Element { + const id = member?.id + const name = member?.name ?? author?.name + const icon = member?.icon ?? author?.icon + const color = member?.color ?? author?.color + const { box, glyph } = SIZES[size] + + // The host wears the app's own face — it IS Roxy, not a bot standing in for her. + if (!name || id === ROXY_HOST_ID || icon === 'roxy') { + return ( + Roxy + ) + } + + const Icon = ICONS[(icon ?? '') as keyof typeof ICONS] ?? Bot + return ( +
+ +
+ ) +} diff --git a/src/renderer/src/components/BotCarousel.tsx b/src/renderer/src/components/BotCarousel.tsx new file mode 100644 index 0000000..4513b8f --- /dev/null +++ b/src/renderer/src/components/BotCarousel.tsx @@ -0,0 +1,152 @@ +import { useTranslation } from 'react-i18next' +import { Plus } from 'lucide-react' +import type { Bot } from '@shared/types' +import { BotAvatar } from './BotAvatar' +import { cn } from '../lib/cn' + +/** + * The saved bots, as a row of faces above the session list — Instagram-stories + * shaped, and for the same reason: a roster is browsed by recognition, not read + * as a list. + * + * It sits between the two buttons and the sessions on purpose. Bots are not + * sessions, so listing them among the chats would make them look like more + * conversations to scroll past; and a bot you cannot see in one glance is a bot + * you forget you made. One tap opens its own chat, so the strip is also the + * only navigation a bot needs. + * + * Squircles, not circles, even though the reference is round: `BotAvatar` is + * masked to the app's superellipse, so a `rounded-full` wrapper would draw a + * circle around a visibly non-circular face and the mismatch shows at the + * corners. The ring follows the avatar's shape one size up instead. + * + * Empty means empty: with no bots this renders nothing at all rather than a + * placeholder rail. The "New bot" button above is already the empty state (see + * the diagram), and an empty strip would just be a second, quieter one. + */ +export function BotCarousel({ + bots, + activeChatId, + busyChatIds, + onOpen, + onEdit, + onNew, + railed +}: { + bots: Bot[] + activeChatId: string | null + /** Bot chats with a turn in flight — pulses the ring, as stories do for unseen. */ + busyChatIds: Set + onOpen: (bot: Bot) => void + onEdit: (bot: Bot) => void + onNew: () => void + /** Collapsed sidebar: stack vertically instead of scrolling sideways. */ + railed?: boolean +}): JSX.Element | null { + const { t } = useTranslation() + + if (bots.length === 0) return null + + return ( +
+
{ + if (railed || Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return + e.currentTarget.scrollLeft += e.deltaX + e.preventDefault() + }} + className={cn( + 'flex gap-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden', + railed ? 'flex-col items-center' : 'items-start overflow-x-auto pb-0.5' + )} + > + {bots.map((bot) => { + const active = bot.chatId === activeChatId + const busy = busyChatIds.has(bot.chatId) + return ( + + ) + })} + + {/* The trailing "+" from the diagram — once bots exist, adding another + is a move within the roster, so it lives at the end of the row. */} + +
+
+ ) +} diff --git a/src/renderer/src/components/BotDialog.tsx b/src/renderer/src/components/BotDialog.tsx new file mode 100644 index 0000000..9ffa135 --- /dev/null +++ b/src/renderer/src/components/BotDialog.tsx @@ -0,0 +1,233 @@ +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Bot as BotIcon, Trash2, X } from 'lucide-react' +import type { Bot, CreateBotInput } from '@shared/types' +import { BOT_LOOKS } from '@shared/channel-members' +import { BotAvatar, accentOf } from './BotAvatar' +import { Button, Input, Textarea } from './ui' +import { cn } from '../lib/cn' + +/** + * The form you get right after clicking "New bot" — and the one you get back + * when you edit it. + * + * It is deliberately a blocking step rather than a bot that appears fully + * formed with a default brief. The three fields ARE the bot: what to call it, + * what it is for, and how it should work. Filling them in is the moment the + * user learns that a bot is something they define, not a preset they pick — so + * skipping straight to a chat with "New Bot" would teach exactly the wrong + * model of the feature (and produce a roster of identical bots). + * + * The same component covers create and edit, because the thing being described + * does not change between the two, and having two dialogs is how the wording of + * the two fields drifts apart. + */ +export function BotDialog({ + bot, + onSave, + onDelete, + onClose +}: { + /** The bot being edited, or undefined to create a new one. */ + bot?: Bot + onSave: (input: CreateBotInput) => Promise + /** Offered only when editing. */ + onDelete?: () => Promise + onClose: () => void +}): JSX.Element { + const { t } = useTranslation() + const [name, setName] = useState(bot?.name ?? '') + const [description, setDescription] = useState(bot?.description ?? '') + const [instructions, setInstructions] = useState(bot?.instructions ?? '') + const [look, setLook] = useState(() => { + const i = BOT_LOOKS.findIndex((l) => l.icon === bot?.icon) + return i === -1 ? 0 : i + }) + const [saving, setSaving] = useState(false) + + useEffect(() => { + const onKey = (e: KeyboardEvent): void => { + if (e.key === 'Escape') onClose() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [onClose]) + + const canSave = !!name.trim() && !saving + + const save = async (): Promise => { + if (!canSave) return + setSaving(true) + try { + await onSave({ + name: name.trim(), + description: description.trim(), + instructions: instructions.trim(), + icon: BOT_LOOKS[look].icon, + color: BOT_LOOKS[look].color + }) + onClose() + } finally { + setSaving(false) + } + } + + const remove = async (): Promise => { + if (!onDelete) return + setSaving(true) + try { + await onDelete() + onClose() + } finally { + setSaving(false) + } + } + + return ( +
+
e.stopPropagation()} + > +
+ {/* The live avatar, not a generic dialog icon: the picked face is part + of what is being authored, so it belongs where the title is. */} +
+ {name.trim() ? ( + + ) : ( +
+ +
+ )} +
+
+

+ {bot ? t('bots.editTitle', { name: bot.name }) : t('bots.newTitle')} +

+

{t('bots.subtitle')}

+
+ +
+ +
+ + setName(e.target.value)} + placeholder={t('bots.namePlaceholder')} + /> + + + + setDescription(e.target.value)} + placeholder={t('bots.descriptionPlaceholder')} + /> + + + +
+ {BOT_LOOKS.map((l, i) => ( + + ))} +
+
+ + {/* The one field that actually decides how the bot behaves, and the + one people put a one-liner in. Highlighted while empty once a name + is in - the mistake is silent otherwise, because a bot with no + brief still answers (as plain Roxy) and nothing says why. */} + +