Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"permissions": {
"defaultMode": "dontAsk"
}
}
90 changes: 90 additions & 0 deletions ROXY-PLUS.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions package-lock.json

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

48 changes: 48 additions & 0 deletions script/.es.mjs
Original file line number Diff line number Diff line change
@@ -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')
6 changes: 6 additions & 0 deletions script/.p.json
Original file line number Diff line number Diff line change
@@ -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'"
}
]
6 changes: 6 additions & 0 deletions script/.p2.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[
{
"find": " <div className=\"no-scrollbar mt-2 flex min-h-0 w-full flex-col overflow-y-auto\">",
"replace": " <div className=\"mt-2 flex min-h-0 w-full flex-col overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\">"
}
]
27 changes: 27 additions & 0 deletions script/.patch.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* CRLF-safe single-replacement patcher: node script/.patch.mjs <file> <patch.json>
* 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})`)
59 changes: 59 additions & 0 deletions src/main/db/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
`
]

Expand Down Expand Up @@ -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')
Expand Down
Loading