diff --git a/README.md b/README.md index fac9441a5..81fef1d87 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,18 @@ agent-office setup # the first-start walkthrough again Every option is in [docs/configuration.md](docs/configuration.md). Choosing models and providers per worker is in [docs/agents.md](docs/agents.md). +### Editorial JeyTid (optional) + +This checkout also contains an optional private editorial subsystem for a single owner. It is +disabled by default so the base office keeps its Node 20 behavior. Enable it on Node 22.5+ with +`EDITORIAL_ENABLED=1`; it persists projects, revisions, tasks, artifacts, approvals, costs and +backups under the office data directory and exposes the authenticated `/editorial` view. Telegram, +OpenAI and Mastodon remain opt-in connectors and are never reported as connected without their +credentials and an observed response. + +See [docs/editorial.md](docs/editorial.md) for setup, commands, recovery, capability limits and the +official sources used by the adapters. A local mock demo is available with `agent-office editorial demo`. + To run it from a clone instead: ```bash diff --git a/docs/editorial.env.example b/docs/editorial.env.example new file mode 100644 index 000000000..ed5290f74 --- /dev/null +++ b/docs/editorial.env.example @@ -0,0 +1,17 @@ +# Copy into the process environment; never commit real values. +EDITORIAL_ENABLED=1 +TELEGRAM_BOT_TOKEN= +TELEGRAM_OWNER_ID= +TELEGRAM_ALLOWED_CHAT_IDS= +EDITORIAL_PROVIDER=mock +EDITORIAL_AUTONOMOUS_MODE=0 +OPENAI_API_KEY= +EDITORIAL_TEXT_MODEL= +EDITORIAL_IMAGE_MODEL=gpt-image-1 +EDITORIAL_MAX_CONCURRENT=3 +EDITORIAL_BUDGET_USD=5 +EDITORIAL_BUDGET_WARNING_RATIO=0.8 +EDITORIAL_AGENT_OFFICE_FLOOR= +EDITORIAL_AGENT_OFFICE_PROVIDER=claude +MASTODON_BASE_URL=https:// +MASTODON_ACCESS_TOKEN= diff --git a/docs/editorial.md b/docs/editorial.md new file mode 100644 index 000000000..09a561b07 --- /dev/null +++ b/docs/editorial.md @@ -0,0 +1,154 @@ +# Editorial JeyTid + +Editorial es un subsistema opcional de Agent Office para dirigir una biblioteca personal desde un +solo propietario. El código vive en `src/server/editorial/` y no cambia el comportamiento del +oficio base mientras `EDITORIAL_ENABLED` no sea `1`. + +## Requisitos y activación + +El oficio base admite Node 20+. Editorial usa `node:sqlite`, por lo que requiere Node 22.5 o una +versión posterior con ese módulo disponible. La base y los archivos privados quedan bajo: + +```text +/.agent-office/editorial/ + editorial.sqlite + files/projects//... +``` + +Ejemplo de configuración (no contiene credenciales): + +```powershell +$env:EDITORIAL_ENABLED = "1" +$env:TELEGRAM_BOT_TOKEN = "" +$env:TELEGRAM_OWNER_ID = "" +$env:TELEGRAM_ALLOWED_CHAT_IDS = "" +$env:EDITORIAL_PROVIDER = "mock" # cambia a openai solo con OPENAI_API_KEY +$env:EDITORIAL_AUTONOMOUS_MODE = "0" # 1 ejecuta tareas elegibles sin pedir confirmación por tarea +$env:EDITORIAL_BUDGET_USD = "5" +$env:EDITORIAL_MAX_CONCURRENT = "3" +``` + +También se puede comprobar la instalación sin levantar el servidor: + +```bash +agent-office editorial doctor --data-dir .agent-office +agent-office editorial demo --data-dir .agent-office +agent-office editorial snapshot --data-dir .agent-office +agent-office editorial backup --data-dir .agent-office --destination ./editorial-backup +agent-office editorial restore ./editorial-backup --data-dir ./restored-office +``` + +`demo` usa un proveedor mock determinista y nunca publica en KDP ni en una red social. + +## Modelo operativo + +La entrada de una obra crea un proyecto aislado, una revisión inicial del brief y un flujo durable +con once perfiles: dirección editorial, investigación, derechos/procedencia, escritura, edición de +desarrollo, corrección, arte, maquetación/KDP, auditoría independiente, publicación KDP y marketing. +Cada tarea guarda contrato, dependencias, intentos, generación, lease, heartbeat, checkpoints, +resumen, proveedor, modelo, versión de perfil, evidencias, costos y artefactos. No se almacena +razonamiento privado. + +La cola reserva presupuesto dentro de una transacción SQLite, limita la concurrencia, recupera +leases vencidos y descarta respuestas tardías cuando la generación ya no coincide o la tarea fue +cancelada. Una revisión nueva revoca aprobaciones y marca releases preparados como `needs_changes`. +Los artefactos guardan SHA-256, revisión y procedencia; las rutas nunca vienen directamente del +usuario. + +## Telegram + +El bot usa long polling de `getUpdates`, conserva el offset y deduplica `update_id` en SQLite. Solo +acepta el `TELEGRAM_OWNER_ID` y los chats numéricos incluidos en +`TELEGRAM_ALLOWED_CHAT_IDS`. No se concede autoridad por nombre de usuario. + +Comandos principales: + +```text +/nuevo_libro +/ideas [contexto] +/proyectos +/estado +/tareas [project_id] +/modo +/ejecutar [project_id] +/agentes +/costos +/aprobaciones +/publicar +/pausar /continuar +/cancelar +``` + +En modo `supervisado` (`EDITORIAL_AUTONOMOUS_MODE=0`, valor predeterminado), crear un libro o una +tarea deja la cola pendiente; el propietario inicia el trabajo con `/ejecutar project_id` o +`/ejecutar` para toda la cola. En modo `autónomo` (`EDITORIAL_AUTONOMOUS_MODE=1`), el orquestador +revisa la cola periódicamente y ejecuta las tareas elegibles sin confirmación individual. La vista +`/editorial`, `/modo`, `/estado` y `/tareas` permiten observar el avance. El propietario puede cambiar +el modo del proceso actual con `/modo autonomo` o `/modo supervisado`; para conservar la elección +después de reiniciar, actualiza `EDITORIAL_AUTONOMOUS_MODE`. Este modo no autoriza por +sí mismo publicación externa, gasto publicitario, login en KDP ni publicación social: esas acciones +siguen sujetas a la política de aprobación del propietario. + +Un documento adjunto debe llevar `project_id` al inicio del caption y no puede superar 20 MB. Los +botones de aprobación llevan solo el id opaco de la aprobación; el token secreto nunca se envía en +el callback. Aprobar cambia el estado a `approved`; `/publicar` vuelve a comprobar el hash exacto, +consume la aprobación una sola vez y deja el release en `assisted`. La acción externa KDP no se +ejecuta automáticamente. + +## Documentos, derechos y release + +El módulo conserva fuente Markdown editable, EPUB, PDF interior básico, render HTML, metadatos, +checklist KDP y ZIP con `MANIFEST.json`. EPUB y PDF tienen validación estructural local; el PDF no +pretende ser una preflight tipográfica profesional. La auditoría comprueba archivos, hashes, +formatos, derechos y hallazgos bloqueantes antes de marcar un candidato como aprobado. + +La preparación KDP distingue `prepared` y `assisted` de `published`. El propietario todavía debe +revisar identidad, territorios, precio, páginas y cubierta, y completar login, MFA, CAPTCHA y envío. +No se realizan pruebas de publicación real. + +## Marketing y conectores + +El calendario social es durable, exportable y requiere aprobación del propietario antes de publicar. +Existe un conector opcional real para Mastodon: solo usa `MASTODON_BASE_URL` y +`MASTODON_ACCESS_TOKEN` en memoria, llama `POST /api/v1/statuses` y solo registra `published` si la +respuesta remota contiene un id. Sin esas variables, el estado es exportación asistida y no se +simula una publicación. + +## Vista, seguridad y recuperación + +Con el usuario del oficio autenticado, `/editorial` muestra proyectos, tareas, runs, revisiones, +artefactos, aprobaciones, hallazgos, capacidades, costos y conectores. Los endpoints son de sesión, +los secretos no aparecen en el snapshot, y la ruta de artefactos valida el registro privado antes de +leerlo. El almacenamiento usa directorios privados, escritura temporal + rename y hashes. + +El respaldo copia SQLite y `files/` después de checkpoint WAL y crea `BACKUP-MANIFEST.json`. Se puede +restaurar en un directorio separado para verificar sin tocar la instalación activa; `--force` es +obligatorio para reemplazar un destino que ya tenga base. + +## Matriz de capacidades actual + +| Capacidad | Estado sin credenciales | Evidencia o límite | +| --- | --- | --- | +| Estado, revisiones, canon aislado | Implementada | SQLite + invalidación por revisión | +| Cola durable, leases, checkpoints | Implementada | Reserva y recuperación transaccional | +| Once perfiles y adaptador Agent Office | Implementada / requiere floor para despachar | El proveedor editorial local sigue siendo la autoridad del resultado | +| Texto e imagen real | Requiere configuración | Adaptadores HTTP; no hay llamada real en pruebas | +| Mock reproducible | Probada localmente | Texto e imagen marcados como mock | +| EPUB | Implementada y validada localmente | Contenedor y archivos requeridos | +| PDF | Limitada y validada localmente | PDF de texto básico, no preflight de imprenta | +| KDP automatizado | No soportada | Se requiere ruta asistida con propietario | +| Calendario social | Implementada | SQLite + exportación | +| Mastodon | Requiere configuración | Solo se confirma publicación con respuesta remota | +| Telegram | Requiere token e id | Polling, deduplicación, callbacks y adjuntos | + +## Fuentes oficiales consultadas + +- Agent Office: [README](https://github.com/AgentSystemLabs/agent-office), [cómo funciona](https://github.com/AgentSystemLabs/agent-office/blob/main/docs/how-it-works.md), [agentes](https://github.com/AgentSystemLabs/agent-office/blob/main/docs/agents.md). +- [Node.js SQLite](https://nodejs.org/api/sqlite.html). +- [Telegram Bot API](https://core.telegram.org/bots/api), incluyendo `getUpdates`, callbacks y límites de archivos. +- [OpenAI Responses API](https://developers.openai.com/api/reference/resources/responses) y [Images API](https://developers.openai.com/api/reference/resources/images). +- [KDP Help](https://kdp.amazon.com/en_US/help), [preparación de cubierta](https://kdp.amazon.com/en_US/help/topic/G202187850) y [plantillas/calculador](https://kdp.amazon.com/es_ES/cover-templates). +- [Mastodon: publicar un status](https://docs.joinmastodon.org/methods/statuses/) y [guías de API](https://docs.joinmastodon.org/api/guidelines/). + +Estas fuentes describen APIs y políticas externas; la matriz anterior es el estado verificable de +esta instalación, no una promesa de capacidades de las plataformas. diff --git a/package-lock.json b/package-lock.json index e3f643997..9f2ba2144 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@lydell/node-pty": "^1.2.0-beta.15", "@xterm/addon-serialize": "^0.14.0", "@xterm/headless": "^6.0.0", + "fflate": "^0.8.3", "selfsigned": "^5.5.0", "ws": "^8.21.3" }, @@ -3879,7 +3880,6 @@ "version": "0.8.3", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", - "dev": true, "license": "MIT" }, "node_modules/fill-range": { diff --git a/package.json b/package.json index 66e7bd2e9..c0cf114b8 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@lydell/node-pty": "^1.2.0-beta.15", "@xterm/addon-serialize": "^0.14.0", "@xterm/headless": "^6.0.0", + "fflate": "^0.8.3", "selfsigned": "^5.5.0", "ws": "^8.21.3" }, diff --git a/src/client/editorial.html b/src/client/editorial.html new file mode 100644 index 000000000..8ed99bbb2 --- /dev/null +++ b/src/client/editorial.html @@ -0,0 +1,68 @@ + + + + + + Editorial JeyTid + + + +
+

📚 Editorial JeyTid

Estado operativo, dependencias, artefactos, aprobaciones y conectores.

+
Cargando…
+
+
+ + + diff --git a/src/server/cli.ts b/src/server/cli.ts index 63d35fd3e..44856c005 100644 --- a/src/server/cli.ts +++ b/src/server/cli.ts @@ -6,6 +6,10 @@ import { tildify } from './building.js'; import { openBrowser } from './browser.js'; const argv = process.argv.slice(2); +if (argv[0] === 'editorial') { + const { editorialCommand } = await import('./editorial/cli.js'); + process.exit(await editorialCommand(argv.slice(1))); +} if (argv[0] === 'prune') { const { prune } = await import('./prune.js'); process.exit(await prune(argv.slice(1))); diff --git a/src/server/editorial/agent-office-adapter.ts b/src/server/editorial/agent-office-adapter.ts new file mode 100644 index 000000000..d96a52a85 --- /dev/null +++ b/src/server/editorial/agent-office-adapter.ts @@ -0,0 +1,55 @@ +import type { Ctx } from '../office/context.js'; +import type { EditorialRepository } from './repository.js'; +import { id, now } from './ids.js'; +import type { TaskContract } from './types.js'; + +interface FloorLike { + id: string; + def: { name: string }; + queue: { add(prompt: string, by: string, title?: string, issue?: number, provider?: 'claude' | 'opencode' | 'codex' | 'grok' | 'muse' | 'dsh' | 'pi' | 'cursor' | 'custom', model?: string, effort?: 'low' | 'medium' | 'high' | 'xhigh' | 'max', owner?: string): string | undefined }; + workers: { list(): Array<{ id: string; name: string; status: string }> }; +} + +export interface AgentOfficeDispatch { + dispatched: boolean; + requestId: string; + floorId?: string; + error?: string; +} + +/** + * Keeps editorial runs associated with Agent Office without requiring each book to be a public git + * repository. The queue dispatch is deliberately opt-in: a configured floor is required and the + * existing Agent Office queue remains the authority for worker lifecycle. + */ +export class AgentOfficeAdapter { + private ctx?: Pick; + + constructor(private readonly repo: EditorialRepository, private readonly configuredFloor?: string, private readonly provider: FloorLike['queue'] extends { add: (...args: infer A) => unknown } ? A[4] : never = 'claude') {} + + bind(ctx: Pick): void { + this.ctx = ctx; + } + + dispatch(task: TaskContract, prompt: string, owner = 'editorial'): AgentOfficeDispatch { + const requestId = id('office_request'); + const floor = this.floor(); + if (!floor) return { dispatched: false, requestId, error: this.configuredFloor ? `Agent Office floor not found: ${this.configuredFloor}` : 'EDITORIAL_AGENT_OFFICE_FLOOR is not configured' }; + const error = floor.queue.add(prompt, owner, `Editorial ${task.roleId}: ${task.objective.slice(0, 90)}`, undefined, this.provider as any); + if (error) return { dispatched: false, requestId, floorId: floor.id, error }; + this.repo.audit(owner, 'agent-office.queue.dispatch', task.projectId, task.taskId, undefined, { requestId, floorId: floor.id, roleId: task.roleId, dispatchedAt: now() }); + return { dispatched: true, requestId, floorId: floor.id }; + } + + workers(): Array<{ floorId: string; floorName: string; workerId: string; name: string; status: string }> { + const floors = this.ctx ? [...this.ctx.floors.values()] as unknown as FloorLike[] : []; + return floors.flatMap((floor) => floor.workers.list().map((worker) => ({ floorId: floor.id, floorName: floor.def.name, workerId: worker.id, name: worker.name, status: worker.status }))); + } + + private floor(): FloorLike | undefined { + if (!this.ctx) return undefined; + const floors = [...this.ctx.floors.values()] as unknown as FloorLike[]; + if (!this.configuredFloor) return undefined; + return floors.find((floor) => floor.id === this.configuredFloor || floor.def.name === this.configuredFloor); + } +} diff --git a/src/server/editorial/audit.ts b/src/server/editorial/audit.ts new file mode 100644 index 000000000..c4776f0db --- /dev/null +++ b/src/server/editorial/audit.ts @@ -0,0 +1,54 @@ +import { createHash } from 'node:crypto'; +import type { EditorialDocuments, DocumentValidation } from './documents.js'; +import { validateEpub, validatePdf } from './documents.js'; +import { now } from './ids.js'; +import type { EditorialRepository } from './repository.js'; +import type { ArtifactRef, Finding, ProjectRecord, ReleaseCandidate } from './types.js'; +import type { EditorialStorage } from './storage.js'; + +export interface AuditResult { + candidate: ReleaseCandidate; + report: ArtifactRef; + findings: Finding[]; + approved: boolean; +} + +/** Independent, deterministic pre-release checks. It never publishes to KDP or a social network. */ +export class EditorialAudit { + constructor(private readonly repo: EditorialRepository, private readonly storage: EditorialStorage, private readonly documents: EditorialDocuments) {} + + auditCandidate(project: ProjectRecord, candidate: ReleaseCandidate): AuditResult { + const findings: Finding[] = []; + const artifacts = this.repo.listArtifacts(project.id); + const byId = new Map(artifacts.map((artifact) => [artifact.id, artifact])); + for (const artifactId of candidate.artifactIds) { + const artifact = byId.get(artifactId); + if (!artifact) { + findings.push({ severity: 'blocking', title: 'Artefacto ausente', detail: `El manifiesto referencia ${artifactId}, que no está en el proyecto.`, status: 'open' }); + continue; + } + try { + const bytes = this.storage.read(artifact.path); + const digest = createHash('sha256').update(bytes).digest('hex'); + if (digest !== artifact.sha256) findings.push({ severity: 'blocking', title: 'Hash de artefacto no coincide', detail: artifact.path, status: 'open' }); + const validation = artifact.type === 'epub' ? validateEpub(bytes) : artifact.type === 'pdf_interior' ? validatePdf(bytes) : { ok: true, problems: [] }; + for (const problem of validation.problems) findings.push({ severity: 'blocking', title: `Validación ${artifact.type}`, detail: problem, location: artifact.path, status: 'open' }); + } catch (error) { + findings.push({ severity: 'blocking', title: 'Artefacto ilegible', detail: `${artifact.path}: ${error instanceof Error ? error.message : 'error desconocido'}`, status: 'open' }); + } + if (!this.repo.listRights(project.id).some((rights) => rights.artifactId === artifact.id)) findings.push({ severity: 'blocking', title: 'Procedencia ausente', detail: `No existe registro de derechos para ${artifact.id}.`, status: 'open' }); + } + const blocking = findings.filter((finding) => finding.severity === 'blocking'); + for (const finding of findings) this.repo.addFinding(project.id, finding); + const approved = blocking.length === 0; + const reportText = ['# Auditoría independiente', '', `Candidato: ${candidate.id}`, `Fecha: ${now()}`, `Resultado: ${approved ? 'approved' : 'needs_changes'}`, '', ...findings.map((finding) => `- [${finding.severity}] ${finding.title}: ${finding.detail}`), '', 'La auditoría es local y no sustituye la revisión de derechos, políticas ni el formulario de la plataforma externa.'].join('\n'); + const report = this.documents.writeTextArtifact(project, 'audit_report', 'reports', reportText, { candidateId: candidate.id, independent: true, approved, generatedAt: now() }); + this.repo.updateReleaseState(candidate.id, approved ? 'approved' : 'needs_changes', approved ? 'approved' : 'needs_changes'); + this.repo.audit('independent-auditor', 'release.audit', project.id, undefined, candidate.manifestHash, { candidateId: candidate.id, approved, findingCount: findings.length, reportId: report.id }); + return { candidate: this.repo.getRelease(candidate.id) ?? candidate, report, findings, approved }; + } +} + +export function validationFinding(validation: DocumentValidation, artifact: ArtifactRef): Finding[] { + return validation.ok ? [] : validation.problems.map((detail) => ({ severity: 'blocking', title: `Validación ${artifact.type}`, detail, location: artifact.path, status: 'open' as const })); +} diff --git a/src/server/editorial/backup.ts b/src/server/editorial/backup.ts new file mode 100644 index 000000000..d3c5de0b5 --- /dev/null +++ b/src/server/editorial/backup.ts @@ -0,0 +1,58 @@ +import { cpSync, existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { EditorialDatabase } from './db.js'; + +export interface EditorialBackupManifest { + format: 1; + createdAt: string; + sourceEditorialDir: string; + files: string[]; +} + +/** + * Creates a self-contained copy of the private editorial database and file store. The database is + * checkpointed before copying so a restore does not depend on a live WAL sidecar. + */ +export function backupEditorial(dataDir: string, destination: string, checkpoint = true): EditorialBackupManifest { + const source = path.resolve(dataDir, 'editorial'); + const target = path.resolve(destination); + if (target === source || target.startsWith(`${source}${path.sep}`)) throw new Error('Backup destination cannot be inside the source editorial directory'); + if (!existsSync(source)) throw new Error(`Editorial directory does not exist: ${source}`); + mkdirSync(target, { recursive: true, mode: 0o700 }); + const databasePath = path.join(source, 'editorial.sqlite'); + if (checkpoint && existsSync(databasePath)) { + const database = new EditorialDatabase(databasePath); + try { database.checkpoint(); } finally { database.close(); } + } + cpSync(source, target, { recursive: true, force: true, preserveTimestamps: true }); + const files = listFiles(target).map((file) => path.relative(target, file)); + const manifest: EditorialBackupManifest = { format: 1, createdAt: new Date().toISOString(), sourceEditorialDir: source, files }; + // This is metadata, not user content; it makes a copied directory auditable without secrets. + const manifestPath = path.join(target, 'BACKUP-MANIFEST.json'); + requireSafeManifestPath(manifestPath, target); + writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), { mode: 0o600 }); + return manifest; +} + +/** Restores into a separate data directory by default; `force` is required to overwrite an existing database. */ +export function restoreEditorialBackup(backupDirectory: string, dataDir: string, force = false): void { + const source = path.resolve(backupDirectory); + const target = path.resolve(dataDir, 'editorial'); + const databasePath = path.join(source, 'editorial.sqlite'); + if (!existsSync(source) || !existsSync(databasePath) || !statSync(databasePath).isFile()) throw new Error('Backup must contain editorial.sqlite'); + if (existsSync(path.join(target, 'editorial.sqlite')) && !force) throw new Error(`Destination already contains an editorial database: ${target}; pass --force only when replacement is intended`); + mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 }); + cpSync(source, target, { recursive: true, force: true, preserveTimestamps: true }); +} + +function listFiles(directory: string): string[] { + return readdirSync(directory, { withFileTypes: true }).flatMap((entry: { name: string; isDirectory(): boolean }) => { + const full = path.join(directory, entry.name); + return entry.isDirectory() ? listFiles(full) : [full]; + }); +} + +function requireSafeManifestPath(filePath: string, root: string): void { + if (!filePath.startsWith(`${path.resolve(root)}${path.sep}`)) throw new Error('Backup manifest path escaped destination'); +} + diff --git a/src/server/editorial/capabilities.ts b/src/server/editorial/capabilities.ts new file mode 100644 index 000000000..455f8dabc --- /dev/null +++ b/src/server/editorial/capabilities.ts @@ -0,0 +1,19 @@ +import type { CapabilityRow } from './types.js'; + +export function initialCapabilities(checkedAt = new Date().toISOString()): CapabilityRow[] { + return [ + { capability: 'editorial-state', component: 'engine', state: 'implemented', evidence: ['SQLite schema and state transitions'], checkedAt }, + { capability: 'durable-queue', component: 'orchestrator', state: 'implemented', evidence: ['Lease/checkpoint implementation'], checkedAt }, + { capability: 'agent-office-association', component: 'agent-office-adapter', state: 'implemented', evidence: ['Worker/floor/run association table'], limitation: 'Dispatch requires a configured Agent Office floor', checkedAt }, + { capability: 'text-generation', component: 'provider.openai', state: 'requires_config', evidence: ['HTTP Responses adapter'], dependency: 'OPENAI_API_KEY and model', checkedAt }, + { capability: 'image-generation', component: 'provider.openai', state: 'requires_config', evidence: ['HTTP Images adapter'], dependency: 'OPENAI_API_KEY and image model', checkedAt }, + { capability: 'mock-generation', component: 'provider.mock', state: 'tested_local', evidence: ['Deterministic fixtures'], checkedAt }, + { capability: 'epub-export', component: 'documents', state: 'implemented', evidence: ['EPUB package writer'], checkedAt }, + { capability: 'pdf-export', component: 'documents', state: 'limited', evidence: ['Minimal text PDF writer'], limitation: 'Visual richness and print preflight remain limited until an external renderer is configured', checkedAt }, + { capability: 'kdp-preparation', component: 'kdp', state: 'implemented', evidence: ['Checklist, metadata and manifest generation'], checkedAt }, + { capability: 'kdp-automated-publish', component: 'kdp', state: 'unsupported', evidence: ['No public end-to-end API assumed'], limitation: 'Use assisted workflow; owner handles login/MFA/CAPTCHA', checkedAt }, + { capability: 'social-calendar', component: 'marketing', state: 'implemented', evidence: ['Durable schedule and export'], checkedAt }, + { capability: 'social-real-connector', component: 'marketing', state: 'requires_config', evidence: ['Connector interface'], dependency: 'Account and platform permissions', checkedAt }, + { capability: 'telegram', component: 'telegram', state: 'requires_config', evidence: ['Long-polling adapter and owner-ID checks'], dependency: 'TELEGRAM_BOT_TOKEN and TELEGRAM_OWNER_ID', checkedAt }, + ]; +} diff --git a/src/server/editorial/cli.ts b/src/server/editorial/cli.ts new file mode 100644 index 000000000..032551830 --- /dev/null +++ b/src/server/editorial/cli.ts @@ -0,0 +1,79 @@ +import path from 'node:path'; +import { backupEditorial, restoreEditorialBackup } from './backup.js'; +import { EditorialRuntime, seededBrief } from './runtime.js'; + +export async function editorialCommand(args: string[]): Promise { + const command = args[0] || 'doctor'; + const dataDir = value(args, '--data-dir') || process.env.EDITORIAL_DATA_DIR || path.join(process.cwd(), '.agent-office'); + if (command === '--help' || command === 'help') { + console.log(help()); + return 0; + } + if (command === 'doctor') return doctor(dataDir); + if (command === 'snapshot') return snapshot(dataDir); + if (command === 'backup') { + const destination = value(args, '--destination') || path.join(dataDir, 'editorial-backups', new Date().toISOString().replace(/[:.]/g, '-')); + console.log(JSON.stringify(backupEditorial(dataDir, destination), null, 2)); + return 0; + } + if (command === 'restore') { + const source = args[1]; + if (!source) throw new Error('editorial restore needs a backup directory'); + restoreEditorialBackup(source, dataDir, args.includes('--force')); + console.log(`Editorial backup restored into ${path.join(dataDir, 'editorial')}`); + return 0; + } + if (command === 'demo') return demo(dataDir); + console.error(`Unknown editorial command: ${command}`); + console.log(help()); + return 2; +} + +function doctor(dataDir: string): number { + const runtime = new EditorialRuntime(dataDir, { ...process.env, EDITORIAL_PROVIDER: process.env.EDITORIAL_PROVIDER || 'mock' }); + try { + const snapshot = runtime.snapshot(); + const supported = Number(process.versions.node.split('.')[0]) >= 22; + console.log(JSON.stringify({ ok: supported, node: process.versions.node, dataDir, database: runtime.db.filePath, provider: runtime.provider.id, providerConfigured: runtime.provider.configured, projects: snapshot.projects.length, capabilities: snapshot.capabilities }, null, 2)); + return supported ? 0 : 1; + } finally { + runtime.shutdown(); + } +} + +function snapshot(dataDir: string): number { + const runtime = new EditorialRuntime(dataDir, { ...process.env, EDITORIAL_PROVIDER: process.env.EDITORIAL_PROVIDER || 'mock' }); + try { + console.log(JSON.stringify(runtime.snapshot(), null, 2)); + return 0; + } finally { + runtime.shutdown(); + } +} + +function demo(dataDir: string): number { + const runtime = new EditorialRuntime(dataDir, { ...process.env, EDITORIAL_PROVIDER: 'mock' }); + try { + const project = runtime.createProject(seededBrief(`Demo local ${new Date().toISOString().slice(0, 10)}`, { authorName: 'Autor de prueba', genre: 'cuento' })); + const manuscript = runtime.documents.writeManuscript(project, '# Una obra breve\n\nEste texto pertenece a la demo local y no se publica.'); + const epub = runtime.documents.exportEpub(project, project.title, project.brief.authorName, '# Una obra breve\n\nEste texto pertenece a la demo local y no se publica.'); + const pdf = runtime.documents.exportPdf(project, project.title, '# Una obra breve\n\nEste texto pertenece a la demo local y no se publica.'); + const rendering = runtime.documents.writeRendering(project, project.title, '# Una obra breve\n\nEste texto pertenece a la demo local y no se publica.'); + const packageArtifact = runtime.documents.createPackage(project, [manuscript, epub, pdf, rendering], { mode: 'demo', published: false }); + const candidate = runtime.createReleaseCandidate(project.id, [manuscript, epub, pdf, rendering, packageArtifact], { mode: 'demo', externalPublication: false }, 'approved'); + console.log(JSON.stringify({ ok: true, published: false, projectId: project.id, releaseId: candidate.id, artifacts: runtime.repo.listArtifacts(project.id) }, null, 2)); + return 0; + } finally { + runtime.shutdown(); + } +} + +function value(args: string[], flag: string): string | undefined { + const index = args.indexOf(flag); + return index >= 0 ? args[index + 1] : undefined; +} + +function help(): string { + return ['agent-office editorial ', '', 'Commands:', ' doctor Check Node, SQLite, provider and capabilities', ' snapshot Print the persisted editorial state', ' demo Create local mock artifacts; never publishes', ' backup --destination Copy SQLite and private files', ' restore [--force] Restore into --data-dir/editorial', '', 'Options:', ' --data-dir Agent Office data directory (default .agent-office)', ''].join('\n'); +} + diff --git a/src/server/editorial/config.ts b/src/server/editorial/config.ts new file mode 100644 index 000000000..4684eaeca --- /dev/null +++ b/src/server/editorial/config.ts @@ -0,0 +1,98 @@ +import path from 'node:path'; +import type { AgentEffort } from '../../shared/providers.js'; +import type { RoleId } from './types.js'; + +export interface RoleProviderConfig { + provider: string; + model?: string; + effort?: AgentEffort; +} + +export interface EditorialConfig { + dataDir: string; + storageDir: string; + timezone: string; + ownerId?: string; + authorizedChatIds: string[]; + botToken?: string; + maxConcurrentGenerations: number; + budgetUsd?: number; + warningThresholdUsd: number; + provider: string; + textModel: string; + imageModel: string; + autonomousMode: boolean; + paidAdvertising: boolean; + agentOfficeFloor?: string; + agentOfficeProvider: 'claude' | 'opencode' | 'codex' | 'grok' | 'muse' | 'dsh' | 'pi' | 'cursor' | 'custom'; + roleProviders: Partial>; +} + +export function loadEditorialConfig(dataDir: string, env: NodeJS.ProcessEnv = process.env): EditorialConfig { + const ownerId = numericId(env.TELEGRAM_OWNER_ID); + const allowedChatIds = splitIds(env.TELEGRAM_ALLOWED_CHAT_IDS); + if (ownerId && !allowedChatIds.includes(ownerId)) allowedChatIds.unshift(ownerId); + const budgetUsd = positiveNumber(env.EDITORIAL_BUDGET_USD); + const maxConcurrentGenerations = Math.min(20, Math.max(1, integer(env.EDITORIAL_MAX_CONCURRENT, 3))); + return { + dataDir, + storageDir: path.join(dataDir, 'editorial'), + timezone: env.APP_TIMEZONE?.trim() || 'America/Santo_Domingo', + ownerId, + authorizedChatIds: allowedChatIds, + botToken: env.TELEGRAM_BOT_TOKEN?.trim() || undefined, + maxConcurrentGenerations, + budgetUsd, + warningThresholdUsd: Math.max(0, Math.min(1, positiveNumber(env.EDITORIAL_BUDGET_WARNING_RATIO) ?? 0.8)), + provider: env.EDITORIAL_PROVIDER?.trim().toLowerCase() || 'mock', + textModel: env.EDITORIAL_TEXT_MODEL?.trim() || 'configured-text-model', + imageModel: env.EDITORIAL_IMAGE_MODEL?.trim() || 'gpt-image-1', + autonomousMode: booleanValue(env.EDITORIAL_AUTONOMOUS_MODE), + paidAdvertising: env.EDITORIAL_PAID_ADVERTISING === '1' || env.EDITORIAL_PAID_ADVERTISING === 'true', + agentOfficeFloor: env.EDITORIAL_AGENT_OFFICE_FLOOR?.trim() || undefined, + agentOfficeProvider: validOfficeProvider(env.EDITORIAL_AGENT_OFFICE_PROVIDER), + roleProviders: parseRoleProviders(env.EDITORIAL_ROLE_CONFIG_JSON), + }; +} + +function numericId(value: string | undefined): string | undefined { + return value && /^\d{1,20}$/.test(value.trim()) ? value.trim() : undefined; +} + +function splitIds(value: string | undefined): string[] { + return [...new Set((value ?? '').split(',').map((item) => numericId(item.trim())).filter((item): item is string => !!item))]; +} + +function integer(value: string | undefined, fallback: number): number { + const parsed = Number(value); + return Number.isInteger(parsed) ? parsed : fallback; +} + +function positiveNumber(value: string | undefined): number | undefined { + if (!value) return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined; +} + +function booleanValue(value: string | undefined): boolean { + return value?.trim().toLowerCase() === '1' || value?.trim().toLowerCase() === 'true'; +} + +function validOfficeProvider(value: string | undefined): EditorialConfig['agentOfficeProvider'] { + return ['claude', 'opencode', 'codex', 'grok', 'muse', 'dsh', 'pi', 'cursor', 'custom'].includes(value ?? '') ? value as EditorialConfig['agentOfficeProvider'] : 'claude'; +} + +function parseRoleProviders(value: string | undefined): EditorialConfig['roleProviders'] { + if (!value) return {}; + try { + const parsed = JSON.parse(value) as Record; + const result: EditorialConfig['roleProviders'] = {}; + for (const [role, raw] of Object.entries(parsed)) { + if (!raw || typeof raw !== 'object' || typeof (raw as Record).provider !== 'string') continue; + result[role as RoleId] = { provider: String((raw as Record).provider), model: typeof (raw as Record).model === 'string' ? String((raw as Record).model) : undefined, effort: typeof (raw as Record).effort === 'string' ? (raw as Record).effort as AgentEffort : undefined }; + } + return result; + } catch { + return {}; + } +} diff --git a/src/server/editorial/db.ts b/src/server/editorial/db.ts new file mode 100644 index 000000000..5899485f4 --- /dev/null +++ b/src/server/editorial/db.ts @@ -0,0 +1,274 @@ +import { mkdirSync } from 'node:fs'; +import path from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; + +type DbValue = string | number | bigint | null | Uint8Array; + +const MIGRATIONS: readonly string[] = [ + `CREATE TABLE IF NOT EXISTS app_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);` + + `CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + stage TEXT NOT NULL, + paused INTEGER NOT NULL DEFAULT 0 CHECK (paused IN (0, 1)), + brief_json TEXT NOT NULL, + active_revision_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + );` + + `CREATE TABLE IF NOT EXISTS revisions ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + kind TEXT NOT NULL, + parent_id TEXT REFERENCES revisions(id), + content_hash TEXT NOT NULL, + manifest_json TEXT NOT NULL, + created_at TEXT NOT NULL + );` + + `CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + edition_id TEXT, + role_id TEXT NOT NULL, + state TEXT NOT NULL, + contract_json TEXT NOT NULL, + result_json TEXT, + attempts INTEGER NOT NULL DEFAULT 0, + max_attempts INTEGER NOT NULL, + generation INTEGER NOT NULL DEFAULT 0, + lease_owner TEXT, + lease_until TEXT, + heartbeat_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + started_at TEXT, + completed_at TEXT + );` + + `CREATE TABLE IF NOT EXISTS task_dependencies ( + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + depends_on TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + PRIMARY KEY (task_id, depends_on), + CHECK (task_id <> depends_on) + );` + + `CREATE TABLE IF NOT EXISTS agent_runs ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + role_id TEXT NOT NULL, + provider TEXT NOT NULL, + model TEXT, + profile_version TEXT NOT NULL, + office_worker_id TEXT, + office_floor_id TEXT, + status TEXT NOT NULL, + action_summary TEXT, + started_at TEXT NOT NULL, + finished_at TEXT + );` + + `CREATE TABLE IF NOT EXISTS artifacts ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + edition_id TEXT, + task_id TEXT REFERENCES tasks(id), + revision_id TEXT REFERENCES revisions(id), + type TEXT NOT NULL, + path TEXT NOT NULL UNIQUE, + sha256 TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + metadata_json TEXT NOT NULL, + created_at TEXT NOT NULL + );` + + `CREATE TABLE IF NOT EXISTS sources ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + url TEXT, + identifier TEXT, + title TEXT, + observed_at TEXT NOT NULL, + notes TEXT, + claims_json TEXT NOT NULL + );` + + `CREATE TABLE IF NOT EXISTS rights_records ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + artifact_id TEXT REFERENCES artifacts(id), + origin TEXT NOT NULL, + creator TEXT, + license TEXT, + evidence TEXT, + ai_status TEXT NOT NULL, + conditions TEXT, + status TEXT NOT NULL, + updated_at TEXT NOT NULL + );` + + `CREATE TABLE IF NOT EXISTS findings ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + task_id TEXT REFERENCES tasks(id), + severity TEXT NOT NULL, + title TEXT NOT NULL, + detail TEXT NOT NULL, + location TEXT, + resolution TEXT, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + resolved_at TEXT + );` + + `CREATE TABLE IF NOT EXISTS approvals ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + action TEXT NOT NULL, + subject_id TEXT NOT NULL, + subject_hash TEXT NOT NULL, + owner_id TEXT NOT NULL, + state TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, + used_at TEXT + );` + + `CREATE TABLE IF NOT EXISTS releases ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + manifest_hash TEXT NOT NULL UNIQUE, + artifact_ids_json TEXT NOT NULL, + metadata_json TEXT NOT NULL, + audit_status TEXT NOT NULL, + state TEXT NOT NULL, + created_at TEXT NOT NULL + );` + + `CREATE TABLE IF NOT EXISTS cost_events ( + id TEXT PRIMARY KEY, + project_id TEXT REFERENCES projects(id) ON DELETE SET NULL, + task_id TEXT REFERENCES tasks(id) ON DELETE SET NULL, + provider TEXT NOT NULL, + estimated_usd REAL NOT NULL DEFAULT 0, + observed_usd REAL NOT NULL DEFAULT 0, + reserved_usd REAL NOT NULL DEFAULT 0, + source TEXT NOT NULL, + created_at TEXT NOT NULL + );` + + `CREATE TABLE IF NOT EXISTS checkpoints ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + generation INTEGER NOT NULL, + state_json TEXT NOT NULL, + created_at TEXT NOT NULL + );` + + `CREATE TABLE IF NOT EXISTS telegram_updates ( + update_id INTEGER PRIMARY KEY, + received_at TEXT NOT NULL + );` + + `CREATE TABLE IF NOT EXISTS social_accounts ( + id TEXT PRIMARY KEY, + platform TEXT NOT NULL, + account_label TEXT NOT NULL, + token_ref TEXT, + capabilities_json TEXT NOT NULL, + status TEXT NOT NULL, + metadata_json TEXT NOT NULL, + updated_at TEXT NOT NULL + );` + + `CREATE TABLE IF NOT EXISTS social_posts ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + account_id TEXT REFERENCES social_accounts(id), + content_hash TEXT NOT NULL, + content_json TEXT NOT NULL, + scheduled_at TEXT, + timezone TEXT NOT NULL, + state TEXT NOT NULL, + remote_id TEXT, + remote_url TEXT, + last_error TEXT, + updated_at TEXT NOT NULL, + UNIQUE(account_id, content_hash, scheduled_at) + );` + + `CREATE TABLE IF NOT EXISTS capability_matrix ( + capability TEXT PRIMARY KEY, + component TEXT NOT NULL, + state TEXT NOT NULL, + evidence_json TEXT NOT NULL, + dependency TEXT, + limitation TEXT, + checked_at TEXT NOT NULL + );` + + `CREATE TABLE IF NOT EXISTS audit_events ( + id TEXT PRIMARY KEY, + actor TEXT NOT NULL, + action TEXT NOT NULL, + project_id TEXT, + task_id TEXT, + subject_hash TEXT, + detail_json TEXT NOT NULL, + created_at TEXT NOT NULL + );` + + `CREATE INDEX IF NOT EXISTS idx_tasks_state ON tasks(state, updated_at);` + + `CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id, updated_at);` + + `CREATE INDEX IF NOT EXISTS idx_artifacts_project ON artifacts(project_id, created_at);` + + `CREATE INDEX IF NOT EXISTS idx_findings_project ON findings(project_id, status, severity);`, +]; + +export class EditorialDatabase { + readonly db: DatabaseSync; + + constructor(readonly filePath: string) { + mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 }); + this.db = new DatabaseSync(filePath); + this.db.exec('PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA busy_timeout = 5000;'); + this.migrate(); + } + + close(): void { + this.db.close(); + } + + checkpoint(): void { + this.db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); + } + + run(sql: string, ...params: DbValue[]) { + return this.db.prepare(sql).run(...params); + } + + get>(sql: string, ...params: DbValue[]): T | undefined { + return this.db.prepare(sql).get(...params) as T | undefined; + } + + all>(sql: string, ...params: DbValue[]): T[] { + return this.db.prepare(sql).all(...params) as T[]; + } + + exec(sql: string): void { + this.db.exec(sql); + } + + transaction(fn: () => T): T { + this.db.exec('BEGIN IMMEDIATE'); + try { + const result = fn(); + this.db.exec('COMMIT'); + return result; + } catch (error) { + try { + this.db.exec('ROLLBACK'); + } catch { + // Keep the original error; SQLite may already have rolled back after a constraint failure. + } + throw error; + } + } + + private migrate(): void { + this.db.exec('CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)'); + const current = Number(this.get<{ version: number }>('SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations')?.version ?? 0); + for (let i = current; i < MIGRATIONS.length; i += 1) { + const version = i + 1; + this.transaction(() => { + this.db.exec(MIGRATIONS[i]); + this.run('INSERT INTO schema_migrations(version, applied_at) VALUES (?, ?)', version, new Date().toISOString()); + }); + } + } +} + +export type { DbValue }; diff --git a/src/server/editorial/documents.ts b/src/server/editorial/documents.ts new file mode 100644 index 000000000..4dae364fe --- /dev/null +++ b/src/server/editorial/documents.ts @@ -0,0 +1,167 @@ +import { strToU8, unzipSync, zipSync } from 'fflate'; +import type { EditorialRepository } from './repository.js'; +import type { EditorialStorage } from './storage.js'; +import { id, json, now } from './ids.js'; +import type { ArtifactRef, ProjectRecord } from './types.js'; + +export class EditorialDocuments { + constructor(private readonly storage: EditorialStorage, private readonly repo: EditorialRepository) {} + + writeManuscript(project: ProjectRecord, markdown: string, taskId?: string): ArtifactRef { + const stored = this.storage.write(project.id, 'originals', 'md', markdown); + const artifactId = id('artifact'); + const revision = this.repo.createRevision(project.id, 'manuscript', stored.sha256, { format: 'markdown', artifactId }, project.activeRevisionId); + const artifact = { ...toArtifact(project.id, 'manuscript_source', stored, { format: 'markdown', generatedAt: now(), contentKind: 'editable-source' }, taskId), id: artifactId, revisionId: revision.id }; + this.repo.recordArtifact(artifact); + return artifact; + } + + exportEpub(project: ProjectRecord, title: string, author: string | undefined, markdown: string, taskId?: string): ArtifactRef { + const bytes = epub(title, author, markdown); + const stored = this.storage.write(project.id, 'derived', 'epub', bytes); + const artifact = toArtifact(project.id, 'epub', stored, { title, author, format: 'epub', generatedAt: now(), source: 'manuscript' }, taskId); + this.repo.recordArtifact(artifact); + return artifact; + } + + exportPdf(project: ProjectRecord, title: string, markdown: string, taskId?: string): ArtifactRef { + const bytes = pdf(title, markdown); + const stored = this.storage.write(project.id, 'derived', 'pdf', bytes); + const artifact = toArtifact(project.id, 'pdf_interior', stored, { title, format: 'pdf', generatedAt: now(), validationScope: 'basic text PDF structure' }, taskId); + this.repo.recordArtifact(artifact); + return artifact; + } + + writeRendering(project: ProjectRecord, title: string, markdown: string, taskId?: string): ArtifactRef { + const html = `${escapeHtml(title)}

${escapeHtml(title)}

${escapeHtml(markdown)}
`; + const stored = this.storage.write(project.id, 'renderings', 'html', html); + const artifact = toArtifact(project.id, 'rendering', stored, { title, format: 'html-preview', generatedAt: now(), reviewed: false }, taskId); + this.repo.recordArtifact(artifact); + return artifact; + } + + writeTextArtifact(project: ProjectRecord, type: ArtifactRef['type'], area: 'originals' | 'sources' | 'derived' | 'reports' | 'social', content: string, metadata: Record, taskId?: string): ArtifactRef { + const stored = this.storage.write(project.id, area, 'md', content); + const artifact = toArtifact(project.id, type, stored, { ...metadata, generatedAt: now(), contentKind: 'editable-text' }, taskId); + this.repo.recordArtifact(artifact); + return artifact; + } + + createPackage(project: ProjectRecord, artifacts: ArtifactRef[], metadata: Record, taskId?: string): ArtifactRef { + const manifest = { projectId: project.id, projectTitle: project.title, generatedAt: now(), metadata, artifacts: artifacts.map((artifact) => ({ id: artifact.id, type: artifact.type, sha256: artifact.sha256, path: artifact.path, sizeBytes: artifact.sizeBytes })) }; + const files: Record = { 'MANIFEST.json': strToU8(JSON.stringify(manifest, null, 2)) }; + for (const artifact of artifacts) files[`artifacts/${artifact.id}-${artifact.type}`] = this.storage.read(artifact.path); + const stored = this.storage.write(project.id, 'packages', 'zip', zipSync(files, { level: 0 })); + const record = toArtifact(project.id, 'release_package', stored, { manifest, artifactIds: artifacts.map((artifact) => artifact.id), generatedAt: now() }, taskId); + this.repo.recordArtifact(record); + return record; + } +} + +function toArtifact(projectId: string, type: ArtifactRef['type'], stored: { relativePath: string; sha256: string; sizeBytes: number }, metadata: Record, taskId?: string): ArtifactRef { + return { id: id('artifact'), type, projectId, taskId, path: stored.relativePath, sha256: stored.sha256, sizeBytes: stored.sizeBytes, metadata }; +} + +function epub(title: string, author: string | undefined, markdown: string): Uint8Array { + const safeTitle = escapeHtml(title); + const safeAuthor = escapeHtml(author || 'Autor configurable'); + const body = markdownToXhtml(markdown); + const files: Record = { + mimetype: strToU8('application/epub+zip'), + 'META-INF/container.xml': strToU8(''), + 'OEBPS/content.opf': strToU8(`urn:uuid:${id('book')}${safeTitle}es-419${safeAuthor}`), + 'OEBPS/nav.xhtml': strToU8(`${safeTitle}`), + 'OEBPS/chapter.xhtml': strToU8(`${safeTitle}

${safeTitle}

${body}`), + }; + return zipSync(files, { level: 0 }); +} + +function markdownToXhtml(markdown: string): string { + return markdown.split(/\r?\n/).map((line) => { + if (line.startsWith('# ')) return `

${escapeHtml(line.slice(2))}

`; + if (line.startsWith('## ')) return `

${escapeHtml(line.slice(3))}

`; + if (!line.trim()) return ''; + return `

${escapeHtml(line)}

`; + }).join('\n'); +} + +function pdf(title: string, markdown: string): Uint8Array { + const lines = [`${title}`, '', ...markdown.replace(/\r/g, '').split('\n')].flatMap((line) => wrap(line, 92)); + const pages = [] as string[][]; + for (let i = 0; i < lines.length; i += 44) pages.push(lines.slice(i, i + 44)); + const objects: string[] = []; + const pageIds: number[] = []; + objects.push('<< /Type /Catalog /Pages 2 0 R >>'); + objects.push(''); + objects.push('<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>'); + for (const pageLines of pages.length ? pages : [['']]) { + const content = ['BT', '/F1 10 Tf', '50 760 Td', ...pageLines.map((line, index) => `${index ? '0 -16 Td ' : ''}(${escapePdf(line)}) Tj`), 'ET'].join('\n'); + const contentId = objects.length + 1; + objects.push(`<< /Length ${Buffer.byteLength(content, 'utf8')} >>\nstream\n${content}\nendstream`); + const pageId = objects.length + 1; + objects.push(`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 3 0 R >> >> /Contents ${contentId} 0 R >>`); + pageIds.push(pageId); + } + objects[1] = `<< /Type /Pages /Kids [${pageIds.map((pageId) => `${pageId} 0 R`).join(' ')}] /Count ${pageIds.length} >>`; + const chunks = ['%PDF-1.4\n']; + const offsets = [0]; + for (let index = 0; index < objects.length; index += 1) { + offsets.push(Buffer.byteLength(chunks.join(''), 'utf8')); + chunks.push(`${index + 1} 0 obj\n${objects[index]}\nendobj\n`); + } + const xref = Buffer.byteLength(chunks.join(''), 'utf8'); + chunks.push(`xref\n0 ${objects.length + 1}\n0000000000 65535 f \n${offsets.slice(1).map((offset) => `${String(offset).padStart(10, '0')} 00000 n \n`).join('')}trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF`); + return new TextEncoder().encode(chunks.join('')); +} + +function wrap(value: string, width: number): string[] { + if (!value) return ['']; + const words = value.split(/\s+/); + const lines: string[] = []; + let line = ''; + for (const word of words) { + if (line && line.length + word.length + 1 > width) { lines.push(line); line = word; } else line = line ? `${line} ${word}` : word; + } + if (line) lines.push(line); + return lines; +} + +function escapePdf(value: string): string { + return value.replaceAll('\\', '\\\\').replaceAll('(', '\\(').replaceAll(')', '\\)').replace(/[\u0000-\u001f\u007f-\u00ff]/g, '?'); +} + +function escapeHtml(value: string): string { + return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"'); +} + +export function manifestJson(project: ProjectRecord, artifacts: ArtifactRef[], metadata: Record): string { + return json({ projectId: project.id, title: project.title, artifacts, metadata }); +} + +export interface DocumentValidation { + ok: boolean; + problems: string[]; +} + +export function validateEpub(bytes: Uint8Array): DocumentValidation { + const problems: string[] = []; + try { + const files = unzipSync(bytes); + const names = Object.keys(files); + if (!names.includes('mimetype')) problems.push('Missing mimetype'); + else if (new TextDecoder().decode(files.mimetype) !== 'application/epub+zip') problems.push('Invalid mimetype content'); + for (const required of ['META-INF/container.xml', 'OEBPS/content.opf', 'OEBPS/nav.xhtml']) if (!names.includes(required)) problems.push(`Missing ${required}`); + } catch { + problems.push('Not a readable ZIP/EPUB container'); + } + return { ok: problems.length === 0, problems }; +} + +export function validatePdf(bytes: Uint8Array): DocumentValidation { + const text = new TextDecoder('latin1').decode(bytes); + const problems: string[] = []; + if (!text.startsWith('%PDF-')) problems.push('Missing PDF header'); + if (!text.includes('%%EOF')) problems.push('Missing PDF EOF marker'); + if (!text.includes('/Type /Catalog')) problems.push('Missing PDF catalog'); + return { ok: problems.length === 0, problems }; +} diff --git a/src/server/editorial/ids.ts b/src/server/editorial/ids.ts new file mode 100644 index 000000000..af1e9f848 --- /dev/null +++ b/src/server/editorial/ids.ts @@ -0,0 +1,19 @@ +import { createHash, randomUUID } from 'node:crypto'; + +export const id = (prefix: string): string => `${prefix}_${randomUUID().replaceAll('-', '')}`; +export const now = (): string => new Date().toISOString(); +export const hashText = (value: string): string => createHash('sha256').update(value, 'utf8').digest('hex'); +export const hashBytes = (value: Uint8Array): string => createHash('sha256').update(value).digest('hex'); + +export function json(value: unknown): string { + return JSON.stringify(value, (_key, nested) => (typeof nested === 'bigint' ? nested.toString() : nested)); +} + +export function parseJson(value: unknown, fallback: T): T { + if (typeof value !== 'string') return fallback; + try { + return JSON.parse(value) as T; + } catch { + return fallback; + } +} diff --git a/src/server/editorial/kdp.ts b/src/server/editorial/kdp.ts new file mode 100644 index 000000000..613385acb --- /dev/null +++ b/src/server/editorial/kdp.ts @@ -0,0 +1,50 @@ +import type { EditorialDocuments } from './documents.js'; +import type { EditorialRepository } from './repository.js'; +import type { ArtifactRef, ProjectRecord, ReleaseCandidate } from './types.js'; +import { now } from './ids.js'; + +export interface KdpPreparationResult { + metadata: ArtifactRef; + checklist: ArtifactRef; + candidate?: ReleaseCandidate; + blockers: string[]; +} + +/** Preparation/assisted route only. No KDP login, MFA, CAPTCHA or external submission is automated. */ +export class KdpPreparation { + constructor(private readonly repo: EditorialRepository, private readonly documents: EditorialDocuments) {} + + prepare(project: ProjectRecord, candidate?: ReleaseCandidate): KdpPreparationResult { + const artifacts = this.repo.listArtifacts(project.id); + const epub = artifacts.find((artifact) => artifact.type === 'epub'); + const pdf = artifacts.find((artifact) => artifact.type === 'pdf_interior'); + const blockers: string[] = []; + if (!epub) blockers.push('No existe un EPUB final asociado al proyecto'); + if (!pdf) blockers.push('No existe un PDF interior asociado al proyecto'); + const metadata = this.documents.writeTextArtifact(project, 'metadata', 'reports', JSON.stringify({ title: project.brief.provisionalTitle, author: project.brief.authorName, language: project.brief.language, markets: project.brief.locale, formats: project.brief.desiredFormats, generatedAt: now(), source: 'editorial-jeytid', kdpAutomation: 'unsupported' }, null, 2), { format: 'kdp-metadata-json', externalSubmission: false }); + const checklistText = [ + '# Preparación KDP (asistida)', + '', + `Título: ${project.brief.provisionalTitle}`, + `Autor: ${project.brief.authorName || 'pendiente'}`, + `EPUB: ${epub ? 'presente' : 'pendiente'}`, + `PDF interior: ${pdf ? 'presente' : 'pendiente'}`, + `Candidato: ${candidate?.id || 'pendiente'}`, + '', + '## Confirmaciones del propietario', + '- Confirmar identidad, derechos, territorios y precio en KDP.', + '- Revisar el renderizado y el número de páginas antes de preparar la cubierta.', + '- Completar login, MFA, CAPTCHA y envío manual en la cuenta KDP.', + '- No activar exclusividad ni publicidad pagada desde este módulo.', + '', + 'Fuentes oficiales:', + '- https://kdp.amazon.com/en_US/help', + '- https://kdp.amazon.com/en_US/help/topic/G202187850', + '- https://kdp.amazon.com/es_ES/cover-templates', + ].join('\n'); + const checklist = this.documents.writeTextArtifact(project, 'kdp_checklist', 'reports', checklistText, { format: 'kdp-checklist', externalSubmission: false, blockers }); + this.repo.audit('kdp-publisher', 'kdp.preparation', project.id, undefined, undefined, { candidateId: candidate?.id, blockers }); + if (candidate && blockers.length === 0) this.repo.updateReleaseState(candidate.id, 'prepared'); + return { metadata, checklist, candidate: candidate ? this.repo.getRelease(candidate.id) : undefined, blockers }; + } +} diff --git a/src/server/editorial/orchestrator.ts b/src/server/editorial/orchestrator.ts new file mode 100644 index 000000000..f4e388f87 --- /dev/null +++ b/src/server/editorial/orchestrator.ts @@ -0,0 +1,130 @@ +import { id, now } from './ids.js'; +import type { EditorialRepository } from './repository.js'; +import type { TaskContract, TaskResult, UsageSnapshot } from './types.js'; + +export interface ExecutionContext { + task: TaskContract; + runId: string; + generation: number; + signal: AbortSignal; + checkpoint(state: unknown): void; + heartbeat(): boolean; +} + +export type TaskExecutor = (context: ExecutionContext) => Promise; + +export interface OrchestratorOptions { + maxConcurrent: number; + budgetUsd?: number; + leaseMs: number; + pollMs: number; + owner: string; + estimateCost(task: TaskContract): number; + executor: TaskExecutor; + onEvent?: (event: { type: string; taskId?: string; runId?: string; detail?: string }) => void; +} + +export class EditorialOrchestrator { + private readonly active = new Map(); + private timer?: NodeJS.Timeout; + private stopped = false; + private pumping = false; + + constructor(readonly repo: EditorialRepository, readonly options: OrchestratorOptions) {} + + start(automatic = true): void { + this.stopped = false; + this.repo.recoverExpiredLeases(); + if (this.timer) clearInterval(this.timer); + this.timer = undefined; + if (!automatic) return; + this.timer = setInterval(() => this.pump(), this.options.pollMs); + void this.pump(); + } + + stop(): void { + this.stopped = true; + if (this.timer) clearInterval(this.timer); + this.timer = undefined; + for (const run of this.active.values()) run.controller.abort(); + this.active.clear(); + } + + async pump(projectId?: string): Promise { + if (this.stopped || this.pumping) return; + this.pumping = true; + try { + this.repo.recoverExpiredLeases(); + while (!this.stopped && this.active.size < Math.max(1, Math.floor(this.options.maxConcurrent))) { + const taskId = this.repo.readyTasks(1, projectId)[0]; + if (!taskId) break; + const task = this.repo.getTask(taskId); + if (!task) break; + const estimate = Math.max(0, this.options.estimateCost(task.contract)); + const reservationId = this.repo.reserveBudget(task.contract.projectId, task.contract.taskId, 'pending', estimate, this.options.budgetUsd); + if (!reservationId) { + this.options.onEvent?.({ type: 'budget_blocked', taskId, detail: 'No hay presupuesto disponible para reservar esta tarea.' }); + break; + } + const leaseOwner = `${this.options.owner}:${id('lease')}`; + const leaseUntil = new Date(Date.now() + this.options.leaseMs).toISOString(); + const claimed = this.repo.claimTask(taskId, leaseOwner, leaseUntil); + if (!claimed) { + this.repo.settleBudget(reservationId, { source: 'unavailable' }); + continue; + } + const runId = id('run'); + const controller = new AbortController(); + this.active.set(taskId, { owner: leaseOwner, controller, generation: claimed.generation }); + this.repo.recordRun({ runId, taskId, roleId: claimed.contract.roleId, provider: 'pending', profileVersion: 'pending', status: 'started', startedAt: now() }); + this.options.onEvent?.({ type: 'task_started', taskId, runId }); + void this.execute(claimed.contract, taskId, claimed.generation, leaseOwner, runId, reservationId, controller, projectId); + } + } finally { + this.pumping = false; + } + } + + cancel(taskId: string): boolean { + const current = this.active.get(taskId); + current?.controller.abort(); + const changed = this.repo.cancelTask(taskId, 'Cancelada por el propietario u orquestador'); + if (changed) this.options.onEvent?.({ type: 'task_cancelled', taskId }); + return changed; + } + + private async execute(task: TaskContract, taskId: string, generation: number, leaseOwner: string, runId: string, reservationId: string, controller: AbortController, pumpProjectId?: string): Promise { + const heartbeatTimer = setInterval(() => { + const ok = this.repo.heartbeat(taskId, leaseOwner, new Date(Date.now() + this.options.leaseMs).toISOString()); + if (!ok) controller.abort(); + }, Math.max(1000, Math.floor(this.options.leaseMs / 3))); + const timeout = setTimeout(() => controller.abort(), Math.max(1000, task.timeoutSeconds * 1000)); + const context: ExecutionContext = { + task, runId, generation, signal: controller.signal, + checkpoint: (state) => this.repo.saveCheckpoint(taskId, generation, state), + heartbeat: () => this.repo.heartbeat(taskId, leaseOwner, new Date(Date.now() + this.options.leaseMs).toISOString()), + }; + try { + let result: TaskResult; + try { + result = await this.options.executor(context); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown task failure'; + result = { taskId, runId, state: controller.signal.aborted ? 'cancelled' : 'failed', actionSummary: message, provider: 'unknown', profileVersion: 'unknown', artifacts: [], findings: [], blockers: [message], uncertainties: [], evidence: [], usage: { source: 'unavailable' } }; + } + const accepted = this.repo.completeTask(taskId, leaseOwner, generation, result); + this.repo.settleBudget(reservationId, result.usage); + this.repo.finishRun(runId, accepted ? result.state : 'cancelled', accepted ? result.actionSummary : 'Resultado tardío descartado porque la generación ya no es vigente.'); + this.options.onEvent?.({ type: accepted ? 'task_finished' : 'late_result_discarded', taskId, runId, detail: result.actionSummary }); + } finally { + clearInterval(heartbeatTimer); + clearTimeout(timeout); + this.active.delete(taskId); + void this.pump(pumpProjectId); + } + } +} + +export function unavailableUsage(): UsageSnapshot { + return { source: 'unavailable' }; +} diff --git a/src/server/editorial/policy.ts b/src/server/editorial/policy.ts new file mode 100644 index 000000000..2422c5aff --- /dev/null +++ b/src/server/editorial/policy.ts @@ -0,0 +1,95 @@ +import { randomBytes } from 'node:crypto'; +import type { EditorialRepository } from './repository.js'; +import { hashText, id, json, now } from './ids.js'; +import { canUseApproval, roleCanApprove, roleCanAssignTask, roleCanCreateTask, roleCanPublish } from './state.js'; +import type { ApprovalAction, ApprovalRecord, ArtifactRef, ProjectRecord, ReleaseCandidate, RoleId } from './types.js'; + +export type Actor = 'owner' | 'orchestrator' | RoleId; + +export function assertTaskAuthority(actor: Actor, operation: 'create' | 'assign'): void { + const allowed = operation === 'create' ? roleCanCreateTask(actor) : roleCanAssignTask(actor); + if (!allowed) throw new Error(`${actor} cannot ${operation} editorial tasks`); +} + +export function assertApprovalAuthority(actor: Actor, action: ApprovalAction): void { + if (!roleCanApprove(actor, action)) throw new Error(`${actor} cannot approve ${action}`); +} + +export function assertPublishAuthority(actor: Actor): void { + if (!roleCanPublish(actor)) throw new Error(`${actor} cannot publish`); +} + +export interface ApprovalRequest { + projectId: string; + action: ApprovalAction; + subjectId: string; + subject: unknown; + ownerId: string; + ttlMs?: number; +} + +export function requestApproval(repo: EditorialRepository, actor: Actor, request: ApprovalRequest): { record: ApprovalRecord; token: string } { + assertApprovalAuthority(actor, request.action); + const token = randomBytes(24).toString('base64url'); + const createdAt = now(); + const record: ApprovalRecord = { + id: id('approval'), projectId: request.projectId, action: request.action, subjectId: request.subjectId, + subjectHash: hashText(json(request.subject)), ownerId: request.ownerId, state: 'pending', tokenHash: hashText(token), + expiresAt: new Date(Date.now() + (request.ttlMs ?? 24 * 60 * 60 * 1000)).toISOString(), createdAt, + }; + repo.createApproval(record); + repo.audit(actor, 'approval.request', request.projectId, undefined, record.subjectHash, { action: record.action, subjectId: record.subjectId }); + return { record, token }; +} + +export function consumeApproval(repo: EditorialRepository, actor: Actor, token: string, expected: Pick): ApprovalRecord { + if (actor !== 'owner') throw new Error('Only the owner can consume an approval'); + const subjectHash = hashText(json(expected.subject)); + const record = repo.consumeApproval(hashText(token), expected.ownerId, expected.action, expected.subjectId, subjectHash); + if (!record || record.projectId !== expected.projectId || !canUseApproval('pending', record.expiresAt)) throw new Error('Approval is invalid, expired, already used, or for another version'); + repo.audit(actor, 'approval.consume', expected.projectId, expected.subjectId, subjectHash, { action: expected.action }); + return record; +} + +export function approveApproval(repo: EditorialRepository, actor: Actor, approvalId: string, ownerId: string): ApprovalRecord { + if (actor !== 'owner') throw new Error('Only the owner can approve a decision'); + const record = repo.approveApproval(approvalId, ownerId); + if (!record) throw new Error('Approval is invalid, expired, or already decided'); + repo.audit(actor, 'approval.approve', record.projectId, record.subjectId, record.subjectHash, { action: record.action }); + return record; +} + +export function rejectApproval(repo: EditorialRepository, actor: Actor, approvalId: string, ownerId: string): ApprovalRecord { + if (actor !== 'owner') throw new Error('Only the owner can reject a decision'); + const record = repo.rejectApproval(approvalId, ownerId); + if (!record) throw new Error('Approval is invalid or already decided'); + repo.audit(actor, 'approval.reject', record.projectId, record.subjectId, record.subjectHash, { action: record.action }); + return record; +} + +export function consumeApprovedApproval(repo: EditorialRepository, actor: Actor, approval: ApprovalRecord, expectedSubject: unknown, ownerId: string): ApprovalRecord { + if (actor !== 'owner') throw new Error('Only the owner can consume an approval'); + const subjectHash = hashText(json(expectedSubject)); + if (approval.ownerId !== ownerId || approval.subjectHash !== subjectHash || !canUseApproval(approval.state, approval.expiresAt)) throw new Error('Approval is invalid, expired, or for another version'); + const consumed = repo.consumeApprovalById(approval.id, ownerId, subjectHash); + if (!consumed) throw new Error('Approval was already consumed or changed'); + repo.audit(actor, 'approval.consume', approval.projectId, approval.subjectId, subjectHash, { action: approval.action }); + return consumed; +} + +export function createReleaseCandidate(repo: EditorialRepository, project: ProjectRecord, artifacts: ArtifactRef[], metadata: Record, auditStatus: ReleaseCandidate['auditStatus'] = 'not_run'): ReleaseCandidate { + const owned = artifacts.filter((artifact) => artifact.projectId === project.id); + if (owned.length !== artifacts.length || artifacts.length === 0) throw new Error('Release artifacts must belong to the project and cannot be empty'); + const artifactIds = artifacts.map((artifact) => artifact.id); + const manifestHash = hashText(json({ projectId: project.id, artifactIds: [...artifactIds].sort(), hashes: artifacts.map((artifact) => [artifact.id, artifact.sha256]).sort(), metadata })); + const candidate: ReleaseCandidate = { id: id('release'), projectId: project.id, manifestHash, artifactIds, metadata, auditStatus, state: auditStatus === 'approved' ? 'approved' : 'draft', createdAt: now() }; + repo.createRelease(candidate); + repo.audit('orchestrator', 'release.create', project.id, candidate.id, manifestHash, { artifactIds }); + return candidate; +} + +export function assertReleaseIsPublishable(repo: EditorialRepository, candidate: ReleaseCandidate, approval: ApprovalRecord): void { + if (candidate.auditStatus !== 'approved' || !['approved', 'prepared'].includes(candidate.state)) throw new Error('Release candidate is not independently audited and approved'); + if (approval.action !== 'publish' || approval.subjectId !== candidate.id || approval.subjectHash !== hashText(json(candidate))) throw new Error('Approval does not match the exact release candidate'); + if (!canUseApproval(approval.state, approval.expiresAt)) throw new Error('Approval is not usable'); +} diff --git a/src/server/editorial/profiles.ts b/src/server/editorial/profiles.ts new file mode 100644 index 000000000..53811321f --- /dev/null +++ b/src/server/editorial/profiles.ts @@ -0,0 +1,59 @@ +import type { AgentProfile, RoleId } from './types.js'; +import { EDITORIAL_ROLES } from './types.js'; + +const commonLimits = ['No secrets in prompts or artifacts', 'No private chain-of-thought storage', 'Escalate scope-changing decisions to the director']; + +const profile = (data: Omit): AgentProfile => ({ ...data, version: '1.0.0' }); + +export const AGENT_PROFILES: Record = { + 'editorial-director': profile({ + id: 'editorial-director', displayName: 'Director editorial y orquestador', + mission: 'Convertir decisiones del propietario en un plan editorial ejecutable y verificable.', + responsibilities: ['Crear briefs y planes', 'Asignar tareas y dependencias', 'Controlar presupuesto y reintentos', 'Solicitar aprobaciones'], + inputs: ['Mensajes del propietario', 'Estado del proyecto', 'Resultados de especialistas'], + deliverables: ['Brief', 'Plan de producción', 'Reporte de hitos', 'Solicitudes de aprobación'], + allowedTools: ['project.read', 'project.write', 'task.assign', 'task.retry', 'approval.request', 'status.notify'], + fileScope: ['project metadata', 'task contracts', 'reports'], limits: [...commonLimits, 'No puede autoaprobar auditoría ni publicación'], + budgetPolicy: 'Reserva presupuesto antes de lanzar tareas; detiene nuevas generaciones al límite.', completionConditions: ['Todas las tareas tienen contrato y dependencia válida', 'Los bloqueos se comunican'], + escalation: ['Decisiones creativas materiales', 'Presupuesto insuficiente', 'Auditoría bloqueada'], systemPrompt: 'Actúa como director editorial. Coordina; no inventes evidencia ni te autoautorices a publicar.', + }), + researcher: profile({ + id: 'researcher', displayName: 'Investigador y creador de ideas', mission: 'Proponer conceptos diferenciados y reunir fuentes verificables sin inventar mercado.', + responsibilities: ['Generar propuestas comparables', 'Investigar contexto', 'Registrar fuentes y afirmaciones', 'Separar hechos e hipótesis'], inputs: ['Brief inicial', 'Consultas autorizadas', 'Fuentes públicas'], deliverables: ['Propuestas', 'Dossier de investigación', 'Registro de fuentes'], allowedTools: ['web.read', 'source.record', 'artifact.write'], fileScope: ['research', 'sources'], limits: [...commonLimits, 'No inventar ventas, rankings ni demanda'], budgetPolicy: 'Usa fuentes accesibles y etiqueta estimaciones.', completionConditions: ['Cada afirmación relevante tiene evidencia o queda marcada'], escalation: ['Fuente contradictoria', 'Necesidad de cuenta o pago'], systemPrompt: 'Investiga con trazabilidad. Declara qué sabes, qué infieres y qué no puedes comprobar.', + }), + 'rights-manager': profile({ + id: 'rights-manager', displayName: 'Gestor de derechos, licencias y procedencia', mission: 'Mantener un expediente de derechos y procedencia por recurso.', responsibilities: ['Inventariar materiales', 'Registrar licencias y evidencia', 'Separar contenido generado y asistido por IA', 'Preparar página legal'], inputs: ['Artefactos', 'Licencias', 'Fuentes oficiales'], deliverables: ['Inventario de derechos', 'Informe de procedencia', 'Pendientes legales'], allowedTools: ['rights.read', 'rights.write', 'source.record', 'artifact.write'], fileScope: ['rights', 'sources', 'legal'], limits: [...commonLimits, 'No certifica originalidad ni registro legal', 'No firma, paga ni presenta trámites'], budgetPolicy: 'No consume servicios de similitud o registro sin autorización.', completionConditions: ['Cada recurso de terceros tiene origen, licencia y estado'], escalation: ['Permiso faltante', 'Jurisdicción no configurada', 'Posible coincidencia'], systemPrompt: 'Describe límites jurídicos y conserva evidencia; no sustituyas asesoría legal.', + }), + writer: profile({ + id: 'writer', displayName: 'Escritor profesional', mission: 'Desarrollar manuscritos por unidades con voz, canon y versiones trazables.', responsibilities: ['Crear esquema', 'Redactar capítulos', 'Mantener canon', 'Solicitar decisiones faltantes'], inputs: ['Brief aprobado', 'Dossier', 'Canon vigente', 'Guía de estilo'], deliverables: ['Capítulos', 'Manuscrito editable', 'Resúmenes', 'Actualizaciones propuestas del canon'], allowedTools: ['context.read', 'artifact.write', 'revision.create', 'canon.propose'], fileScope: ['manuscript', 'canon/proposals'], limits: [...commonLimits, 'No sobrescribir hechos aprobados', 'No rellenar extensión artificialmente'], budgetPolicy: 'Genera por unidad y respeta la concurrencia.', completionConditions: ['Unidad cumple criterios y enlaza la revisión de entrada'], escalation: ['Ambigüedad que cambia el concepto', 'Fuente necesaria no aprobada'], systemPrompt: 'Escribe por unidades. Conserva continuidad y señala lo que necesita decisión o verificación.', + }), + 'development-editor': profile({ + id: 'development-editor', displayName: 'Editor de desarrollo y estilo', mission: 'Mejorar estructura, voz, claridad y ritmo preservando la intención aprobada.', responsibilities: ['Revisar estructura y público', 'Detectar huecos y redundancias', 'Proponer reescrituras', 'Registrar cambios'], inputs: ['Manuscrito', 'Brief', 'Guía de estilo', 'Canon'], deliverables: ['Versión editada', 'Informe de cambios', 'Tareas de reescritura'], allowedTools: ['context.read', 'artifact.write', 'finding.write', 'revision.create'], fileScope: ['manuscript', 'edit-reports'], limits: [...commonLimits, 'No cambiar el brief sin aprobación'], budgetPolicy: 'Limita rondas según configuración del proyecto.', completionConditions: ['Hallazgos tienen ubicación y acción propuesta'], escalation: ['Conflicto entre voz y corrección', 'Cambio estructural material'], systemPrompt: 'Edita con propósito explícito. No ocultes cambios ni conviertas preferencia en error objetivo.', + }), + proofreader: profile({ + id: 'proofreader', displayName: 'Corrector y verificador de contenido', mission: 'Detectar errores objetivos y comprobar afirmaciones o continuidad con alcance claro.', responsibilities: ['Corregir ortografía y gramática', 'Verificar nombres, cifras y fechas', 'Revisar continuidad', 'Clasificar hallazgos'], inputs: ['Revisión congelada', 'Fuentes aprobadas', 'Guía lingüística'], deliverables: ['Correcciones', 'Informe reproducible', 'Lista de afirmaciones pendientes'], allowedTools: ['context.read', 'source.read', 'finding.write', 'artifact.write'], fileScope: ['manuscript', 'proof-reports'], limits: [...commonLimits, 'No prometer ausencia absoluta de errores'], budgetPolicy: 'Prioriza afirmaciones y hallazgos de mayor impacto.', completionConditions: ['Informe ligado a hash de revisión'], escalation: ['Afirmación no verificable', 'Contradicción del canon'], systemPrompt: 'Distingue error objetivo de elección de estilo y documenta ubicación y evidencia.', + }), + 'art-director': profile({ + id: 'art-director', displayName: 'Director de arte, ilustrador y diseñador de cubiertas', mission: 'Crear una identidad visual consistente y componer cubiertas con texto verificable.', responsibilities: ['Definir guía visual', 'Generar ilustraciones', 'Conservar prompts y procedencia', 'Componer portada y lomo'], inputs: ['Brief', 'Canon visual', 'Paginación final', 'Plantilla oficial'], deliverables: ['Guía visual', 'Ilustraciones', 'Portada digital', 'Cubierta impresa editable/exportada'], allowedTools: ['image.generate', 'artifact.write', 'rights.write', 'layout.read'], fileScope: ['art', 'covers', 'rights'], limits: [...commonLimits, 'No poner texto final en la imagen generada', 'No cerrar cubierta antes de paginación'], budgetPolicy: 'Limita variantes y registra costo estimado/observado.', completionConditions: ['Recursos tienen proveedor, modelo, licencia y revisión'], escalation: ['Baja resolución', 'Plantilla incompatible', 'Derecho de recurso incierto'], systemPrompt: 'Separa generación de imagen y tipografía. Mantén metadatos de procedencia y revisa legibilidad.', + }), + 'layout-kdp': profile({ + id: 'layout-kdp', displayName: 'Maquetador y especialista técnico en KDP', mission: 'Producir formatos legibles y comprobables con especificaciones de la edición.', responsibilities: ['Definir formato', 'Maquetar', 'Calcular paginación', 'Validar EPUB/PDF y cubierta'], inputs: ['Manuscrito final', 'Ilustraciones', 'Opciones de edición', 'Reglas oficiales'], deliverables: ['EPUB', 'PDF interior', 'Renderizados', 'Medidas de cubierta', 'Checklist técnico'], allowedTools: ['document.render', 'artifact.write', 'validation.run', 'source.read'], fileScope: ['layout', 'exports', 'renderings'], limits: [...commonLimits, 'No asumir PDF como solución universal', 'No aprobar lo no ejecutado'], budgetPolicy: 'Recalcula derivados cuando cambie la paginación.', completionConditions: ['Archivos abren y validaciones reportan alcance'], escalation: ['Fuente no incrustada', 'Sangrado o plantilla faltante', 'Validación externa no disponible'], systemPrompt: 'Valida dimensiones y lectura real. Declara pendientes como pendientes.', + }), + 'independent-auditor': profile({ + id: 'independent-auditor', displayName: 'Auditor final independiente de calidad', mission: 'Emitir un dictamen independiente sobre un candidato congelado sin modificarlo.', responsibilities: ['Revisar paquete completo', 'Evaluar evidencias y renderizados', 'Bloquear incidencias materiales', 'Emitir dictamen'], inputs: ['Candidato inmutable', 'Fuentes', 'Informes', 'Renderizados'], deliverables: ['Informe de auditoría', 'Dictamen', 'Lista de correcciones'], allowedTools: ['artifact.read', 'validation.read', 'finding.write', 'audit.write'], fileScope: ['audit-reports'], limits: [...commonLimits, 'No modifica archivos auditados', 'No autoautoriza publicación'], budgetPolicy: 'Mantén separación de ejecución y versión.', completionConditions: ['Cada componente tiene alcance y evidencia'], escalation: ['Incidencia bloqueante', 'Versión inconsistente', 'Comprobación externa requerida'], systemPrompt: 'Audita una versión congelada. No confundas existencia de archivo con calidad o publicación.', + }), + 'kdp-publisher': profile({ + id: 'kdp-publisher', displayName: 'Gestor de publicación y distribución en KDP', mission: 'Preparar y, si existe una vía permitida, gestionar un borrador sin duplicados ni envío no autorizado.', responsibilities: ['Preparar metadatos', 'Validar coherencia', 'Cargar borrador mediante capacidad disponible', 'Registrar estado real'], inputs: ['Candidato auditado', 'Metadatos', 'Decisiones comerciales', 'Reglas vigentes'], deliverables: ['Ficha KDP', 'Checklist', 'Registro de carga/estado'], allowedTools: ['kdp.prepare', 'kdp.assisted', 'artifact.write', 'approval.read'], fileScope: ['kdp', 'release'], limits: [...commonLimits, 'No publica sin aprobación exacta', 'No cambia datos fiscales o bancarios'], budgetPolicy: 'No activa exclusividad ni gasto sin decisión explícita.', completionConditions: ['Estado distingue preparado, cargado, revisión y disponible'], escalation: ['MFA/CAPTCHA', 'API no soportada', 'Resultado remoto incierto'], systemPrompt: 'Distingue preparación, asistencia y automatización soportada. Nunca reintentes una acción externa incierta a ciegas.', + }), + 'marketing-analyst': profile({ + id: 'marketing-analyst', displayName: 'Responsable de marketing, redes y analítica', mission: 'Comunicar la obra con contenido autorizado y métricas reales, sin inventar resultados.', responsibilities: ['Definir posicionamiento', 'Preparar calendario', 'Adaptar piezas por red', 'Leer métricas y proponer mejoras'], inputs: ['Brief', 'Extractos autorizados', 'Estado de publicación', 'Cuentas conectadas'], deliverables: ['Calendario', 'Piezas sociales', 'Métricas con origen', 'Informe de resultados'], allowedTools: ['social.prepare', 'social.schedule', 'social.publish', 'metrics.read', 'artifact.write'], fileScope: ['marketing', 'social'], limits: [...commonLimits, 'No mensajes masivos ni publicidad pagada sin autorización', 'No inventar reseñas, ventas o seguidores'], budgetPolicy: 'Publicidad pagada desactivada por defecto.', completionConditions: ['Cada publicación tiene cuenta, horario, zona y estado'], escalation: ['Permiso insuficiente', 'Libro no disponible', 'Métrica no atribuible'], systemPrompt: 'Usa únicamente contenido autorizado y métricas con origen, fecha y período.', + }), +}; + +export function profileFor(role: RoleId): AgentProfile { + return AGENT_PROFILES[role]; +} + +export function allProfiles(): AgentProfile[] { + return EDITORIAL_ROLES.map((role) => profileFor(role)); +} diff --git a/src/server/editorial/providers.ts b/src/server/editorial/providers.ts new file mode 100644 index 000000000..56c7a3107 --- /dev/null +++ b/src/server/editorial/providers.ts @@ -0,0 +1,167 @@ +import type { UsageSnapshot } from './types.js'; + +export interface TextGenerationRequest { + systemPrompt: string; + prompt: string; + model: string; + maxOutputTokens?: number; + temperature?: number; +} + +export interface TextGenerationResult { + text: string; + provider: string; + model: string; + usage: UsageSnapshot; + evidence: { kind: 'service'; ref: string; observedAt: string; note?: string }[]; +} + +export interface ImageGenerationRequest { + prompt: string; + model: string; + size?: string; + quality?: string; + outputFormat?: 'png' | 'jpeg' | 'webp'; +} + +export interface ImageGenerationResult { + bytes: Uint8Array; + mimeType: string; + provider: string; + model: string; + usage: UsageSnapshot; + evidence: { kind: 'service'; ref: string; observedAt: string; note?: string }[]; +} + +export interface EditorialProvider { + readonly id: string; + readonly configured: boolean; + generateText(request: TextGenerationRequest, signal?: AbortSignal): Promise; + generateImage(request: ImageGenerationRequest, signal?: AbortSignal): Promise; +} + +export class MockEditorialProvider implements EditorialProvider { + readonly id = 'mock'; + readonly configured = true; + + async generateText(request: TextGenerationRequest): Promise { + const title = request.prompt.split(/\s+/).filter(Boolean).slice(0, 8).join(' ') || 'obra breve'; + const text = `Borrador de prueba para «${title}».\n\nEsta salida procede del proveedor mock y debe permanecer identificada como simulación.`; + return { text, provider: this.id, model: request.model, usage: { source: 'estimate', estimatedCostUsd: 0 }, evidence: [{ kind: 'service', ref: 'mock://editorial/text', observedAt: new Date().toISOString(), note: 'Proveedor determinista de prueba; no es un servicio real.' }] }; + } + + async generateImage(request: ImageGenerationRequest): Promise { + const label = escapeXml(request.prompt.slice(0, 80)); + const svg = `MOCK — ${label}`; + return { bytes: new TextEncoder().encode(svg), mimeType: 'image/svg+xml', provider: this.id, model: request.model, usage: { source: 'estimate', estimatedCostUsd: 0 }, evidence: [{ kind: 'service', ref: 'mock://editorial/image', observedAt: new Date().toISOString(), note: 'Ilustración SVG de prueba; no es una imagen generada por un proveedor real.' }] }; + } +} + +export interface OpenAIProviderOptions { + apiKey?: string; + baseUrl?: string; + fetchImpl?: typeof fetch; +} + +/** + * OpenAI Responses and Images adapters use the documented HTTP endpoints rather than assuming a + * subscription includes API access. References: https://developers.openai.com/api/reference/resources/responses + * and https://developers.openai.com/api/reference/resources/images. + */ +export class OpenAIEditorialProvider implements EditorialProvider { + readonly id = 'openai'; + readonly configured: boolean; + private readonly fetchImpl: typeof fetch; + private readonly baseUrl: string; + + constructor(private readonly options: OpenAIProviderOptions = {}) { + this.configured = !!options.apiKey; + this.fetchImpl = options.fetchImpl ?? fetch; + this.baseUrl = (options.baseUrl ?? 'https://api.openai.com/v1').replace(/\/$/, ''); + } + + async generateText(request: TextGenerationRequest, signal?: AbortSignal): Promise { + const body = { + model: request.model, + input: [ + { role: 'system', content: [{ type: 'input_text', text: request.systemPrompt }] }, + { role: 'user', content: [{ type: 'input_text', text: request.prompt }] }, + ], + ...(request.maxOutputTokens ? { max_output_tokens: request.maxOutputTokens } : {}), + ...(request.temperature === undefined ? {} : { temperature: request.temperature }), + }; + const response = await this.request('/responses', body, signal); + const text = response.output_text ?? extractResponseText(response.output); + if (!text) throw new Error('OpenAI response did not contain output text'); + return { text, provider: this.id, model: request.model, usage: usageFromResponse(response.usage), evidence: [{ kind: 'service', ref: `${this.baseUrl}/responses`, observedAt: new Date().toISOString(), note: 'Respuesta recibida del endpoint configurado.' }] }; + } + + async generateImage(request: ImageGenerationRequest, signal?: AbortSignal): Promise { + const response = await this.request('/images/generations', { + model: request.model, + prompt: request.prompt, + ...(request.size ? { size: request.size } : {}), + ...(request.quality ? { quality: request.quality } : {}), + ...(request.outputFormat ? { output_format: request.outputFormat } : {}), + }, signal); + const first = Array.isArray(response.data) ? response.data[0] : undefined; + if (!first || typeof first !== 'object') throw new Error('OpenAI image response did not contain image data'); + if (typeof first.b64_json === 'string') { + const format = request.outputFormat ?? 'png'; + return { bytes: Buffer.from(first.b64_json, 'base64'), mimeType: `image/${format}`, provider: this.id, model: request.model, usage: usageFromResponse(response.usage), evidence: [{ kind: 'service', ref: `${this.baseUrl}/images/generations`, observedAt: new Date().toISOString(), note: 'Imagen recibida como base64 del endpoint configurado.' }] }; + } + if (typeof first.url === 'string') { + const image = await this.fetchImpl(first.url, { signal }); + if (!image.ok) throw new Error(`OpenAI image download failed (${image.status})`); + return { bytes: new Uint8Array(await image.arrayBuffer()), mimeType: image.headers.get('content-type') || 'image/png', provider: this.id, model: request.model, usage: usageFromResponse(response.usage), evidence: [{ kind: 'service', ref: first.url, observedAt: new Date().toISOString(), note: 'URL temporal descargada inmediatamente; no se guarda la credencial.' }] }; + } + throw new Error('OpenAI image response contained neither b64_json nor url'); + } + + private async request(endpoint: string, body: unknown, signal?: AbortSignal): Promise { + if (!this.options.apiKey) throw new Error('OPENAI_API_KEY is not configured'); + const response = await this.fetchImpl(`${this.baseUrl}${endpoint}`, { method: 'POST', headers: { authorization: `Bearer ${this.options.apiKey}`, 'content-type': 'application/json' }, body: JSON.stringify(body), signal }); + const payload = await response.json().catch(() => undefined); + if (!response.ok) { + const message = payload && typeof payload === 'object' && 'error' in payload && payload.error && typeof payload.error === 'object' && 'message' in payload.error ? String(payload.error.message) : `HTTP ${response.status}`; + throw new Error(`OpenAI request failed: ${message}`); + } + return payload; + } +} + +export function providerFromEnvironment(env: NodeJS.ProcessEnv = process.env): EditorialProvider { + const provider = (env.EDITORIAL_PROVIDER ?? 'mock').trim().toLowerCase(); + if (provider === 'openai') return new OpenAIEditorialProvider({ apiKey: env.OPENAI_API_KEY, baseUrl: env.OPENAI_BASE_URL }); + return new MockEditorialProvider(); +} + +function usageFromResponse(value: unknown): UsageSnapshot { + if (!value || typeof value !== 'object') return { source: 'unavailable' }; + const record = value as Record; + const input = numberOrUndefined(record.input_tokens); + const output = numberOrUndefined(record.output_tokens); + return { inputTokens: input, outputTokens: output, source: input === undefined && output === undefined ? 'unavailable' : 'provider' }; +} + +function numberOrUndefined(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined; +} + +function extractResponseText(output: unknown): string { + if (!Array.isArray(output)) return ''; + const parts: string[] = []; + for (const item of output) { + if (!item || typeof item !== 'object') continue; + const content = (item as Record).content; + if (!Array.isArray(content)) continue; + for (const part of content) { + if (part && typeof part === 'object' && typeof (part as Record).text === 'string') parts.push(String((part as Record).text)); + } + } + return parts.join(''); +} + +function escapeXml(value: string): string { + return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"'); +} diff --git a/src/server/editorial/repository.ts b/src/server/editorial/repository.ts new file mode 100644 index 000000000..1c539b3ef --- /dev/null +++ b/src/server/editorial/repository.ts @@ -0,0 +1,390 @@ +import type { EditorialDatabase } from './db.js'; +import { hashText, id, json, now, parseJson } from './ids.js'; +import type { AgentRunRecord, ApprovalRecord, ArtifactRef, CapabilityRow, Finding, ProjectBrief, ProjectRecord, ReleaseCandidate, RevisionRecord, RightsRecord, RoleId, SocialAccountRecord, SocialPostRecord, SourceRecord, TaskContract, TaskResult, TaskState, UsageSnapshot } from './types.js'; +import { initialCapabilities } from './capabilities.js'; +import { assertProjectTransition } from './state.js'; + +type Row = Record; + +export class EditorialRepository { + constructor(readonly db: EditorialDatabase) { + this.seedCapabilities(); + } + + createProject(brief: ProjectBrief): ProjectRecord { + const project: ProjectRecord = { id: id('project'), title: brief.provisionalTitle, stage: 'idea', paused: false, brief, createdAt: now(), updatedAt: now() }; + this.db.run('INSERT INTO projects(id,title,stage,paused,brief_json,created_at,updated_at) VALUES (?,?,?,?,?,?,?)', project.id, project.title, project.stage, 0, json(project.brief), project.createdAt, project.updatedAt); + this.audit('owner', 'project.create', project.id, undefined, undefined, { title: project.title }); + return project; + } + + getProject(projectId: string): ProjectRecord | undefined { + const row = this.db.get('SELECT * FROM projects WHERE id = ?', projectId); + return row ? projectRow(row) : undefined; + } + + listProjects(): ProjectRecord[] { + return this.db.all('SELECT * FROM projects ORDER BY updated_at DESC').map(projectRow); + } + + updateProjectStage(projectId: string, stage: ProjectRecord['stage'], paused = stage === 'paused'): void { + const current = this.getProject(projectId); + if (!current) throw new Error('Project not found'); + assertProjectTransition(current.stage, stage); + const updatedAt = now(); + this.db.run('UPDATE projects SET stage = ?, paused = ?, updated_at = ? WHERE id = ?', stage, paused ? 1 : 0, updatedAt, projectId); + } + + createRevision(projectId: string, kind: string, contentHash: string, manifest: Record, parentId?: string, activate = true): RevisionRecord { + const project = this.getProject(projectId); + if (!project) throw new Error('Project not found'); + if (parentId && !this.db.get('SELECT 1 AS id FROM revisions WHERE id = ? AND project_id = ?', parentId, projectId)) throw new Error('Revision parent does not belong to project'); + const revision: RevisionRecord = { id: id('revision'), projectId, kind, parentId, contentHash, manifest, createdAt: now() }; + this.db.transaction(() => { + this.db.run('INSERT INTO revisions(id,project_id,kind,parent_id,content_hash,manifest_json,created_at) VALUES (?,?,?,?,?,?,?)', revision.id, revision.projectId, revision.kind, revision.parentId ?? null, revision.contentHash, json(revision.manifest), revision.createdAt); + if (activate) this.db.run('UPDATE projects SET active_revision_id = ?, updated_at = ? WHERE id = ?', revision.id, revision.createdAt, projectId); + if (parentId) { + this.db.run('UPDATE releases SET state = \'needs_changes\', audit_status = \'needs_changes\' WHERE project_id = ? AND state IN (\'approved\',\'prepared\',\'assisted\')', projectId); + this.db.run('UPDATE approvals SET state = \'revoked\' WHERE project_id = ? AND state IN (\'pending\',\'approved\')', projectId); + } + this.audit('owner', 'revision.create', projectId, undefined, contentHash, { revisionId: revision.id, kind, parentId, activate }); + }); + return revision; + } + + listRevisions(projectId?: string): RevisionRecord[] { + const rows = projectId ? this.db.all('SELECT * FROM revisions WHERE project_id = ? ORDER BY created_at', projectId) : this.db.all('SELECT * FROM revisions ORDER BY created_at'); + return rows.map(revisionRow); + } + + createTask(contract: TaskContract, state: TaskState = 'pending'): void { + const createdAt = now(); + this.db.transaction(() => { + this.db.run('INSERT INTO tasks(id,project_id,edition_id,role_id,state,contract_json,max_attempts,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?)', contract.taskId, contract.projectId, contract.editionId ?? null, contract.roleId, state, json(contract), contract.maxAttempts, createdAt, createdAt); + for (const dep of contract.dependencyTaskIds) this.db.run('INSERT INTO task_dependencies(task_id,depends_on) VALUES (?,?)', contract.taskId, dep); + }); + this.audit('orchestrator', 'task.create', contract.projectId, contract.taskId, undefined, { roleId: contract.roleId }); + } + + getTask(taskId: string): { contract: TaskContract; state: TaskState; attempts: number; generation: number; leaseOwner?: string; leaseUntil?: string; updatedAt: string; result?: TaskResult } | undefined { + const row = this.db.get('SELECT * FROM tasks WHERE id = ?', taskId); + return row ? taskRow(row) : undefined; + } + + listTasks(projectId?: string): Array<{ contract: TaskContract; state: TaskState; attempts: number; generation: number; updatedAt: string }> { + const rows = projectId ? this.db.all('SELECT * FROM tasks WHERE project_id = ? ORDER BY created_at', projectId) : this.db.all('SELECT * FROM tasks ORDER BY created_at'); + return rows.map(taskRow); + } + + readyTasks(limit: number, projectId?: string): string[] { + const projectFilter = projectId ? ' AND t.project_id = ?' : ''; + const query = `SELECT t.id FROM tasks t WHERE t.state IN ('pending','ready','uncertain')${projectFilter} AND NOT EXISTS ( + SELECT 1 FROM task_dependencies d JOIN tasks dep ON dep.id = d.depends_on WHERE d.task_id = t.id AND dep.state <> 'completed' + ) ORDER BY t.created_at LIMIT ?`; + const rows = projectId ? this.db.all<{ id: string }>(query, projectId, limit) : this.db.all<{ id: string }>(query, limit); + return rows.map((row) => row.id); + } + + claimTask(taskId: string, owner: string, leaseUntil: string): { contract: TaskContract; generation: number } | undefined { + return this.db.transaction(() => { + const task = this.getTask(taskId); + if (!task || !['pending', 'ready', 'uncertain'].includes(task.state)) return undefined; + const generation = task.generation + 1; + const result = this.db.run('UPDATE tasks SET state = ?, attempts = attempts + 1, generation = ?, lease_owner = ?, lease_until = ?, heartbeat_at = ?, started_at = COALESCE(started_at, ?), updated_at = ? WHERE id = ? AND state IN (\'pending\',\'ready\',\'uncertain\')', 'running', generation, owner, leaseUntil, now(), now(), now(), taskId); + if (!result.changes) return undefined; + return { contract: task.contract, generation }; + }); + } + + heartbeat(taskId: string, owner: string, leaseUntil: string): boolean { + return Number(this.db.run('UPDATE tasks SET lease_until = ?, heartbeat_at = ?, updated_at = ? WHERE id = ? AND state = \'running\' AND lease_owner = ?', leaseUntil, now(), now(), taskId, owner).changes) > 0; + } + + recoverExpiredLeases(at = now()): number { + const result = this.db.run(`UPDATE tasks SET state = CASE WHEN attempts < max_attempts THEN 'ready' ELSE 'failed' END, + lease_owner = NULL, lease_until = NULL, updated_at = ? + WHERE state = 'running' AND lease_until IS NOT NULL AND lease_until <= ?`, at, at); + return Number(result.changes); + } + + cancelTask(taskId: string, reason: string): boolean { + const result = this.db.run(`UPDATE tasks SET state = 'cancelled', generation = generation + 1, + lease_owner = NULL, lease_until = NULL, updated_at = ?, result_json = ? + WHERE id = ? AND state IN ('pending','blocked','ready','running','waiting_user','uncertain')`, now(), json({ reason }), taskId); + return Number(result.changes) > 0; + } + + reserveBudget(projectId: string | undefined, taskId: string, provider: string, amountUsd: number, budgetUsd?: number): string | undefined { + if (!(amountUsd >= 0) || !Number.isFinite(amountUsd)) throw new Error('Invalid budget reservation'); + return this.db.transaction(() => { + const totals = this.usageTotals(); + if (budgetUsd !== undefined && totals.observedUsd + totals.reservedUsd + amountUsd > budgetUsd + 1e-9) return undefined; + const reservationId = id('reserve'); + this.db.run('INSERT INTO cost_events(id,project_id,task_id,provider,estimated_usd,observed_usd,reserved_usd,source,created_at) VALUES (?,?,?,?,?,?,?,?,?)', reservationId, projectId ?? null, taskId, provider, 0, 0, amountUsd, 'estimate', now()); + return reservationId; + }); + } + + settleBudget(reservationId: string, usage: UsageSnapshot): void { + this.db.run('UPDATE cost_events SET estimated_usd = ?, observed_usd = ?, reserved_usd = 0, source = ? WHERE id = ?', usage.estimatedCostUsd ?? 0, usage.observedCostUsd ?? 0, usage.source, reservationId); + } + + completeTask(taskId: string, owner: string, generation: number, result: TaskResult): boolean { + return Number(this.db.run('UPDATE tasks SET state = ?, result_json = ?, lease_owner = NULL, lease_until = NULL, updated_at = ?, completed_at = ? WHERE id = ? AND state = \'running\' AND lease_owner = ? AND generation = ?', result.state, json(result), now(), now(), taskId, owner, generation).changes) > 0; + } + + saveCheckpoint(taskId: string, generation: number, state: unknown): void { + this.db.run('INSERT INTO checkpoints(id,task_id,generation,state_json,created_at) VALUES (?,?,?,?,?)', id('checkpoint'), taskId, generation, json(state), now()); + } + + recordRun(run: AgentRunRecord): void { + this.db.run('INSERT INTO agent_runs(id,task_id,role_id,provider,model,profile_version,office_worker_id,office_floor_id,status,action_summary,started_at,finished_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)', run.runId, run.taskId, run.roleId, run.provider, run.model ?? null, run.profileVersion, run.officeWorkerId ?? null, run.officeFloorId ?? null, run.status, run.actionSummary ?? null, run.startedAt, run.finishedAt ?? null); + } + + finishRun(runId: string, status: AgentRunRecord['status'], summary: string): void { + this.db.run('UPDATE agent_runs SET status = ?, action_summary = ?, finished_at = ? WHERE id = ?', status, summary, now(), runId); + } + + updateRunMetadata(runId: string, provider: string, model: string | undefined, profileVersion: string): void { + this.db.run('UPDATE agent_runs SET provider = ?, model = ?, profile_version = ? WHERE id = ?', provider, model ?? null, profileVersion, runId); + } + + listRuns(projectId?: string): AgentRunRecord[] { + const rows = projectId ? this.db.all('SELECT r.* FROM agent_runs r JOIN tasks t ON t.id = r.task_id WHERE t.project_id = ? ORDER BY r.started_at DESC', projectId) : this.db.all('SELECT * FROM agent_runs ORDER BY started_at DESC'); + return rows.map((row) => ({ runId: String(row.id), taskId: String(row.task_id), roleId: row.role_id as AgentRunRecord['roleId'], provider: String(row.provider), model: row.model ? String(row.model) : undefined, profileVersion: String(row.profile_version), officeWorkerId: row.office_worker_id ? String(row.office_worker_id) : undefined, officeFloorId: row.office_floor_id ? String(row.office_floor_id) : undefined, status: row.status as AgentRunRecord['status'], startedAt: String(row.started_at), finishedAt: row.finished_at ? String(row.finished_at) : undefined, actionSummary: row.action_summary ? String(row.action_summary) : undefined })); + } + + recordArtifact(artifact: ArtifactRef): void { + this.db.transaction(() => { + this.db.run('INSERT INTO artifacts(id,project_id,edition_id,task_id,revision_id,type,path,sha256,size_bytes,metadata_json,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)', artifact.id, artifact.projectId, artifact.editionId ?? null, artifact.taskId ?? null, artifact.revisionId ?? null, artifact.type, artifact.path, artifact.sha256, artifact.sizeBytes, json(artifact.metadata), now()); + this.db.run('INSERT INTO rights_records(id,project_id,artifact_id,origin,creator,license,evidence,ai_status,conditions,status,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)', id('rights'), artifact.projectId, artifact.id, typeof artifact.metadata.source === 'string' ? artifact.metadata.source : typeof artifact.metadata.provider === 'string' ? artifact.metadata.provider : 'owner-provided', typeof artifact.metadata.creator === 'string' ? artifact.metadata.creator : null, typeof artifact.metadata.license === 'string' ? artifact.metadata.license : null, typeof artifact.metadata.evidence === 'string' ? artifact.metadata.evidence : json(artifact.metadata.evidence ?? {}), typeof artifact.metadata.aiStatus === 'string' ? artifact.metadata.aiStatus : 'unknown', null, 'recorded', now()); + }); + } + + listAllArtifacts(): ArtifactRef[] { + return this.db.all('SELECT * FROM artifacts ORDER BY created_at').map(artifactRow); + } + + listArtifacts(projectId: string): ArtifactRef[] { + return this.db.all('SELECT * FROM artifacts WHERE project_id = ? ORDER BY created_at', projectId).map(artifactRow); + } + + addFinding(projectId: string, finding: Finding, taskId?: string): string { + const findingId = finding.id ?? id('finding'); + this.db.run('INSERT INTO findings(id,project_id,task_id,severity,title,detail,location,resolution,status,created_at) VALUES (?,?,?,?,?,?,?,?,?,?)', findingId, projectId, taskId ?? null, finding.severity, finding.title, finding.detail, finding.location ?? null, finding.resolution ?? null, finding.status, now()); + return findingId; + } + + recordSource(source: SourceRecord): void { + this.db.run('INSERT INTO sources(id,project_id,url,identifier,title,observed_at,notes,claims_json) VALUES (?,?,?,?,?,?,?,?)', source.id, source.projectId, source.url ?? null, source.identifier ?? null, source.title ?? null, source.observedAt, source.notes ?? null, json(source.claims)); + } + + listSources(projectId?: string): SourceRecord[] { + const rows = projectId ? this.db.all('SELECT * FROM sources WHERE project_id = ? ORDER BY observed_at', projectId) : this.db.all('SELECT * FROM sources ORDER BY observed_at'); + return rows.map(sourceRow); + } + + recordRights(rights: RightsRecord): void { + this.db.run('INSERT INTO rights_records(id,project_id,artifact_id,origin,creator,license,evidence,ai_status,conditions,status,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)', rights.id, rights.projectId, rights.artifactId ?? null, rights.origin, rights.creator ?? null, rights.license ?? null, rights.evidence ?? null, rights.aiStatus, rights.conditions ?? null, rights.status, rights.updatedAt); + } + + listRights(projectId?: string): RightsRecord[] { + const rows = projectId ? this.db.all('SELECT * FROM rights_records WHERE project_id = ? ORDER BY updated_at', projectId) : this.db.all('SELECT * FROM rights_records ORDER BY updated_at'); + return rows.map(rightsRow); + } + + createApproval(record: ApprovalRecord): void { + this.db.run('INSERT INTO approvals(id,project_id,action,subject_id,subject_hash,owner_id,state,token_hash,expires_at,created_at,used_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)', record.id, record.projectId, record.action, record.subjectId, record.subjectHash, record.ownerId, record.state, record.tokenHash, record.expiresAt, record.createdAt, record.usedAt ?? null); + } + + getApproval(approvalId: string): ApprovalRecord | undefined { + const row = this.db.get('SELECT * FROM approvals WHERE id = ?', approvalId); + return row ? approvalRow(row) : undefined; + } + + approveApproval(approvalId: string, ownerId: string): ApprovalRecord | undefined { + return this.db.transaction(() => { + const row = this.db.get('SELECT * FROM approvals WHERE id = ? AND owner_id = ?', approvalId, ownerId); + if (!row || row.state !== 'pending' || Date.parse(String(row.expires_at)) <= Date.now()) return undefined; + const changed = this.db.run('UPDATE approvals SET state = \'approved\' WHERE id = ? AND owner_id = ? AND state = \'pending\'', approvalId, ownerId); + return changed.changes ? approvalRow({ ...row, state: 'approved' }) : undefined; + }); + } + + rejectApproval(approvalId: string, ownerId: string): ApprovalRecord | undefined { + return this.db.transaction(() => { + const row = this.db.get('SELECT * FROM approvals WHERE id = ? AND owner_id = ?', approvalId, ownerId); + if (!row || !['pending', 'approved'].includes(String(row.state))) return undefined; + const changed = this.db.run('UPDATE approvals SET state = \'rejected\' WHERE id = ? AND owner_id = ? AND state IN (\'pending\',\'approved\')', approvalId, ownerId); + return changed.changes ? approvalRow({ ...row, state: 'rejected' }) : undefined; + }); + } + + consumeApprovalById(approvalId: string, ownerId: string, subjectHash: string): ApprovalRecord | undefined { + return this.db.transaction(() => { + const row = this.db.get('SELECT * FROM approvals WHERE id = ? AND owner_id = ? AND subject_hash = ?', approvalId, ownerId, subjectHash); + if (!row || !['pending', 'approved'].includes(String(row.state)) || Date.parse(String(row.expires_at)) <= Date.now()) return undefined; + const usedAt = now(); + const changed = this.db.run('UPDATE approvals SET state = \'used\', used_at = ? WHERE id = ? AND owner_id = ? AND subject_hash = ? AND state IN (\'pending\',\'approved\')', usedAt, approvalId, ownerId, subjectHash); + return changed.changes ? approvalRow({ ...row, state: 'used', used_at: usedAt }) : undefined; + }); + } + + consumeApproval(tokenHash: string, ownerId: string, action: ApprovalRecord['action'], subjectId: string, subjectHash: string): ApprovalRecord | undefined { + return this.db.transaction(() => { + const row = this.db.get('SELECT * FROM approvals WHERE token_hash = ? AND owner_id = ? AND action = ? AND subject_id = ? AND subject_hash = ?', tokenHash, ownerId, action, subjectId, subjectHash); + if (!row || row.state !== 'pending' || Date.parse(String(row.expires_at)) <= Date.now()) return undefined; + const usedAt = now(); + const changed = this.db.run('UPDATE approvals SET state = \'used\', used_at = ? WHERE id = ? AND state = \'pending\'', usedAt, String(row.id)); + if (!changed.changes) return undefined; + return approvalRow({ ...row, state: 'used', used_at: usedAt }); + }); + } + + listApprovals(projectId?: string): ApprovalRecord[] { + const rows = projectId ? this.db.all('SELECT * FROM approvals WHERE project_id = ? ORDER BY created_at DESC', projectId) : this.db.all('SELECT * FROM approvals ORDER BY created_at DESC'); + return rows.map(approvalRow); + } + + findApprovalByTokenHash(tokenHash: string): ApprovalRecord | undefined { + const row = this.db.get('SELECT * FROM approvals WHERE token_hash = ?', tokenHash); + return row ? approvalRow(row) : undefined; + } + + upsertSocialAccount(account: SocialAccountRecord): void { + this.db.run('INSERT INTO social_accounts(id,platform,account_label,token_ref,capabilities_json,status,metadata_json,updated_at) VALUES (?,?,?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET platform=excluded.platform,account_label=excluded.account_label,token_ref=excluded.token_ref,capabilities_json=excluded.capabilities_json,status=excluded.status,metadata_json=excluded.metadata_json,updated_at=excluded.updated_at', account.id, account.platform, account.accountLabel, account.tokenRef ?? null, json(account.capabilities), account.status, json(account.metadata), account.updatedAt); + } + + listSocialAccounts(): SocialAccountRecord[] { + return this.db.all('SELECT * FROM social_accounts ORDER BY account_label').map(socialAccountRow); + } + + createSocialPost(post: SocialPostRecord): void { + this.db.run('INSERT INTO social_posts(id,project_id,account_id,content_hash,content_json,scheduled_at,timezone,state,remote_id,remote_url,last_error,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)', post.id, post.projectId, post.accountId ?? null, post.contentHash, json(post.content), post.scheduledAt ?? null, post.timezone, post.state, post.remoteId ?? null, post.remoteUrl ?? null, post.lastError ?? null, post.updatedAt); + } + + updateSocialPost(postId: string, patch: Partial>): boolean { + const result = this.db.run('UPDATE social_posts SET state = COALESCE(?, state), remote_id = COALESCE(?, remote_id), remote_url = COALESCE(?, remote_url), last_error = COALESCE(?, last_error), updated_at = ? WHERE id = ?', patch.state ?? null, patch.remoteId ?? null, patch.remoteUrl ?? null, patch.lastError ?? null, now(), postId); + return Number(result.changes) > 0; + } + + listSocialPosts(projectId?: string): SocialPostRecord[] { + const rows = projectId ? this.db.all('SELECT * FROM social_posts WHERE project_id = ? ORDER BY scheduled_at, updated_at', projectId) : this.db.all('SELECT * FROM social_posts ORDER BY scheduled_at, updated_at'); + return rows.map(socialPostRow); + } + + updateReleaseState(releaseId: string, state: ReleaseCandidate['state'], auditStatus?: ReleaseCandidate['auditStatus']): boolean { + const result = this.db.run('UPDATE releases SET state = ?, audit_status = COALESCE(?, audit_status) WHERE id = ?', state, auditStatus ?? null, releaseId); + return Number(result.changes) > 0; + } + + createRelease(candidate: ReleaseCandidate): void { + this.db.run('INSERT INTO releases(id,project_id,manifest_hash,artifact_ids_json,metadata_json,audit_status,state,created_at) VALUES (?,?,?,?,?,?,?,?)', candidate.id, candidate.projectId, candidate.manifestHash, json(candidate.artifactIds), json(candidate.metadata), candidate.auditStatus, candidate.state, candidate.createdAt); + } + + getRelease(releaseId: string): ReleaseCandidate | undefined { + const row = this.db.get('SELECT * FROM releases WHERE id = ?', releaseId); + return row ? releaseRow(row) : undefined; + } + + listReleases(projectId?: string): ReleaseCandidate[] { + const rows = projectId ? this.db.all('SELECT * FROM releases WHERE project_id = ? ORDER BY created_at DESC', projectId) : this.db.all('SELECT * FROM releases ORDER BY created_at DESC'); + return rows.map(releaseRow); + } + + invalidateProjectOutputs(projectId: string, reason: string): void { + this.db.transaction(() => { + this.db.run('UPDATE releases SET state = \'needs_changes\', audit_status = \'needs_changes\' WHERE project_id = ? AND state IN (\'approved\',\'prepared\',\'assisted\')', projectId); + this.db.run('UPDATE approvals SET state = \'revoked\' WHERE project_id = ? AND state = \'pending\'', projectId); + this.audit('system', 'outputs.invalidate', projectId, undefined, undefined, { reason }); + }); + } + + listFindings(projectId?: string): Finding[] { + const rows = projectId ? this.db.all('SELECT * FROM findings WHERE project_id = ? ORDER BY created_at', projectId) : this.db.all('SELECT * FROM findings ORDER BY created_at'); + return rows.map((row) => ({ id: String(row.id), severity: row.severity as Finding['severity'], title: String(row.title), detail: String(row.detail), location: row.location ? String(row.location) : undefined, resolution: row.resolution ? String(row.resolution) : undefined, status: row.status as Finding['status'] })); + } + + rememberTelegramUpdate(updateId: number): boolean { + try { + this.db.run('INSERT INTO telegram_updates(update_id,received_at) VALUES (?,?)', updateId, now()); + return true; + } catch { + return false; + } + } + + saveCapabilities(rows: CapabilityRow[]): void { + this.db.transaction(() => { + for (const row of rows) this.db.run('INSERT INTO capability_matrix(capability,component,state,evidence_json,dependency,limitation,checked_at) VALUES (?,?,?,?,?,?,?) ON CONFLICT(capability) DO UPDATE SET component=excluded.component,state=excluded.state,evidence_json=excluded.evidence_json,dependency=excluded.dependency,limitation=excluded.limitation,checked_at=excluded.checked_at', row.capability, row.component, row.state, json(row.evidence), row.dependency ?? null, row.limitation ?? null, row.checkedAt); + }); + } + + capabilities(): CapabilityRow[] { + return this.db.all('SELECT * FROM capability_matrix ORDER BY capability').map((row) => ({ capability: String(row.capability), component: String(row.component), state: row.state as CapabilityRow['state'], evidence: parseJson(row.evidence_json, []), dependency: row.dependency ? String(row.dependency) : undefined, limitation: row.limitation ? String(row.limitation) : undefined, checkedAt: String(row.checked_at) })); + } + + recordCost(projectId: string | undefined, taskId: string | undefined, provider: string, usage: UsageSnapshot, reservedUsd = 0): void { + this.db.run('INSERT INTO cost_events(id,project_id,task_id,provider,estimated_usd,observed_usd,reserved_usd,source,created_at) VALUES (?,?,?,?,?,?,?,?,?)', id('cost'), projectId ?? null, taskId ?? null, provider, usage.estimatedCostUsd ?? 0, usage.observedCostUsd ?? 0, reservedUsd, usage.source, now()); + } + + usageTotals(): { estimatedUsd: number; observedUsd: number; reservedUsd: number } { + const row = this.db.get<{ estimated: number; observed: number; reserved: number }>('SELECT COALESCE(SUM(estimated_usd),0) AS estimated, COALESCE(SUM(observed_usd),0) AS observed, COALESCE(SUM(reserved_usd),0) AS reserved FROM cost_events'); + return { estimatedUsd: Number(row?.estimated ?? 0), observedUsd: Number(row?.observed ?? 0), reservedUsd: Number(row?.reserved ?? 0) }; + } + + audit(actor: string, action: string, projectId?: string, taskId?: string, subjectHash?: string, detail: unknown = {}): void { + this.db.run('INSERT INTO audit_events(id,actor,action,project_id,task_id,subject_hash,detail_json,created_at) VALUES (?,?,?,?,?,?,?,?)', id('audit'), actor, action, projectId ?? null, taskId ?? null, subjectHash ?? null, json(detail), now()); + } + + private seedCapabilities(): void { + if (this.db.get('SELECT 1 AS ok FROM capability_matrix LIMIT 1')) return; + this.saveCapabilities(initialCapabilities()); + } +} + +function projectRow(row: Row): ProjectRecord { + return { id: String(row.id), title: String(row.title), stage: row.stage as ProjectRecord['stage'], paused: Number(row.paused) === 1, brief: parseJson(row.brief_json, { provisionalTitle: String(row.title), collaborators: [], language: 'es', locale: 'es-419', desiredFormats: ['epub'] }), activeRevisionId: row.active_revision_id ? String(row.active_revision_id) : undefined, createdAt: String(row.created_at), updatedAt: String(row.updated_at) }; +} + +function revisionRow(row: Row): RevisionRecord { + return { id: String(row.id), projectId: String(row.project_id), kind: String(row.kind), parentId: row.parent_id ? String(row.parent_id) : undefined, contentHash: String(row.content_hash), manifest: parseJson>(row.manifest_json, {}), createdAt: String(row.created_at) }; +} + +function taskRow(row: Row) { + return { contract: parseJson(row.contract_json, { taskId: String(row.id), projectId: String(row.project_id), roleId: row.role_id as RoleId, objective: '', scope: '', inputRevisionIds: [], dependencyTaskIds: [], acceptanceCriteria: [], tools: [], timeoutSeconds: 900, maxAttempts: Number(row.max_attempts), resultDestination: '' }), state: row.state as TaskState, attempts: Number(row.attempts), generation: Number(row.generation), leaseOwner: row.lease_owner ? String(row.lease_owner) : undefined, leaseUntil: row.lease_until ? String(row.lease_until) : undefined, updatedAt: String(row.updated_at), result: row.result_json ? parseJson(row.result_json, undefined) : undefined }; +} + +function artifactRow(row: Row): ArtifactRef { + return { id: String(row.id), type: row.type as ArtifactRef['type'], projectId: String(row.project_id), editionId: row.edition_id ? String(row.edition_id) : undefined, taskId: row.task_id ? String(row.task_id) : undefined, revisionId: row.revision_id ? String(row.revision_id) : undefined, path: String(row.path), sha256: String(row.sha256), sizeBytes: Number(row.size_bytes), metadata: parseJson>(row.metadata_json, {}) }; +} + +function approvalRow(row: Row): ApprovalRecord { + return { id: String(row.id), projectId: String(row.project_id), action: row.action as ApprovalRecord['action'], subjectId: String(row.subject_id), subjectHash: String(row.subject_hash), ownerId: String(row.owner_id), state: row.state as ApprovalRecord['state'], tokenHash: String(row.token_hash), expiresAt: String(row.expires_at), createdAt: String(row.created_at), usedAt: row.used_at ? String(row.used_at) : undefined }; +} + +function releaseRow(row: Row): ReleaseCandidate { + return { id: String(row.id), projectId: String(row.project_id), manifestHash: String(row.manifest_hash), artifactIds: parseJson(row.artifact_ids_json, []), metadata: parseJson>(row.metadata_json, {}), auditStatus: row.audit_status as ReleaseCandidate['auditStatus'], state: row.state as ReleaseCandidate['state'], createdAt: String(row.created_at) }; +} + +function sourceRow(row: Row): SourceRecord { + return { id: String(row.id), projectId: String(row.project_id), url: row.url ? String(row.url) : undefined, identifier: row.identifier ? String(row.identifier) : undefined, title: row.title ? String(row.title) : undefined, observedAt: String(row.observed_at), notes: row.notes ? String(row.notes) : undefined, claims: parseJson(row.claims_json, []) }; +} + +function rightsRow(row: Row): RightsRecord { + return { id: String(row.id), projectId: String(row.project_id), artifactId: row.artifact_id ? String(row.artifact_id) : undefined, origin: String(row.origin), creator: row.creator ? String(row.creator) : undefined, license: row.license ? String(row.license) : undefined, evidence: row.evidence ? String(row.evidence) : undefined, aiStatus: String(row.ai_status), conditions: row.conditions ? String(row.conditions) : undefined, status: String(row.status), updatedAt: String(row.updated_at) }; +} + +function socialAccountRow(row: Row): SocialAccountRecord { + return { id: String(row.id), platform: String(row.platform), accountLabel: String(row.account_label), tokenRef: row.token_ref ? String(row.token_ref) : undefined, capabilities: parseJson(row.capabilities_json, []), status: String(row.status), metadata: parseJson>(row.metadata_json, {}), updatedAt: String(row.updated_at) }; +} + +function socialPostRow(row: Row): SocialPostRecord { + return { id: String(row.id), projectId: String(row.project_id), accountId: row.account_id ? String(row.account_id) : undefined, contentHash: String(row.content_hash), content: parseJson>(row.content_json, {}), scheduledAt: row.scheduled_at ? String(row.scheduled_at) : undefined, timezone: String(row.timezone), state: String(row.state), remoteId: row.remote_id ? String(row.remote_id) : undefined, remoteUrl: row.remote_url ? String(row.remote_url) : undefined, lastError: row.last_error ? String(row.last_error) : undefined, updatedAt: String(row.updated_at) }; +} + +export function manifestHash(artifactIds: string[], metadata: Record): string { + return hashText(json({ artifactIds: [...artifactIds].sort(), metadata })); +} + +export type { ReleaseCandidate, ApprovalRecord }; diff --git a/src/server/editorial/runtime.ts b/src/server/editorial/runtime.ts new file mode 100644 index 000000000..c72cf74e6 --- /dev/null +++ b/src/server/editorial/runtime.ts @@ -0,0 +1,290 @@ +import { AgentOfficeAdapter } from './agent-office-adapter.js'; +import { loadEditorialConfig, type EditorialConfig } from './config.js'; +import { EditorialDatabase } from './db.js'; +import { EditorialDocuments } from './documents.js'; +import { id, now } from './ids.js'; +import { EditorialOrchestrator, type ExecutionContext } from './orchestrator.js'; +import { AGENT_PROFILES, profileFor } from './profiles.js'; +import { EditorialProvider, providerFromEnvironment } from './providers.js'; +import { EditorialRepository } from './repository.js'; +import { EditorialStorage } from './storage.js'; +import { approveApproval, assertReleaseIsPublishable, assertTaskAuthority, consumeApprovedApproval, createReleaseCandidate, rejectApproval, requestApproval } from './policy.js'; +import { TelegramBot } from './telegram.js'; +import { hashText, json } from './ids.js'; +import { backupEditorial } from './backup.js'; +import { EditorialAudit } from './audit.js'; +import { KdpPreparation } from './kdp.js'; +import { EditorialMarketing } from './social.js'; +import type { ApprovalRecord, ArtifactRef, DashboardSnapshot, ProjectBrief, ProjectRecord, ReleaseCandidate, RoleId, TaskContract, TaskResult } from './types.js'; + +export class EditorialRuntime { + readonly config: EditorialConfig; + readonly db: EditorialDatabase; + readonly repo: EditorialRepository; + readonly storage: EditorialStorage; + readonly documents: EditorialDocuments; + readonly audit: EditorialAudit; + readonly kdp: KdpPreparation; + readonly marketing: EditorialMarketing; + readonly provider: EditorialProvider; + readonly telegram: TelegramBot; + readonly agentOffice: AgentOfficeAdapter; + readonly orchestrator: EditorialOrchestrator; + + constructor(dataDir: string, env: NodeJS.ProcessEnv = process.env, dependencies: { fetchImpl?: typeof fetch } = {}) { + this.config = loadEditorialConfig(dataDir, env); + this.db = new EditorialDatabase(`${this.config.storageDir}/editorial.sqlite`); + this.repo = new EditorialRepository(this.db); + this.storage = new EditorialStorage(`${this.config.storageDir}/files`); + this.documents = new EditorialDocuments(this.storage, this.repo); + this.audit = new EditorialAudit(this.repo, this.storage, this.documents); + this.kdp = new KdpPreparation(this.repo, this.documents); + this.marketing = new EditorialMarketing(this.repo, this.documents, env.MASTODON_BASE_URL, env.MASTODON_ACCESS_TOKEN, dependencies.fetchImpl); + this.provider = providerFromEnvironment(env); + this.agentOffice = new AgentOfficeAdapter(this.repo, this.config.agentOfficeFloor, this.config.agentOfficeProvider); + this.orchestrator = new EditorialOrchestrator(this.repo, { + maxConcurrent: this.config.maxConcurrentGenerations, + budgetUsd: this.config.budgetUsd, + leaseMs: 10 * 60 * 1000, + pollMs: 1000, + owner: 'editorial', + estimateCost: (task) => task.budgetUsd ?? 0, + executor: (context) => this.execute(context), + onEvent: (event) => this.repo.audit('orchestrator', `queue.${event.type}`, undefined, event.taskId, undefined, event), + }); + this.telegram = new TelegramBot(this, this.config.botToken, this.config.ownerId, this.config.authorizedChatIds); + } + + start(): void { + this.orchestrator.start(this.config.autonomousMode); + this.telegram.start(); + } + + setAutonomousMode(enabled: boolean): void { + if (this.config.autonomousMode === enabled) return; + this.config.autonomousMode = enabled; + this.orchestrator.start(enabled); + this.repo.audit('owner', 'queue.mode-change', undefined, undefined, undefined, { autonomousMode: enabled }); + } + + async runPending(projectId?: string): Promise { + this.repo.audit('owner', 'queue.manual-run', projectId, undefined, undefined, { autonomousMode: this.config.autonomousMode }); + await this.orchestrator.pump(projectId); + } + + bindAgentOffice(ctx: Parameters[0]): void { + this.agentOffice.bind(ctx); + } + + shutdown(): void { + this.telegram.stop(); + this.orchestrator.stop(); + this.db.close(); + } + + createProject(brief: ProjectBrief): ProjectRecord { + const project = this.repo.createProject(brief); + this.repo.createRevision(project.id, 'brief', hashText(json(brief)), { brief, source: 'owner-input' }); + return this.repo.getProject(project.id) ?? project; + } + + createTask(actor: 'owner' | 'orchestrator' | RoleId, input: Omit): TaskContract { + assertTaskAuthority(actor, 'create'); + const contract = { ...input, taskId: id('task') }; + this.repo.createTask(contract); + if (this.config.autonomousMode) void this.orchestrator.pump(); + return contract; + } + + createBookWorkflow(projectId: string): TaskContract[] { + const project = this.repo.getProject(projectId); + if (!project) throw new Error('Project not found'); + if (project.stage === 'idea') this.repo.updateProjectStage(projectId, 'research'); + const sequence: Array<{ roleId: RoleId; objective: string; scope: string }> = [ + { roleId: 'editorial-director', objective: 'Convertir la petición en brief, canon inicial y plan editorial.', scope: 'brief, decisiones y preguntas pendientes' }, + { roleId: 'researcher', objective: 'Investigar el tema, separar hechos de hipótesis y registrar fuentes.', scope: 'dossier de investigación con trazabilidad' }, + { roleId: 'rights-manager', objective: 'Registrar origen, licencia, procedencia y riesgos de derechos.', scope: 'matriz de derechos por material' }, + { roleId: 'writer', objective: 'Redactar una unidad coherente a partir del brief y las fuentes autorizadas.', scope: 'borrador editable, sin inventar evidencia' }, + { roleId: 'development-editor', objective: 'Revisar estructura, continuidad, promesa y voz.', scope: 'informe de edición y cambios propuestos' }, + { roleId: 'proofreader', objective: 'Corregir ortografía, gramática y consistencia sin alterar la intención.', scope: 'informe de corrección' }, + { roleId: 'art-director', objective: 'Proponer una ilustración sin texto tipográfico y registrar su procedencia.', scope: 'dirección visual y arte derivado' }, + { roleId: 'layout-kdp', objective: 'Preparar manuscrito, EPUB, PDF interior y renderizado de revisión.', scope: 'formatos editoriales y preflight básico' }, + { roleId: 'independent-auditor', objective: 'Auditar artefactos, hashes, derechos y formatos antes del release.', scope: 'hallazgos y candidato de release' }, + { roleId: 'kdp-publisher', objective: 'Preparar metadatos y checklist KDP en modo asistido.', scope: 'paquete y checklist; no iniciar sesión externa' }, + { roleId: 'marketing-analyst', objective: 'Crear calendario social y exportación asistida con contenido autorizado.', scope: 'piezas, horarios y métricas pendientes' }, + ]; + const tasks: TaskContract[] = []; + let dependencies: string[] = []; + for (const item of sequence) { + const task = this.createTask('orchestrator', { projectId, roleId: item.roleId, objective: item.objective, scope: item.scope, inputRevisionIds: project.activeRevisionId ? [project.activeRevisionId] : [], dependencyTaskIds: dependencies, acceptanceCriteria: ['Entregable persistido', 'Resumen de acción y evidencias', 'Bloqueadores explícitos'], tools: profileFor(item.roleId).allowedTools, timeoutSeconds: 900, maxAttempts: 2, resultDestination: profileFor(item.roleId).fileScope[0] ?? 'reports' }); + tasks.push(task); + dependencies = [task.taskId]; + } + this.repo.audit('owner', 'workflow.create', projectId, undefined, undefined, { taskIds: tasks.map((task) => task.taskId), roles: tasks.map((task) => task.roleId) }); + return tasks; + } + + createReleaseCandidate(projectId: string, artifacts?: ArtifactRef[], metadata: Record = {}, auditStatus: ReleaseCandidate['auditStatus'] = 'not_run'): ReleaseCandidate { + const project = this.repo.getProject(projectId); + if (!project) throw new Error('Project not found'); + const selected = artifacts ?? this.repo.listArtifacts(projectId).filter((artifact) => ['manuscript_source', 'epub', 'pdf_interior', 'cover_digital', 'cover_print', 'metadata', 'rights_report', 'audit_report'].includes(artifact.type)); + return createReleaseCandidate(this.repo, project, selected, metadata, auditStatus); + } + + auditReleaseCandidate(releaseId: string) { + const candidate = this.repo.getRelease(releaseId); + if (!candidate) throw new Error('Release candidate not found'); + const project = this.repo.getProject(candidate.projectId); + if (!project) throw new Error('Project not found'); + return this.audit.auditCandidate(project, candidate); + } + + prepareKdp(projectId: string, releaseId?: string) { + const project = this.repo.getProject(projectId); + if (!project) throw new Error('Project not found'); + return this.kdp.prepare(project, releaseId ? this.repo.getRelease(releaseId) : undefined); + } + + requestReleaseApproval(releaseId: string, ownerId = this.config.ownerId): { record: ApprovalRecord; token: string } { + const candidate = this.repo.getRelease(releaseId); + if (!candidate) throw new Error('Release candidate not found'); + if (!ownerId) throw new Error('TELEGRAM_OWNER_ID is required for an approval'); + return requestApproval(this.repo, 'owner', { projectId: candidate.projectId, action: 'publish', subjectId: candidate.id, subject: candidate, ownerId }); + } + + approveApproval(approvalId: string, ownerId = this.config.ownerId): ApprovalRecord { + if (!ownerId) throw new Error('Owner identity is not configured'); + return approveApproval(this.repo, 'owner', approvalId, ownerId); + } + + rejectApproval(approvalId: string, ownerId = this.config.ownerId): ApprovalRecord { + if (!ownerId) throw new Error('Owner identity is not configured'); + return rejectApproval(this.repo, 'owner', approvalId, ownerId); + } + + prepareApprovedRelease(releaseId: string, ownerId = this.config.ownerId): ReleaseCandidate { + const candidate = this.repo.getRelease(releaseId); + if (!candidate) throw new Error('Release candidate not found'); + const approval = this.repo.listApprovals(candidate.projectId).find((item) => item.action === 'publish' && item.subjectId === releaseId && item.ownerId === ownerId && ['pending', 'approved'].includes(item.state)); + if (!approval) throw new Error('No hay una aprobación de propietario para este candidato'); + assertReleaseIsPublishable(this.repo, candidate, approval); + consumeApprovedApproval(this.repo, 'owner', approval, candidate, ownerId ?? ''); + this.repo.updateReleaseState(candidate.id, 'assisted'); + this.repo.audit('owner', 'release.assisted-preparation', candidate.projectId, undefined, candidate.manifestHash, { releaseId: candidate.id, externalPublication: false }); + return this.repo.getRelease(candidate.id) ?? { ...candidate, state: 'assisted' }; + } + + ingestTelegramDocument(projectId: string, fileName: string, bytes: Uint8Array, mimeType?: string): ArtifactRef { + const project = this.repo.getProject(projectId); + if (!project) throw new Error('Project not found'); + if (bytes.byteLength > 20 * 1024 * 1024) throw new Error('Telegram documents above 20 MB are not accepted'); + const extension = fileName.split('.').pop()?.toLowerCase() || 'bin'; + const stored = this.storage.write(projectId, 'sources', extension, bytes); + const artifact: ArtifactRef = { id: id('artifact'), type: 'source_upload', projectId, path: stored.relativePath, sha256: stored.sha256, sizeBytes: stored.sizeBytes, metadata: { fileName, mimeType, source: 'telegram', receivedAt: now(), aiStatus: 'not-generated' } }; + this.repo.recordArtifact(artifact); + this.repo.recordSource({ id: id('source'), projectId, identifier: artifact.id, title: fileName, observedAt: now(), notes: mimeType ? `Telegram upload (${mimeType})` : 'Telegram upload', claims: [] }); + this.repo.audit('owner', 'telegram.document.ingest', projectId, undefined, artifact.sha256, { artifactId: artifact.id, fileName, sizeBytes: bytes.byteLength }); + return artifact; + } + + backup(destination: string): ReturnType { + this.db.checkpoint(); + return backupEditorial(this.config.dataDir, destination, false); + } + + snapshot(): DashboardSnapshot { + const usage = this.repo.usageTotals(); + return { + settings: { brandName: 'Editorial JeyTid', interfaceLanguage: 'es', editorialLanguage: 'es-419', timezone: this.config.timezone, ownerId: this.config.ownerId, authorizedChatIds: this.config.authorizedChatIds, markets: [], paidAdvertising: this.config.paidAdvertising, maxConcurrentGenerations: this.config.maxConcurrentGenerations, budgetUsd: this.config.budgetUsd, warningThresholdUsd: this.config.warningThresholdUsd, provider: this.provider.id, textModel: this.config.textModel, imageModel: this.config.imageModel, autonomousMode: this.config.autonomousMode }, + projects: this.repo.listProjects(), + tasks: this.repo.listTasks().map(({ contract, state, attempts, updatedAt }) => ({ ...contract, state, attempts, updatedAt })), + revisions: this.repo.listRevisions(), + artifacts: this.repo.listAllArtifacts(), + sources: this.repo.listSources(), + rights: this.repo.listRights(), + runs: this.repo.listRuns(), + approvals: this.repo.listApprovals(), + releases: this.repo.listReleases(), + findings: this.repo.listFindings(), + socialAccounts: this.repo.listSocialAccounts(), + socialPosts: this.repo.listSocialPosts(), + capabilities: this.repo.capabilities().map((row) => row.capability === 'text-generation' && this.provider.id === 'openai' && this.provider.configured ? { ...row, state: 'tested_local' as const, limitation: undefined } : row), + connectors: { telegram: { configured: !!this.config.botToken && !!this.config.ownerId, capabilities: ['commands', 'callbacks', 'documents'] }, text: { configured: this.provider.configured, capabilities: ['generation'] }, image: { configured: this.provider.configured, capabilities: ['generation'] }, mastodon: { configured: this.marketing.mastodon.configured, capabilities: ['calendar', 'assisted-export', ...(this.marketing.mastodon.configured ? ['publish-status'] : [])], limitation: this.marketing.mastodon.configured ? 'Publishing still requires an approved post.' : 'Configure MASTODON_BASE_URL and MASTODON_ACCESS_TOKEN for the optional real connector.' }, kdp: { configured: false, capabilities: ['prepare', 'assisted'], limitation: 'No automatic publication is claimed.' } }, + costs: { estimatedUsd: usage.estimatedUsd, observedUsd: usage.observedUsd, budgetUsd: this.config.budgetUsd }, + }; + } + + private async execute(context: ExecutionContext): Promise { + const profile = profileFor(context.task.roleId); + const roleConfig = this.config.roleProviders[context.task.roleId]; + const provider = roleConfig?.provider ? providerFromEnvironment({ ...process.env, ...this.providerEnvironment(), EDITORIAL_PROVIDER: roleConfig.provider }) : this.provider; + const model = roleConfig?.model || (context.task.roleId === 'art-director' ? this.config.imageModel : this.config.textModel); + this.repo.updateRunMetadata(context.runId, provider.id, model, profile.version); + const prompt = `${profile.systemPrompt}\n\nObjetivo: ${context.task.objective}\nAlcance: ${context.task.scope}\nCriterios: ${context.task.acceptanceCriteria.join('; ')}\n\nNo almacenes razonamiento privado; devuelve solo el resultado y evidencias verificables.`; + context.checkpoint({ phase: 'started', roleId: context.task.roleId, at: now() }); + if (this.config.agentOfficeFloor) { + const dispatch = this.agentOffice.dispatch(context.task, prompt); + this.repo.audit('orchestrator', 'agent-office.queue.result', context.task.projectId, context.task.taskId, undefined, dispatch); + } + if (context.task.roleId === 'art-director') { + const image = await provider.generateImage({ prompt, model, size: '1024x1024', outputFormat: 'png' }, context.signal); + const stored = this.storage.write(context.task.projectId, 'derived', image.mimeType.includes('svg') ? 'svg' : 'png', image.bytes); + const artifact = { id: id('artifact'), type: 'illustration' as const, projectId: context.task.projectId, taskId: context.task.taskId, path: stored.relativePath, sha256: stored.sha256, sizeBytes: stored.sizeBytes, metadata: { provider: image.provider, model: image.model, aiStatus: 'generated', evidence: image.evidence } }; + this.repo.recordArtifact(artifact); + return { taskId: context.task.taskId, runId: context.runId, state: 'completed', actionSummary: 'Ilustración generada y registrada con procedencia.', provider: image.provider, model: image.model, profileVersion: profile.version, artifacts: [artifact], findings: [], blockers: [], uncertainties: [], evidence: image.evidence, usage: image.usage, nextAction: 'Revisar la ilustración y componer tipografía por separado.' }; + } + const result = await provider.generateText({ systemPrompt: profile.systemPrompt, prompt, model, maxOutputTokens: 1600 }, context.signal); + const project = this.repo.getProject(context.task.projectId); + if (!project) throw new Error('Project not found for task'); + const type = artifactTypeFor(context.task.roleId); + const artifact = this.documents.writeTextArtifact(project, type, type === 'chapter' ? 'originals' : 'reports', result.text, { provider: result.provider, model: result.model, aiStatus: 'generated', evidence: result.evidence }, context.task.taskId); + const artifacts: ArtifactRef[] = [artifact]; + if (context.task.roleId === 'layout-kdp') { + const source = result.text; + artifacts.push(this.documents.exportEpub(project, project.title, project.brief.authorName, source, context.task.taskId)); + artifacts.push(this.documents.exportPdf(project, project.title, source, context.task.taskId)); + artifacts.push(this.documents.writeRendering(project, project.title, source, context.task.taskId)); + } + if (context.task.roleId === 'independent-auditor') { + const candidate = this.createReleaseCandidate(project.id, undefined, { workflowRunId: context.runId }, 'not_run'); + const audit = this.audit.auditCandidate(project, candidate); + artifacts.push(audit.report); + } + if (context.task.roleId === 'kdp-publisher') { + const release = this.repo.listReleases(project.id)[0]; + const preparation = this.kdp.prepare(project, release); + artifacts.push(preparation.metadata, preparation.checklist); + } + if (context.task.roleId === 'marketing-analyst') { + const post = this.marketing.createPost(project, result.text.slice(0, 500)); + artifacts.push(this.marketing.exportCalendar(project)); + this.repo.audit('marketing-analyst', 'social.calendar.created', project.id, context.task.taskId, post.contentHash, { postId: post.id }); + } + return { taskId: context.task.taskId, runId: context.runId, state: 'completed', actionSummary: `${profile.displayName} produjo entregables trazables.`, provider: result.provider, model: result.model, profileVersion: profile.version, artifacts, findings: [], blockers: [], uncertainties: [], evidence: result.evidence, usage: result.usage, nextAction: 'Validar el entregable antes de desbloquear dependencias.' }; + } + + private providerEnvironment(): NodeJS.ProcessEnv { + return { EDITORIAL_PROVIDER: this.config.provider }; + } +} + +function artifactTypeFor(role: RoleId): 'brief' | 'research_dossier' | 'chapter' | 'edit_report' | 'proof_report' | 'rights_report' | 'cover_digital' | 'audit_report' | 'kdp_checklist' | 'social_piece' | 'metadata' { + switch (role) { + case 'researcher': return 'research_dossier'; + case 'writer': return 'chapter'; + case 'development-editor': return 'edit_report'; + case 'proofreader': return 'proof_report'; + case 'rights-manager': return 'rights_report'; + case 'independent-auditor': return 'audit_report'; + case 'kdp-publisher': return 'kdp_checklist'; + case 'marketing-analyst': return 'social_piece'; + case 'editorial-director': return 'brief'; + default: return 'metadata'; + } +} + +export function seededBrief(title: string, extra: Partial = {}): ProjectBrief { + return { provisionalTitle: title, collaborators: [], language: 'es', locale: 'es-419', desiredFormats: ['epub', 'pdf'], ...extra }; +} + +export { AGENT_PROFILES }; diff --git a/src/server/editorial/social.ts b/src/server/editorial/social.ts new file mode 100644 index 000000000..b6ed28903 --- /dev/null +++ b/src/server/editorial/social.ts @@ -0,0 +1,84 @@ +import { hashText, id, json, now } from './ids.js'; +import type { EditorialDocuments } from './documents.js'; +import type { EditorialRepository } from './repository.js'; +import type { ProjectRecord, SocialAccountRecord, SocialPostRecord } from './types.js'; + +export interface SocialPublishResult { + remoteId: string; + remoteUrl?: string; + evidence: { kind: 'service'; ref: string; observedAt: string; note?: string }; +} + +export interface SocialConnector { + readonly platform: string; + readonly configured: boolean; + publish(content: string, scheduledAt?: string): Promise; +} + +/** + * Optional Mastodon connector. It follows the official `POST /api/v1/statuses` contract and only + * marks a post published after the instance returns a remote id. Ref: + * https://docs.joinmastodon.org/methods/statuses/ + */ +export class MastodonConnector implements SocialConnector { + readonly platform = 'mastodon'; + readonly configured: boolean; + private readonly baseUrl?: string; + + constructor(baseUrl?: string, private readonly accessToken?: string, private readonly fetchImpl: typeof fetch = fetch) { + this.baseUrl = baseUrl?.replace(/\/$/, ''); + this.configured = !!this.baseUrl && !!accessToken; + } + + async publish(content: string, scheduledAt?: string): Promise { + if (!this.configured || !this.baseUrl || !this.accessToken) throw new Error('Mastodon connector is not configured'); + const body = { status: content, ...(scheduledAt ? { scheduled_at: scheduledAt } : {}) }; + const response = await this.fetchImpl(`${this.baseUrl}/api/v1/statuses`, { method: 'POST', headers: { authorization: `Bearer ${this.accessToken}`, 'content-type': 'application/json' }, body: JSON.stringify(body) }); + const payload = await response.json().catch(() => undefined) as Record | undefined; + if (!response.ok || !payload || typeof payload.id !== 'string') throw new Error(`Mastodon publish failed (${response.status})`); + return { remoteId: payload.id, remoteUrl: typeof payload.url === 'string' ? payload.url : undefined, evidence: { kind: 'service', ref: `${this.baseUrl}/api/v1/statuses`, observedAt: now(), note: 'Remote response contained a status id.' } }; + } +} + +export class EditorialMarketing { + readonly mastodon: MastodonConnector; + + constructor(private readonly repo: EditorialRepository, private readonly documents: EditorialDocuments, baseUrl?: string, token?: string, fetchImpl?: typeof fetch) { + this.mastodon = new MastodonConnector(baseUrl, token, fetchImpl); + if (this.mastodon.configured) this.repo.upsertSocialAccount({ id: 'mastodon-default', platform: 'mastodon', accountLabel: 'Mastodon configurado', tokenRef: 'MASTODON_ACCESS_TOKEN', capabilities: ['publish-status'], status: 'configured', metadata: { baseUrl }, updatedAt: now() }); + } + + createPost(project: ProjectRecord, content: string, scheduledAt?: string, accountId?: string): SocialPostRecord { + const post: SocialPostRecord = { id: id('social_post'), projectId: project.id, accountId, contentHash: hashText(content), content: { text: content, approved: false }, scheduledAt, timezone: 'UTC', state: scheduledAt ? 'scheduled' : 'draft', updatedAt: now() }; + this.repo.createSocialPost(post); + this.repo.audit('owner', 'social.post.create', project.id, undefined, post.contentHash, { postId: post.id, accountId, scheduledAt }); + return post; + } + + approvePost(postId: string): boolean { + const changed = this.repo.updateSocialPost(postId, { state: 'approved' }); + if (changed) this.repo.audit('owner', 'social.post.approve', undefined, undefined, undefined, { postId }); + return changed; + } + + async publishPost(postId: string): Promise { + const post = this.repo.listSocialPosts().find((candidate) => candidate.id === postId); + if (!post) throw new Error('Social post not found'); + if (post.state !== 'approved') throw new Error('Social post needs owner approval before publishing'); + if (post.accountId && post.accountId !== 'mastodon-default') throw new Error('No connector is registered for this social account'); + const result = await this.mastodon.publish(String(post.content.text ?? ''), post.scheduledAt); + this.repo.updateSocialPost(post.id, { state: post.scheduledAt ? 'scheduled_remote' : 'published', remoteId: result.remoteId, remoteUrl: result.remoteUrl }); + this.repo.audit('owner', 'social.post.remote-publish', post.projectId, undefined, post.contentHash, { postId, platform: 'mastodon', remoteId: result.remoteId }); + return this.repo.listSocialPosts(post.projectId).find((candidate) => candidate.id === postId) ?? post; + } + + exportCalendar(project: ProjectRecord): ReturnType { + const posts = this.repo.listSocialPosts(project.id); + const text = ['# Calendario social', '', ...posts.map((post) => `- ${post.scheduledAt || 'sin fecha'} · ${post.state} · ${String(post.content.text ?? '').replaceAll('\n', ' ')}`)].join('\n'); + return this.documents.writeTextArtifact(project, 'social_piece', 'social', text, { format: 'calendar', postIds: posts.map((post) => post.id), connector: this.mastodon.configured ? 'mastodon-configured' : 'assisted-export' }); + } +} + +export function socialContentHash(content: Record): string { + return hashText(json(content)); +} diff --git a/src/server/editorial/state.ts b/src/server/editorial/state.ts new file mode 100644 index 000000000..69f136756 --- /dev/null +++ b/src/server/editorial/state.ts @@ -0,0 +1,71 @@ +import type { ApprovalAction, ApprovalState, ProjectStage, RoleId, TaskState } from './types.js'; + +const projectTransitions: Record = { + idea: ['research', 'brief', 'paused', 'blocked', 'archived'], + research: ['brief', 'paused', 'blocked', 'archived'], + brief: ['concept_approval', 'research', 'paused', 'blocked', 'archived'], + concept_approval: ['planning', 'brief', 'paused', 'blocked', 'archived'], + planning: ['writing', 'editing', 'paused', 'blocked', 'archived'], + writing: ['editing', 'verification', 'paused', 'blocked', 'archived'], + editing: ['writing', 'verification', 'paused', 'blocked', 'archived'], + verification: ['rights', 'art', 'layout', 'editing', 'paused', 'blocked', 'archived'], + rights: ['art', 'layout', 'candidate', 'verification', 'paused', 'blocked', 'archived'], + art: ['layout', 'candidate', 'rights', 'paused', 'blocked', 'archived'], + layout: ['candidate', 'art', 'verification', 'paused', 'blocked', 'archived'], + candidate: ['audit', 'layout', 'rights', 'paused', 'blocked', 'archived'], + audit: ['kdp_preparation', 'candidate', 'paused', 'blocked', 'archived'], + kdp_preparation: ['marketing', 'published', 'audit', 'paused', 'blocked', 'archived'], + marketing: ['published', 'kdp_preparation', 'paused', 'blocked', 'archived'], + published: ['marketing', 'archived'], + paused: ['idea', 'research', 'brief', 'concept_approval', 'planning', 'writing', 'editing', 'verification', 'rights', 'art', 'layout', 'candidate', 'audit', 'kdp_preparation', 'marketing', 'published', 'blocked', 'archived'], + blocked: ['idea', 'research', 'brief', 'concept_approval', 'planning', 'writing', 'editing', 'verification', 'rights', 'art', 'layout', 'candidate', 'audit', 'kdp_preparation', 'marketing', 'paused', 'archived'], + archived: [], +}; + +const taskTransitions: Record = { + pending: ['blocked', 'ready', 'cancelled'], + blocked: ['pending', 'ready', 'cancelled'], + ready: ['running', 'blocked', 'cancelled'], + running: ['waiting_user', 'completed', 'failed', 'cancelled', 'uncertain'], + waiting_user: ['ready', 'running', 'cancelled'], + completed: ['ready'], + failed: ['ready', 'cancelled'], + cancelled: [], + uncertain: ['ready', 'cancelled'], +}; + +export function canTransitionProject(from: ProjectStage, to: ProjectStage): boolean { + return from === to || projectTransitions[from].includes(to); +} + +export function canTransitionTask(from: TaskState, to: TaskState): boolean { + return from === to || taskTransitions[from].includes(to); +} + +export function assertProjectTransition(from: ProjectStage, to: ProjectStage): void { + if (!canTransitionProject(from, to)) throw new Error(`Invalid project transition: ${from} -> ${to}`); +} + +export function assertTaskTransition(from: TaskState, to: TaskState): void { + if (!canTransitionTask(from, to)) throw new Error(`Invalid task transition: ${from} -> ${to}`); +} + +export function roleCanCreateTask(actor: 'owner' | 'orchestrator' | RoleId): boolean { + return actor === 'owner' || actor === 'orchestrator' || actor === 'editorial-director'; +} + +export function roleCanAssignTask(actor: 'owner' | 'orchestrator' | RoleId): boolean { + return actor === 'owner' || actor === 'orchestrator' || actor === 'editorial-director'; +} + +export function roleCanApprove(actor: 'owner' | 'orchestrator' | RoleId, action: ApprovalAction): boolean { + return actor === 'owner' && (action === 'concept' || action === 'scope' || action === 'release' || action === 'publish' || action === 'social_batch' || action === 'external_expense' || action === 'legal_filing'); +} + +export function roleCanPublish(actor: 'owner' | 'orchestrator' | RoleId): boolean { + return actor === 'owner'; +} + +export function canUseApproval(state: ApprovalState, expiresAt: string, now = new Date()): boolean { + return (state === 'pending' || state === 'approved') && Date.parse(expiresAt) > now.getTime(); +} diff --git a/src/server/editorial/storage.ts b/src/server/editorial/storage.ts new file mode 100644 index 000000000..5bcbc9b26 --- /dev/null +++ b/src/server/editorial/storage.ts @@ -0,0 +1,49 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; + +const AREAS = new Set(['originals', 'sources', 'derived', 'renderings', 'packages', 'reports', 'social']); + +export interface StoredFile { + path: string; + relativePath: string; + sha256: string; + sizeBytes: number; +} + +export class EditorialStorage { + constructor(readonly root: string) { + mkdirSync(root, { recursive: true, mode: 0o700 }); + } + + projectRoot(projectId: string, editionId?: string): string { + const ids = [projectId, editionId].filter(Boolean) as string[]; + if (ids.some((id) => !/^[a-zA-Z0-9_-]+$/.test(id))) throw new Error('Unsafe storage identifier'); + const target = path.resolve(this.root, 'projects', ...ids); + if (!target.startsWith(path.resolve(this.root) + path.sep)) throw new Error('Storage path escaped root'); + mkdirSync(target, { recursive: true, mode: 0o700 }); + return target; + } + + write(projectId: string, area: string, extension: string, data: string | Uint8Array, editionId?: string): StoredFile { + if (!AREAS.has(area)) throw new Error(`Unknown artifact area: ${area}`); + const safeExtension = extension.replace(/[^a-zA-Z0-9]/g, '').slice(0, 12) || 'bin'; + const base = this.projectRoot(projectId, editionId); + const dir = path.join(base, area); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + const relativePath = path.relative(this.root, path.join(dir, `${randomUUID()}.${safeExtension}`)); + const filePath = path.resolve(this.root, relativePath); + if (!filePath.startsWith(path.resolve(this.root) + path.sep)) throw new Error('Storage path escaped root'); + const bytes = typeof data === 'string' ? Buffer.from(data, 'utf8') : Buffer.from(data); + const temp = `${filePath}.${randomUUID()}.tmp`; + writeFileSync(temp, bytes, { mode: 0o600 }); + renameSync(temp, filePath); + return { path: filePath, relativePath, sha256: createHash('sha256').update(bytes).digest('hex'), sizeBytes: bytes.byteLength }; + } + + read(relativePath: string): Buffer { + const filePath = path.resolve(this.root, relativePath); + if (!filePath.startsWith(path.resolve(this.root) + path.sep)) throw new Error('Artifact path escaped root'); + return readFileSync(filePath); + } +} diff --git a/src/server/editorial/telegram.ts b/src/server/editorial/telegram.ts new file mode 100644 index 000000000..3a9257d68 --- /dev/null +++ b/src/server/editorial/telegram.ts @@ -0,0 +1,262 @@ +import type { EditorialRuntime } from './runtime.js'; +import { seededBrief } from './runtime.js'; +import { profileFor } from './profiles.js'; +import type { ApprovalAction, ProjectRecord, RoleId } from './types.js'; + +interface TelegramUser { id: number; first_name?: string; username?: string } +interface TelegramChat { id: number } +interface TelegramMessage { message_id: number; from?: TelegramUser; chat: TelegramChat; text?: string; caption?: string; document?: { file_id: string; file_name?: string; mime_type?: string; file_size?: number }; } +interface TelegramCallback { id: string; from: TelegramUser; data?: string; message?: TelegramMessage } +interface TelegramUpdate { update_id: number; message?: TelegramMessage; callback_query?: TelegramCallback } +interface TelegramResponse { ok: boolean; result: T; description?: string } +interface TelegramFile { file_path?: string; file_size?: number } + +export class TelegramBot { + private stopped = false; + private running = false; + private offset?: number; + + constructor(private readonly runtime: EditorialRuntime, private readonly token?: string, private readonly ownerId?: string, private readonly allowedChatIds: string[] = []) {} + + get configured(): boolean { + return !!this.token && !!this.ownerId; + } + + start(): void { + if (!this.configured || this.running) return; + this.stopped = false; + this.running = true; + void this.poll(); + } + + stop(): void { + this.stopped = true; + this.running = false; + } + + async notify(text: string, chatId = this.ownerId): Promise { + if (!this.configured || !chatId) return; + await this.sendText(chatId, text); + } + + private async poll(): Promise { + while (!this.stopped) { + try { + const updates = await this.call('getUpdates', { ...(this.offset === undefined ? {} : { offset: this.offset }), timeout: 25, limit: 50, allowed_updates: ['message', 'callback_query'] }); + for (const update of updates) { + this.offset = update.update_id + 1; + if (!this.runtime.repo.rememberTelegramUpdate(update.update_id)) continue; + await this.handle(update); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Telegram polling failed'; + this.runtime.repo.audit('telegram', 'poll.error', undefined, undefined, undefined, { message: redact(message) }); + await wait(2000); + } + } + } + + private async handle(update: TelegramUpdate): Promise { + if (update.callback_query) return this.handleCallback(update.callback_query); + const message = update.message; + if (!message?.from) return; + if (!this.authorized(message.from.id, message.chat.id)) { + await this.sendText(String(message.chat.id), 'No autorizado. El bot solo acepta el identificador numérico configurado del propietario.'); + this.runtime.repo.audit('telegram', 'message.denied', undefined, undefined, undefined, { userId: String(message.from.id), chatId: String(message.chat.id) }); + return; + } + if (message.document) return this.document(String(message.chat.id), message); + if (message.text) await this.command(String(message.chat.id), message.text.trim()); + } + + private async command(chatId: string, text: string): Promise { + const [head, ...rest] = text.split(/\s+/); + const command = head.toLowerCase().split('@')[0]; + const arg = rest.join(' ').trim(); + if (command === '/start' || command === '/ayuda') return this.sendText(chatId, helpText()); + if (command === '/configurar') return this.sendText(chatId, `Configuración editorial\nZona horaria: ${this.runtime.config.timezone}\nProveedor: ${this.runtime.provider.id} (${this.runtime.provider.configured ? 'configurado' : 'sin credencial'})\nTelegram: ${this.configured ? 'configurado' : 'sin credencial'}\nModo de ejecución: ${this.runtime.config.autonomousMode ? 'autónomo' : 'supervisado'}\nMáximo simultáneo: ${this.runtime.config.maxConcurrentGenerations}`); + if (command === '/modo') return this.mode(chatId, arg); + if (command === '/ejecutar') return this.runPending(chatId, arg); + if (command === '/proyectos') return this.sendText(chatId, formatProjects(this.runtime.snapshot().projects)); + if (command === '/agentes') return this.sendText(chatId, Object.values(runtimeSnapshotProfiles()).map((profile) => `${profile.displayName} — ${profile.id}`).join('\n')); + if (command === '/estado') return this.sendText(chatId, formatProjectStatus(this.runtime, arg)); + if (command === '/tareas') return this.sendText(chatId, formatTasks(this.runtime, arg)); + if (command === '/costos') return this.sendText(chatId, formatCosts(this.runtime)); + if (command === '/nuevo_libro') return this.newBook(chatId, arg || 'Libro sin título'); + if (command === '/ideas') return this.newIdeasTask(chatId, arg); + if (command === '/pausar') return this.pause(chatId, arg); + if (command === '/continuar') return this.resume(chatId, arg); + if (command === '/cancelar') return this.cancel(chatId, arg); + if (command === '/aprobaciones') return this.approvals(chatId); + if (command === '/publicar') return this.publish(chatId, arg); + if (command === '/redes' || command === '/calendario') return this.sendText(chatId, 'El calendario social se prepara localmente y requiere conectar una cuenta antes de publicar.'); + if (command === '/respaldos') return this.sendText(chatId, 'El respaldo se ejecuta desde el diagnóstico/CLI para evitar enviar SQLite o archivos privados por Telegram.'); + if (text.startsWith('/')) return this.sendText(chatId, 'Comando no reconocido. Usa /ayuda.'); + return this.newBook(chatId, text); + } + + private async newBook(chatId: string, title: string): Promise { + const project = this.runtime.createProject(seededBrief(title)); + const tasks = this.runtime.createBookWorkflow(project.id); + await this.sendText(chatId, `Proyecto creado: ${project.title}\nID: ${project.id}\nEstado: ${this.runtime.repo.getProject(project.id)?.stage}\nFlujo creado: ${tasks.length} tareas encadenadas.`); + } + + private async newIdeasTask(chatId: string, hint: string): Promise { + const project = this.runtime.createProject(seededBrief(hint || 'Propuestas editoriales', { objective: hint || 'Proponer varias ideas comparables para un nuevo libro.' })); + this.runtime.createTask('owner', { projectId: project.id, roleId: 'researcher', objective: 'Proponer varias ideas de libro comparables y registrar fuentes relevantes.', scope: hint || 'Sin restricción adicional', inputRevisionIds: [], dependencyTaskIds: [], acceptanceCriteria: ['Tres propuestas comparables', 'Hechos e hipótesis diferenciados', 'Fuentes registradas o pendientes explícitos'], tools: ['web.read', 'source.record', 'artifact.write'], timeoutSeconds: 1200, maxAttempts: 2, resultDestination: 'research' }); + await this.sendText(chatId, `Tarea de ideas creada en ${project.id}. Consulta /estado ${project.id}.`); + } + + private async pause(chatId: string, projectId: string): Promise { + const project = this.runtime.repo.getProject(projectId); + if (!project) return this.sendText(chatId, 'Indica un project_id válido.'); + this.runtime.repo.updateProjectStage(project.id, 'paused', true); + await this.sendText(chatId, `Proyecto pausado: ${project.id}. Las respuestas tardías no sustituyen revisiones canceladas.`); + } + + private async resume(chatId: string, projectId: string): Promise { + const project = this.runtime.repo.getProject(projectId); + if (!project) return this.sendText(chatId, 'Indica un project_id válido.'); + this.runtime.repo.updateProjectStage(project.id, 'idea', false); + await this.sendText(chatId, `Proyecto reanudado desde una etapa segura: ${project.id}. Revisa el plan antes de continuar.`); + } + + private async cancel(chatId: string, taskId: string): Promise { + if (!taskId || !this.runtime.orchestrator.cancel(taskId)) return this.sendText(chatId, 'No se pudo cancelar esa tarea o ya no está activa.'); + await this.sendText(chatId, `Tarea cancelada: ${taskId}.`); + } + + private async runPending(chatId: string, projectId: string): Promise { + if (projectId && !this.runtime.repo.getProject(projectId)) return this.sendText(chatId, 'Indica un project_id válido o deja el comando sin argumento para ejecutar toda la cola.'); + await this.runtime.runPending(projectId || undefined); + await this.sendText(chatId, projectId ? `Ejecución iniciada para ${projectId}. Consulta /tareas ${projectId}.` : 'Ejecución iniciada para la cola editorial. Consulta /tareas o /proyectos.'); + } + + private async mode(chatId: string, requested: string): Promise { + const normalized = requested.toLowerCase(); + if (!requested) return this.sendText(chatId, formatMode(this.runtime)); + if (['autonomo', 'autónomo', 'auto'].includes(normalized)) this.runtime.setAutonomousMode(true); + else if (['supervisado', 'manual'].includes(normalized)) this.runtime.setAutonomousMode(false); + else return this.sendText(chatId, 'Usa /modo autonomo, /modo supervisado o /modo para consultar el estado.'); + await this.sendText(chatId, formatMode(this.runtime)); + } + + private async approvals(chatId: string): Promise { + const approvals = this.runtime.repo.listApprovals().filter((approval) => ['pending', 'approved'].includes(approval.state)); + if (!approvals.length) return this.sendText(chatId, 'No hay aprobaciones pendientes.'); + for (const approval of approvals) await this.sendText(chatId, `${approval.action} · ${approval.state} · ${approval.subjectId}\nVence: ${approval.expiresAt}`, approval.state === 'pending' ? { inline_keyboard: [[{ text: 'Aprobar', callback_data: `a:${approval.id}` }, { text: 'Rechazar', callback_data: `r:${approval.id}` }]] } : undefined); + } + + private async handleCallback(callback: TelegramCallback): Promise { + const chatId = callback.message?.chat.id; + const [action, approvalId] = callback.data?.split(':', 2) ?? []; + if (!chatId || !this.authorized(callback.from.id, chatId) || !approvalId || !['a', 'r'].includes(action)) return this.answerCallback(callback.id, 'No autorizado'); + try { + const approval = action === 'a' ? this.runtime.approveApproval(approvalId) : this.runtime.rejectApproval(approvalId); + await this.answerCallback(callback.id, action === 'a' ? 'Aprobación registrada' : 'Aprobación rechazada'); + await this.sendText(String(chatId), `${action === 'a' ? 'Aprobada' : 'Rechazada'}: ${approval.action} · ${approval.subjectId}\nLa publicación externa no se ejecuta automáticamente; usa /publicar con el release aprobado.`); + } catch (error) { + await this.answerCallback(callback.id, error instanceof Error ? error.message.slice(0, 180) : 'Aprobación inválida'); + } + } + + private async publish(chatId: string, releaseId: string): Promise { + if (!releaseId) return this.sendText(chatId, 'Indica el release_id auditado y aprobado.'); + try { + const release = this.runtime.prepareApprovedRelease(releaseId); + await this.sendText(chatId, `Paquete preparado para asistencia: ${release.id}. No se inició publicación externa.`); + } catch (error) { + await this.sendText(chatId, `No se puede preparar el release: ${error instanceof Error ? error.message : 'error desconocido'}`); + } + } + + private async document(chatId: string, message: TelegramMessage): Promise { + const document = message.document; + if (!document) return; + if ((document.file_size ?? 0) > 20 * 1024 * 1024) return this.sendText(chatId, 'El archivo supera el límite de 20 MB de descarga de Telegram.'); + const projectId = (message.caption ?? '').trim().split(/\s+/)[0]; + if (!projectId) return this.sendText(chatId, 'Adjunta el archivo con un caption que empiece por el project_id.'); + try { + const file = await this.call('getFile', { file_id: document.file_id }); + if (!file.file_path) throw new Error('Telegram no devolvió file_path'); + const response = await fetch(`https://api.telegram.org/file/bot${this.token}/${file.file_path}`); + if (!response.ok) throw new Error(`Descarga Telegram falló (${response.status})`); + const bytes = new Uint8Array(await response.arrayBuffer()); + const artifact = this.runtime.ingestTelegramDocument(projectId, document.file_name ?? 'upload.bin', bytes, document.mime_type); + await this.sendText(chatId, `Archivo incorporado: ${artifact.id}\nHash: ${artifact.sha256}`); + } catch (error) { + await this.sendText(chatId, `No se pudo incorporar el archivo: ${error instanceof Error ? error.message : 'error desconocido'}`); + } + } + + private authorized(userId: number, chatId: number): boolean { + return String(userId) === this.ownerId && (this.allowedChatIds.length === 0 || this.allowedChatIds.includes(String(chatId))); + } + + private async sendText(chatId: string, text: string, reply_markup?: unknown): Promise { + for (const chunk of splitMessage(text, 3800)) await this.call('sendMessage', { chat_id: chatId, text: chunk, ...(reply_markup ? { reply_markup } : {}) }); + } + + private async answerCallback(callbackQueryId: string, text: string): Promise { + await this.call('answerCallbackQuery', { callback_query_id: callbackQueryId, text }); + } + + private async call(method: string, body: Record): Promise { + if (!this.token) throw new Error('Telegram bot token is not configured'); + const response = await fetch(`https://api.telegram.org/bot${this.token}/${method}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); + const payload = await response.json() as TelegramResponse; + if (!response.ok || !payload.ok) throw new Error(`Telegram ${method} failed: ${payload.description || response.status}`); + return payload.result; + } +} + +function splitMessage(text: string, limit: number): string[] { + if (text.length <= limit) return [text]; + const chunks: string[] = []; + for (let start = 0; start < text.length; start += limit) chunks.push(text.slice(start, start + limit)); + return chunks; +} + +function helpText(): string { + return ['/start /ayuda — guía', '/configurar — conexiones y límites', '/modo [autonomo|supervisado] — consultar o cambiar el modo', '/ejecutar [project_id] — iniciar tareas pendientes', '/nuevo_libro — crear proyecto', '/ideas [contexto] — propuestas persistidas', '/proyectos — listar libros', '/estado ', '/agentes — once perfiles', '/tareas ', '/costos — presupuesto y consumo', '/aprobaciones — decisiones pendientes', '/publicar — preparar asistencia, sin publicar externamente', '/redes /calendario — marketing', '/pausar /continuar ', '/cancelar ', 'Adjuntos: caption = project_id; máximo 20 MB'].join('\n'); +} + +function formatMode(runtime: EditorialRuntime): string { + return runtime.config.autonomousMode + ? 'Modo autónomo: las tareas elegibles se ejecutan solas. Tú puedes observarlas desde /editorial o Telegram; publicación, gastos y acciones externas siguen requiriendo aprobación.' + : 'Modo supervisado: las tareas se quedan pendientes hasta que uses /ejecutar [project_id]. Las acciones sensibles siguen requiriendo aprobación.'; +} + +function formatProjects(projects: ProjectRecord[]): string { + return projects.length ? projects.map((project) => `${project.id} · ${project.title} · ${project.stage}${project.paused ? ' · pausado' : ''}`).join('\n') : 'No hay proyectos.'; +} + +function formatProjectStatus(runtime: EditorialRuntime, projectId: string): string { + const project = runtime.repo.getProject(projectId); + if (!project) return 'Indica un project_id válido.'; + const tasks = runtime.repo.listTasks(project.id); + const open = tasks.filter((task) => !['completed', 'cancelled'].includes(task.state)).length; + return `${project.title}\nID: ${project.id}\nEtapa: ${project.stage}\nTareas abiertas: ${open}/${tasks.length}\nPróximo paso: ${tasks.find((task) => task.state !== 'completed' && task.state !== 'cancelled')?.contract.objective || 'Revisar decisiones pendientes.'}`; +} + +function formatTasks(runtime: EditorialRuntime, projectId: string): string { + const tasks = runtime.repo.listTasks(projectId || undefined); + return tasks.length ? tasks.map((task) => `${task.contract.taskId} · ${task.contract.roleId} · ${task.state} · intentos ${task.attempts}`).join('\n') : 'No hay tareas.'; +} + +function formatCosts(runtime: EditorialRuntime): string { + const costs = runtime.snapshot().costs; + return `Estimado: $${costs.estimatedUsd.toFixed(4)}\nObservado: $${costs.observedUsd.toFixed(4)}\nLímite: ${costs.budgetUsd === undefined ? 'no configurado' : `$${costs.budgetUsd.toFixed(2)}`}`; +} + +function runtimeSnapshotProfiles(): Record { + return Object.fromEntries(Object.values(['editorial-director', 'researcher', 'rights-manager', 'writer', 'development-editor', 'proofreader', 'art-director', 'layout-kdp', 'independent-auditor', 'kdp-publisher', 'marketing-analyst'] as RoleId[]).map((role) => { const profile = profileFor(role); return [role, { id: profile.id, displayName: profile.displayName }]; })); +} + +function redact(value: string): string { + return value.replace(/bot\d+:[A-Za-z0-9_-]+/g, 'bot'); +} + +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/server/editorial/types.ts b/src/server/editorial/types.ts new file mode 100644 index 000000000..eb30b2e2a --- /dev/null +++ b/src/server/editorial/types.ts @@ -0,0 +1,355 @@ +export const EDITORIAL_ROLES = [ + 'editorial-director', + 'researcher', + 'rights-manager', + 'writer', + 'development-editor', + 'proofreader', + 'art-director', + 'layout-kdp', + 'independent-auditor', + 'kdp-publisher', + 'marketing-analyst', +] as const; + +export type RoleId = (typeof EDITORIAL_ROLES)[number]; + +export const PROJECT_STAGES = [ + 'idea', + 'research', + 'brief', + 'concept_approval', + 'planning', + 'writing', + 'editing', + 'verification', + 'rights', + 'art', + 'layout', + 'candidate', + 'audit', + 'kdp_preparation', + 'marketing', + 'published', + 'paused', + 'blocked', + 'archived', +] as const; + +export type ProjectStage = (typeof PROJECT_STAGES)[number]; + +export const TASK_STATES = ['pending', 'blocked', 'ready', 'running', 'waiting_user', 'completed', 'failed', 'cancelled', 'uncertain'] as const; +export type TaskState = (typeof TASK_STATES)[number]; + +export const FINDING_SEVERITIES = ['blocking', 'important', 'minor', 'suggestion'] as const; +export type FindingSeverity = (typeof FINDING_SEVERITIES)[number]; + +export const APPROVAL_ACTIONS = ['concept', 'scope', 'release', 'publish', 'social_batch', 'external_expense', 'legal_filing'] as const; +export type ApprovalAction = (typeof APPROVAL_ACTIONS)[number]; + +export const APPROVAL_STATES = ['pending', 'approved', 'rejected', 'expired', 'revoked', 'used'] as const; +export type ApprovalState = (typeof APPROVAL_STATES)[number]; + +export const RELEASE_STATES = ['draft', 'auditing', 'approved', 'needs_changes', 'prepared', 'assisted', 'submitted', 'uncertain', 'published'] as const; +export type ReleaseState = (typeof RELEASE_STATES)[number]; + +export const CAPABILITY_STATES = ['implemented', 'tested_local', 'tested_real', 'requires_config', 'requires_owner', 'limited', 'unsupported'] as const; +export type CapabilityState = (typeof CAPABILITY_STATES)[number]; + +export const ARTIFACT_TYPES = [ + 'source_upload', + 'brief', + 'research_dossier', + 'manuscript_source', + 'manuscript_docx', + 'chapter', + 'edit_report', + 'proof_report', + 'illustration', + 'cover_digital', + 'cover_print', + 'epub', + 'pdf_interior', + 'rendering', + 'metadata', + 'rights_report', + 'audit_report', + 'kdp_checklist', + 'social_piece', + 'release_package', +] as const; +export type ArtifactType = (typeof ARTIFACT_TYPES)[number]; + +export interface RevisionRecord { + id: string; + projectId: string; + kind: string; + parentId?: string; + contentHash: string; + manifest: Record; + createdAt: string; +} + +export interface SourceRecord { + id: string; + projectId: string; + url?: string; + identifier?: string; + title?: string; + observedAt: string; + notes?: string; + claims: string[]; +} + +export interface RightsRecord { + id: string; + projectId: string; + artifactId?: string; + origin: string; + creator?: string; + license?: string; + evidence?: string; + aiStatus: string; + conditions?: string; + status: string; + updatedAt: string; +} + +export interface SocialAccountRecord { + id: string; + platform: string; + accountLabel: string; + tokenRef?: string; + capabilities: string[]; + status: string; + metadata: Record; + updatedAt: string; +} + +export interface SocialPostRecord { + id: string; + projectId: string; + accountId?: string; + contentHash: string; + content: Record; + scheduledAt?: string; + timezone: string; + state: string; + remoteId?: string; + remoteUrl?: string; + lastError?: string; + updatedAt: string; +} + +export interface OwnerSettings { + brandName: string; + interfaceLanguage: string; + editorialLanguage: string; + timezone: string; + ownerId?: string; + authorizedChatIds: string[]; + markets: string[]; + residenceCountry?: string; + rightsJurisdiction?: string; + paidAdvertising: boolean; + maxConcurrentGenerations: number; + budgetUsd?: number; + warningThresholdUsd: number; + provider: string; + textModel: string; + imageModel: string; + autonomousMode: boolean; +} + +export interface ProjectBrief { + provisionalTitle: string; + authorName?: string; + collaborators: string[]; + language: string; + locale: string; + genre?: string; + subgenre?: string; + audience?: string; + objective?: string; + premise?: string; + promise?: string; + differentiation?: string; + synopsis?: string; + targetWordCount?: number; + tone?: string; + voice?: string; + pointOfView?: string; + desiredFormats: string[]; + visualPreferences?: string; + seriesId?: string; +} + +export interface ProjectRecord { + id: string; + title: string; + stage: ProjectStage; + paused: boolean; + brief: ProjectBrief; + createdAt: string; + updatedAt: string; + activeRevisionId?: string; +} + +export interface TaskContract { + taskId: string; + projectId: string; + editionId?: string; + roleId: RoleId; + objective: string; + scope: string; + inputRevisionIds: string[]; + dependencyTaskIds: string[]; + acceptanceCriteria: string[]; + tools: string[]; + budgetUsd?: number; + timeoutSeconds: number; + maxAttempts: number; + resultDestination: string; +} + +export interface ArtifactRef { + id: string; + type: ArtifactType; + projectId: string; + editionId?: string; + taskId?: string; + revisionId?: string; + path: string; + sha256: string; + sizeBytes: number; + metadata: Record; +} + +export interface TaskResult { + taskId: string; + runId: string; + state: Extract; + actionSummary: string; + provider: string; + model?: string; + profileVersion: string; + artifacts: ArtifactRef[]; + findings: Finding[]; + blockers: string[]; + uncertainties: string[]; + evidence: EvidenceRef[]; + usage: UsageSnapshot; + nextAction?: string; +} + +export interface EvidenceRef { + kind: 'url' | 'file' | 'command' | 'service'; + ref: string; + observedAt: string; + note?: string; +} + +export interface UsageSnapshot { + inputTokens?: number; + outputTokens?: number; + estimatedCostUsd?: number; + observedCostUsd?: number; + currency?: string; + source: 'provider' | 'estimate' | 'unavailable'; +} + +export interface Finding { + id?: string; + severity: FindingSeverity; + title: string; + detail: string; + location?: string; + resolution?: string; + status: 'open' | 'resolved' | 'accepted'; +} + +export interface AgentProfile { + id: RoleId; + displayName: string; + mission: string; + responsibilities: string[]; + inputs: string[]; + deliverables: string[]; + allowedTools: string[]; + fileScope: string[]; + limits: string[]; + budgetPolicy: string; + completionConditions: string[]; + escalation: string[]; + systemPrompt: string; + version: string; +} + +export interface AgentRunRecord { + runId: string; + taskId: string; + roleId: RoleId; + provider: string; + model?: string; + profileVersion: string; + officeWorkerId?: string; + officeFloorId?: string; + status: 'started' | 'completed' | 'failed' | 'cancelled' | 'uncertain'; + startedAt: string; + finishedAt?: string; + actionSummary?: string; +} + +export interface ApprovalRecord { + id: string; + projectId: string; + action: ApprovalAction; + subjectId: string; + subjectHash: string; + ownerId: string; + state: ApprovalState; + tokenHash: string; + expiresAt: string; + createdAt: string; + usedAt?: string; +} + +export interface ReleaseCandidate { + id: string; + projectId: string; + manifestHash: string; + artifactIds: string[]; + metadata: Record; + auditStatus: 'not_run' | 'approved' | 'needs_changes' | 'external_check'; + state: ReleaseState; + createdAt: string; +} + +export interface CapabilityRow { + capability: string; + component: string; + state: CapabilityState; + evidence: string[]; + dependency?: string; + limitation?: string; + checkedAt: string; +} + +export interface DashboardSnapshot { + settings: OwnerSettings; + projects: ProjectRecord[]; + tasks: Array; + revisions: RevisionRecord[]; + artifacts: ArtifactRef[]; + sources: SourceRecord[]; + rights: RightsRecord[]; + runs: AgentRunRecord[]; + approvals: ApprovalRecord[]; + releases: ReleaseCandidate[]; + findings: Finding[]; + socialAccounts: SocialAccountRecord[]; + socialPosts: SocialPostRecord[]; + capabilities: CapabilityRow[]; + connectors: Record; + costs: { estimatedUsd: number; observedUsd: number; budgetUsd?: number }; +} diff --git a/src/server/http/routes/editorial.ts b/src/server/http/routes/editorial.ts new file mode 100644 index 000000000..41ff03ef9 --- /dev/null +++ b/src/server/http/routes/editorial.ts @@ -0,0 +1,53 @@ +import { createReadStream } from 'node:fs'; +import type { Route } from '../router.js'; +import { send } from '../util.js'; +import { readBody } from '../util.js'; + +function unavailable(ctx: Parameters['handle']>[0], res: Parameters['handle']>[1]['res']) { + return send(res, 503, { error: 'Editorial subsystem is disabled. Set EDITORIAL_ENABLED=1 on Node 22.5+.' }); +} + +export const editorialRoutes = { + page: { + path: '/editorial', auth: 'session', handle(ctx, { res }) { + if (!ctx.editorial) return unavailable(ctx, res); + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'x-frame-options': 'DENY' }); + return createReadStream(`${ctx.publicDir}/editorial.html`).pipe(res); + }, + }, + api: { + prefix: '/api/editorial', auth: 'session', async handle(ctx, { req, res, path, url }) { + if (!ctx.editorial) return unavailable(ctx, res); + if (req.method === 'GET' && (path === '/api/editorial' || path === '/api/editorial/overview')) return send(res, 200, ctx.editorial.snapshot()); + if (req.method === 'GET' && path === '/api/editorial/health') return send(res, 200, { ok: true, enabled: true, provider: ctx.editorial.provider.id, configured: ctx.editorial.provider.configured }); + if (req.method === 'POST' && path === '/api/editorial/projects') { + const body = JSON.parse(await readBody(req, 32 * 1024)) as Record; + const title = typeof body.title === 'string' ? body.title.trim() : ''; + if (!title) return send(res, 400, { error: 'title is required' }); + const project = ctx.editorial.createProject({ provisionalTitle: title, authorName: typeof body.authorName === 'string' ? body.authorName : undefined, collaborators: [], language: 'es', locale: 'es-419', desiredFormats: ['epub', 'pdf'] }); + return send(res, 201, project); + } + return send(res, 404, { error: 'Editorial endpoint not found' }); + }, + }, + artifact: { + method: 'GET', path: '/api/editorial/artifact', auth: 'session', handle(ctx, { res, url }) { + if (!ctx.editorial) return unavailable(ctx, res); + const artifactId = url.searchParams.get('id'); + if (!artifactId) return send(res, 400, { error: 'id is required' }); + const artifact = ctx.editorial.snapshot().projects.flatMap((project) => ctx.editorial!.repo.listArtifacts(project.id)).find((candidate) => candidate.id === artifactId); + if (!artifact) return send(res, 404, { error: 'Artifact not found' }); + const bytes = ctx.editorial.storage.read(artifact.path); + res.writeHead(200, { 'content-type': mime(artifact.type), 'content-length': String(bytes.byteLength), 'content-disposition': `attachment; filename="${artifact.id}-${artifact.type}"`, 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' }); + return res.end(bytes); + }, + }, +} satisfies Record; + +function mime(type: string): string { + if (type === 'epub') return 'application/epub+zip'; + if (type === 'pdf_interior') return 'application/pdf'; + if (type === 'illustration' || type === 'cover_digital' || type === 'cover_print') return 'image/png'; + if (type === 'release_package') return 'application/zip'; + return 'text/plain; charset=utf-8'; +} diff --git a/src/server/http/routes/index.ts b/src/server/http/routes/index.ts index 982acdd1b..ac4c15dc6 100644 --- a/src/server/http/routes/index.ts +++ b/src/server/http/routes/index.ts @@ -9,6 +9,7 @@ import { githubRoutes } from './github.js'; import { pageRoutes } from './pages.js'; import { searchRoutes } from './search.js'; import { serviceRoutes } from './services.js'; +import { editorialRoutes } from './editorial.js'; export const routes: readonly Route[] = [ // Anyone. @@ -36,6 +37,9 @@ export const routes: readonly Route[] = [ searchRoutes.search, serviceRoutes.forwards, githubRoutes.github, + editorialRoutes.page, + editorialRoutes.artifact, + editorialRoutes.api, pageRoutes.office, pageRoutes.lite, pageRoutes.bundle, diff --git a/src/server/office/context.ts b/src/server/office/context.ts index 96090a2e0..6c68f0968 100644 --- a/src/server/office/context.ts +++ b/src/server/office/context.ts @@ -28,6 +28,7 @@ import type { Arcade, HighScores } from '../cabinet.js'; import type { AgentProvider, FloorInfo, Me, ServerMsg, ServiceInfo, ServicesState, SignInKind } from '../../shared/protocol.js'; import type { Client } from './client.js'; import type { Spot } from './input.js'; +import type { EditorialRuntime } from '../editorial/runtime.js'; export type ToastLevel = Extract['level']; @@ -52,6 +53,8 @@ export interface Core { /** The building: a floor per project, each with its own workers, boards and queue. */ building: Building; floors: Map; + /** Optional personal editorial subsystem; enabled with EDITORIAL_ENABLED=1. */ + editorial?: EditorialRuntime; } /** Made once the hook server listens, before any floor opens (office/services.ts). */ diff --git a/src/server/office/core.ts b/src/server/office/core.ts index 47ec9f80c..542c9503d 100644 --- a/src/server/office/core.ts +++ b/src/server/office/core.ts @@ -15,7 +15,7 @@ import type { Core, Ctx } from './context.js'; import type { Client } from './client.js'; /** The first of the office: accounts and sign-in, the people in it, chat, the arcade, and the building's floors. */ -export function createCore(ctx: Ctx, cfg: Config, publicDir: string): Core { +export async function createCore(ctx: Ctx, cfg: Config, publicDir: string): Promise { const accounts = new Accounts(cfg.dataDir); const auth = new Auth(cfg.verifier, cfg.salt, cfg.secret, accounts); const clients = new Map(); @@ -44,5 +44,13 @@ export function createCore(ctx: Ctx, cfg: Config, publicDir: string): Core { if (err) console.error(`agent-office: --projects: ${err}`); } const floors = new Map(); - return { cfg, publicDir, accounts, auth, clients, chat, highScores, arcade, officeName, models, building, floors }; + // Keep the base office compatible with Node 20: node:sqlite is loaded only when the optional + // editorial subsystem is explicitly enabled. Editorial itself documents Node 22.5+. + let editorial: Core['editorial']; + if (process.env.EDITORIAL_ENABLED === '1') { + const [major, minor] = process.versions.node.split('.').map(Number); + if (major < 22 || (major === 22 && minor < 5)) throw new Error('Editorial requires Node 22.5+ because it uses node:sqlite; leave EDITORIAL_ENABLED unset for the Node 20 base office.'); + editorial = new (await import('../editorial/runtime.js')).EditorialRuntime(cfg.dataDir); + } + return { cfg, publicDir, accounts, auth, clients, chat, highScores, arcade, officeName, models, building, floors, editorial }; } diff --git a/src/server/server.ts b/src/server/server.ts index a97beb5ba..308e9230d 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -29,11 +29,12 @@ export async function startServer(cfg: Config, opts: StartOptions = {}) { // running from the last office while the floors open. const ctx = {} as Ctx; Object.assign(ctx, messaging(ctx), floorHelpers(ctx), people(ctx), navigation(ctx), gates(ctx)); - Object.assign(ctx, createCore(ctx, cfg, publicDir)); + Object.assign(ctx, await createCore(ctx, cfg, publicDir)); const { hookServer, hookPort } = await startHookServer(ctx); Object.assign(ctx, createServices(ctx)); Object.assign(ctx, await openFloors(ctx, hookPort)); Object.assign(ctx, createLateServices(ctx)); + ctx.editorial?.bindAgentOffice(ctx); // --- HTTP ------------------------------------------------------------------------------------ const handler = requestHandler(ctx, routes); @@ -50,6 +51,7 @@ export async function startServer(cfg: Config, opts: StartOptions = {}) { const { services, tailnet } = ctx; services.start(); tailnet.start(() => services.list().map((s) => s.port)); + ctx.editorial?.start(); /** With `keep` (a restart), workers' terminals keep running for the next office to pick up. */ const shutdown = (keep = false) => { @@ -69,6 +71,7 @@ export async function startServer(cfg: Config, opts: StartOptions = {}) { ctx.limits.close(); for (const a of ctx.accountLimits.values()) a.reader.close(); ctx.signins.shutdown(); + ctx.editorial?.shutdown(); for (const c of ctx.clients.values()) c.ws.close(); server.close(); hookServer.close(); diff --git a/tasks/plan.md b/tasks/plan.md new file mode 100644 index 000000000..7d98a893c --- /dev/null +++ b/tasks/plan.md @@ -0,0 +1,139 @@ +# Plan de implementación: Editorial JeyTid sobre Agent Office + +## Resumen + +Añadir a Agent Office un módulo editorial privado, persistente y verificable que pueda dirigir una +persona propietaria desde Telegram y observar en una vista web complementaria servida por el mismo +proceso. El módulo mantiene el estado operativo en SQLite, los documentos y binarios en un almacén +privado, y usa un orquestador durable para coordinar once perfiles de rol, proveedores configurables, +validaciones, derechos, auditoría, preparación de KDP y marketing. La integración no convierte un +mock en una conexión real: cada capacidad se etiqueta con su evidencia y dependencia. + +## Evidencia y versión base + +- Base: `AgentSystemLabs/agent-office` en `852e2ab28c4f3f31bffdf4b20353bd119c9d3270` (`main`, 2026-10-08). +- Licencia: MIT. +- Stack detectado: Node.js/TypeScript ESM, Node >=20 en el proyecto base, Vite, WebSocket/HTTP, + workers CLI y persistencia existente en `.agent-office/`. +- Verificación inicial: `npm run typecheck` y `npm run build` pasan tras completar los enlaces de + binarios locales; `npm test` arranca pero conserva fallos previos en escenarios de clonación y + DeepSeek Harness que requieren servicios/fixtures externos. Se registrará el resultado exacto + después de cada cambio. +- Fuentes oficiales consultadas el 2026-10-28: Agent Office (`README`, `docs/how-it-works.md`, + `docs/agents.md`), Telegram Bot API, KDP Help, directrices de contenido KDP, preparación de + cubiertas y calculador de plantillas. Las URLs y fechas se conservarán en la documentación del + módulo. + +## Decisiones de arquitectura + +- **Módulo de servidor aislado:** `src/server/editorial/` contiene dominio, persistencia, orquestación, + proveedores, documentos, Telegram, KDP, redes y rutas. Se integra con `Ctx` mediante una interfaz + pequeña y no modifica los registries del cliente salvo la página complementaria. +- **Persistencia:** SQLite mediante `node:sqlite` cuando se activa Editorial; el archivo vive bajo + `.agent-office/editorial/editorial.sqlite`. Las migraciones son numeradas, transaccionales y + verifican invariantes. El modo editorial documentará Node 22.5+ como requisito operativo, mientras + que Agent Office sin Editorial conserva su compatibilidad base. +- **Archivos:** `.agent-office/editorial/projects///` separa originales, + fuentes editables, derivados, renderizados y paquetes. Las rutas se generan a partir de UUIDs; no + se aceptan segmentos de ruta del usuario. Cada artefacto guarda hash SHA-256 y revisión. +- **Contratos:** tipos explícitos y validadores en tiempo de ejecución para proyectos, tareas, + resultados, aprobaciones, artefactos, procedencia y publicaciones. El resumen de acciones reemplaza + cualquier almacenamiento de razonamiento privado. +- **Orquestación:** una cola durable reclama tareas con `BEGIN IMMEDIATE`, heartbeat y lease; solo + tareas cuyos predecesores están completados se ejecutan. La reserva de presupuesto ocurre antes de + lanzar trabajo y se libera/cierra al finalizar; respuestas tardías se descartan por generación. +- **Proveedores:** `mock` para pruebas reproducibles; `openai` por HTTP `fetch` para texto e imágenes, + habilitado solo con credencial configurada. Las imágenes no colocan texto tipográfico; la + composición de portada es un paso separado y trazable. +- **Agent Office:** el adaptador conserva `project_id`, `task_id`, `run_id`, rol y worker/floor cuando + existe. Si `EDITORIAL_AGENT_OFFICE_FLOOR` identifica un floor real, puede enviar una tarea a su + cola; si no, ejecuta el proveedor editorial configurado y deja la asociación en estado explícito. +- **Telegram:** long polling por defecto, token y propietario numérico fuera de la base y de los logs, + chat permitido por ID, actualizaciones deduplicadas en SQLite, callbacks con acción/versión/owner/ + expiración almacenados, y mensajes divididos al límite vigente. +- **KDP y redes:** primero preparación y asistencia; no se publican libros reales durante las pruebas. + La capacidad se expone como `implemented`, `tested_local`, `tested_real`, `requires_config`, + `requires_owner`, `limited` o `unsupported`. +- **Vista:** `/editorial` comparte autenticación y proceso de Agent Office y muestra proyectos, + tareas, dependencias, agentes, revisiones, hallazgos, archivos, aprobaciones, costos, conectores y + calendario sin exponer secretos. + +## Lista de tareas + +### Fase 1: Contratos y persistencia — completada + +- [x] T1. Crear tipos, validadores, estados, roles, perfiles y matriz de capacidades. +- [x] T2. Implementar migraciones SQLite, repositorios transaccionales, hashes, auditoría y + almacenamiento seguro de artefactos. +- [x] T3. Implementar permisos, invalidación de derivados, aprobaciones con tokens de un solo uso y + candidatos inmutables. + +### Fase 2: Orquestación y generación — completada + +- [x] T4. Implementar cola durable, leases, checkpoints, límites, pausa/cancelación y recuperación. +- [x] T5. Implementar adaptador Agent Office y perfiles configurables de los once agentes. +- [x] T6. Implementar proveedores mock y OpenAI para texto e imágenes con procedencia y costos. +- [x] T7. Implementar servicio de documentos: manuscrito editable, EPUB, PDF básico verificable, + renderizados, manifiestos y ZIP de candidato. +- [x] Checkpoint A: migración, permisos, cola y demo local de artefactos pasan pruebas focalizadas. + +### Fase 3: Telegram y observabilidad — completada + +- [x] T8. Integrar el runtime editorial al ciclo de Agent Office, configuración, CLI, salud, + apagado ordenado y respaldos/restauración. +- [x] T9. Implementar Bot API de Telegram, comandos, selector de proyecto, adjuntos limitados, + botones de aprobación, notificaciones de hitos y deduplicación. +- [x] T10. Implementar API y página `/editorial` para observar el estado real y descargar solo + artefactos autorizados. + +### Fase 4: Flujo editorial e integraciones externas — completada en modo local/asistido + +- [x] T11. Implementar creación de proyectos, ideas/brief, investigación con fuentes, escritura por + unidades, edición, corrección, continuidad y memoria/canon aislados por libro. +- [x] T12. Implementar derechos/procedencia, hallazgos, auditoría final independiente y pipeline de + release con invalidación. +- [x] T13. Implementar preparación KDP y flujo asistido con checklist, especificaciones y evidencia + oficial; no asumir API pública ni afirmar publicación automática. +- [x] T14. Implementar calendario social y un conector real opcional, más exportación asistida y + estados de capacidad por cuenta. + +### Fase 5: Verificación y entrega — completada con límite heredado documentado + +- [x] T15. Añadir demo reproducible de una obra breve hasta candidato KDP sin publicar ni gastar en + publicidad. +- [x] T16. Añadir pruebas focalizadas de los invariantes críticos, restauración separada y ausencia de + credenciales reales. La suite amplia base conserva fallos heredados y no se presenta como verde. +- [x] T17. Documentar instalación, configuración, operación, recuperación, límites, matriz de + capacidades, fuentes y resultados; actualizar README y docs relacionados. +- [ ] Checkpoint B: `npm run typecheck`, `npm test`, `npm run build`, demo y diagnóstico reproducible. + Typecheck, build, demo y tests editoriales pasan; `npm test` completo no termina limpio por + escenarios base de clonación/DSH que ya fallaban antes de Editorial. + +### Fase 6: Operación autónoma con propietario observador + +- [x] T18. Añadir una bandera de modo autónomo, con supervisado como valor seguro por defecto, y + reflejarla en la configuración y el snapshot sin exponer credenciales. +- [x] T19. Permitir que el orquestador se ejecute automáticamente solo en modo autónomo y ofrecer + ejecución manual filtrada por proyecto en modo supervisado. +- [x] T20. Exponer `/modo [autonomo|supervisado]` y `/ejecutar [project_id]` en Telegram, actualizar la ayuda y documentar + el flujo de propietario único; conservar aprobaciones para publicación, gastos y acciones externas. +- [x] Checkpoint C: pruebas de modo supervisado/autónomo, typecheck, build y revisión de secretos. + +## Riesgos y mitigaciones + +| Riesgo | Impacto | Mitigación | +| --- | --- | --- | +| `node:sqlite` no existe en Node antiguo | Alto | Activar Editorial solo con Node 22.5+ y fallar con diagnóstico claro; mantener Agent Office base separado. | +| Telegram/KDP/red no tienen credenciales o API suficiente | Alto | Completar código, dobles, exportación y asistencia; marcar la capacidad real y no simular éxito. | +| Integrar workers de Agent Office exige un floor | Medio | Adaptador opcional con asociación persistente; ejecución directa del proveedor cuando no hay floor. | +| Derivados obsoletos tras una edición | Alto | Revisiones, hashes, generación y reglas de invalidación antes de aprobar. | +| Dos tareas superan presupuesto por carrera | Alto | Reserva atómica dentro de transacción inmediata y tests de concurrencia. | +| PDFs/EPUB válidos pero visualmente pobres | Medio | Validación estructural, renderizados y checklist explícito; declarar límites del PDF básico. | +| Secretos en logs o archivos exportados | Alto | Redacción centralizada, `.gitignore`, pruebas de escaneo y archivos de ejemplo sin valores. | + +## Preguntas abiertas no bloqueantes + +- Credenciales y cuentas concretas de Telegram, proveedor de texto/imagen, KDP y red social se + conectarán después; el sistema debe funcionar en modo mock/local mientras no existan. +- El propietario podrá escoger más adelante jurisdicción, mercado, seudónimo, red social y proveedor; + no se inferirá ninguno. diff --git a/tasks/todo.md b/tasks/todo.md new file mode 100644 index 000000000..68087641b --- /dev/null +++ b/tasks/todo.md @@ -0,0 +1,25 @@ +# Editorial JeyTid — tareas + +- [x] T1. Tipos, validadores, estados, roles, perfiles y matriz de capacidades. +- [x] T2. Migraciones SQLite, repositorios, hashes, auditoría y artefactos. +- [x] T3. Permisos, invalidación, aprobaciones y releases inmutables. +- [x] T4. Cola durable, leases, checkpoints, límites, pausa/cancelación y recuperación. +- [x] T5. Adaptador Agent Office y once perfiles configurables. +- [x] T6. Proveedores mock/OpenAI de texto e imagen. +- [x] T7. Documentos, EPUB, PDF, renderizados, manifiestos y paquetes. +- [x] Checkpoint A: fundamentos verificables. +- [x] T8. Runtime integrado, configuración, CLI, salud, apagado y backups/restauración. +- [x] T9. Telegram: polling, comandos, archivos, callbacks y notificaciones. +- [x] T10. API y vista `/editorial`. +- [x] T11. Flujo editorial, memoria/canon y fuentes. +- [x] T12. Derechos, auditoría, findings y release. +- [x] T13. Preparación KDP y asistencia realista. +- [x] T14. Marketing, calendario y conector social opcional. +- [x] T15. Demo reproducible sin publicación ni publicidad. +- [x] T16. Pruebas focalizadas de aceptación, restauración y ausencia de credenciales reales; la suite base conserva fallos heredados. +- [x] T17. Documentación, matriz, fuentes y entrega. +- [ ] Checkpoint B: typecheck, tests, build, demo y diagnóstico; typecheck/build/demo pasan, la suite completa base no termina limpia por fallos heredados. +- [x] T18. Modo autónomo configurable, supervisado por defecto y visible en snapshot. +- [x] T19. Orquestación automática condicionada y ejecución manual por proyecto. +- [x] T20. Comandos Telegram `/modo [autonomo|supervisado]` y `/ejecutar`, ayuda y documentación. +- [x] Checkpoint C: pruebas focalizadas, typecheck, build y escaneo de secretos. diff --git a/tests/editorial.test.ts b/tests/editorial.test.ts new file mode 100644 index 000000000..d0872c435 --- /dev/null +++ b/tests/editorial.test.ts @@ -0,0 +1,212 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { restoreEditorialBackup } from '../src/server/editorial/backup.js'; +import { EditorialDatabase } from '../src/server/editorial/db.js'; +import { EditorialDocuments, validateEpub, validatePdf } from '../src/server/editorial/documents.js'; +import { id } from '../src/server/editorial/ids.js'; +import { allProfiles } from '../src/server/editorial/profiles.js'; +import { EditorialOrchestrator } from '../src/server/editorial/orchestrator.js'; +import { EditorialRepository } from '../src/server/editorial/repository.js'; +import { EditorialRuntime, seededBrief } from '../src/server/editorial/runtime.js'; +import { EditorialStorage } from '../src/server/editorial/storage.js'; +import { OpenAIEditorialProvider } from '../src/server/editorial/providers.js'; + +function tempDir(prefix: string): string { + return mkdtempSync(path.join(os.tmpdir(), `${prefix}-`)); +} + +function contract(projectId: string, taskId = id('task')) { + return { taskId, projectId, roleId: 'writer' as const, objective: 'Escribir una unidad de prueba', scope: 'demo', inputRevisionIds: [], dependencyTaskIds: [], acceptanceCriteria: ['resultado'], tools: ['artifact.write'], timeoutSeconds: 3, maxAttempts: 2, resultDestination: 'originals' }; +} + +test('editorial registers eleven profiles and keeps project state isolated', () => { + assert.equal(allProfiles().length, 11); + const root = tempDir('editorial-state'); + const db = new EditorialDatabase(path.join(root, 'editorial.sqlite')); + try { + const repo = new EditorialRepository(db); + const first = repo.createProject(seededBrief('Primero')); + const second = repo.createProject(seededBrief('Segundo')); + repo.createRevision(first.id, 'brief', 'hash-first', { canon: ['uno'] }); + assert.equal(repo.listProjects().length, 2); + assert.equal(repo.listRevisions(first.id).length, 1); + assert.equal(repo.listRevisions(second.id).length, 0); + assert.equal(repo.rememberTelegramUpdate(100), true); + assert.equal(repo.rememberTelegramUpdate(100), false); + } finally { + db.close(); + rmSync(root, { recursive: true, force: true }); + } +}); + +test('documents, rights, audit and owner approval produce a non-publishing release', () => { + const root = tempDir('editorial-release'); + const runtime = new EditorialRuntime(root, { EDITORIAL_PROVIDER: 'mock', TELEGRAM_OWNER_ID: '12345' }); + try { + const project = runtime.createProject(seededBrief('Obra verificable', { authorName: 'Autora' })); + const manuscript = runtime.documents.writeManuscript(project, '# Capítulo\n\nTexto editable.'); + const epub = runtime.documents.exportEpub(project, project.title, project.brief.authorName, '# Capítulo\n\nTexto EPUB.'); + const pdf = runtime.documents.exportPdf(project, project.title, '# Capítulo\n\nTexto PDF.'); + assert.equal(validateEpub(runtime.storage.read(epub.path)).ok, true); + assert.equal(validatePdf(runtime.storage.read(pdf.path)).ok, true); + const candidate = runtime.createReleaseCandidate(project.id, [manuscript, epub, pdf], { edition: 'digital' }); + const audit = runtime.auditReleaseCandidate(candidate.id); + assert.equal(audit.approved, true); + const requested = runtime.requestReleaseApproval(candidate.id); + assert.equal(runtime.approveApproval(requested.record.id).state, 'approved'); + assert.equal(runtime.prepareApprovedRelease(candidate.id).state, 'assisted'); + assert.ok(runtime.repo.listRights(project.id).some((rights) => rights.artifactId === manuscript.id)); + assert.ok(runtime.repo.listFindings(project.id).every((finding) => finding.status === 'open' || finding.status === 'resolved')); + } finally { + runtime.shutdown(); + rmSync(root, { recursive: true, force: true }); + } +}); + +test('budget reservations are atomic and late task results cannot overwrite cancellation', async () => { + const root = tempDir('editorial-queue'); + const db = new EditorialDatabase(path.join(root, 'editorial.sqlite')); + const repo = new EditorialRepository(db); + try { + const project = repo.createProject(seededBrief('Cola')); + repo.createTask(contract(project.id, 'task-a')); + repo.createTask(contract(project.id, 'task-b')); + repo.createTask(contract(project.id, 'task-c')); + const firstReservation = repo.reserveBudget(project.id, 'task-a', 'mock', 0.8, 1); + assert.ok(firstReservation); + assert.equal(repo.reserveBudget(project.id, 'task-b', 'mock', 0.8, 1), undefined); + repo.settleBudget(firstReservation!, { source: 'estimate', estimatedCostUsd: 0.1 }); + assert.ok(repo.reserveBudget(project.id, 'task-c', 'mock', 0.8, 1)); + repo.cancelTask('task-a', 'budget test'); + repo.cancelTask('task-b', 'budget test'); + repo.cancelTask('task-c', 'budget test'); + + const task = contract(project.id); + repo.createTask(task); + const orchestrator = new EditorialOrchestrator(repo, { + maxConcurrent: 1, budgetUsd: 5, leaseMs: 5000, pollMs: 10, owner: 'test', estimateCost: () => 0, + executor: async (context) => { + await new Promise((resolve) => setTimeout(resolve, 60)); + return { taskId: context.task.taskId, runId: context.runId, state: 'completed', actionSummary: 'late', provider: 'mock', profileVersion: '1', artifacts: [], findings: [], blockers: [], uncertainties: [], evidence: [], usage: { source: 'estimate' as const } }; + }, + }); + await orchestrator.pump(); + for (let attempt = 0; attempt < 20 && repo.getTask(task.taskId)?.state !== 'running'; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 5)); + assert.equal(repo.getTask(task.taskId)?.state, 'running'); + assert.equal(orchestrator.cancel(task.taskId), true); + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.equal(repo.getTask(task.taskId)?.state, 'cancelled'); + assert.equal(repo.listRuns()[0]?.status, 'cancelled'); + orchestrator.stop(); + } finally { + db.close(); + rmSync(root, { recursive: true, force: true }); + } +}); + +test('supervised mode leaves new tasks pending until the owner runs the queue', async () => { + const root = tempDir('editorial-supervised'); + const runtime = new EditorialRuntime(root, { EDITORIAL_PROVIDER: 'mock', EDITORIAL_AUTONOMOUS_MODE: '0' }); + try { + const project = runtime.createProject(seededBrief('Supervisado')); + const task = runtime.createTask('owner', contract(project.id)); + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.equal(runtime.repo.getTask(task.taskId)?.state, 'pending'); + await runtime.runPending(project.id); + for (let attempt = 0; attempt < 40 && runtime.repo.getTask(task.taskId)?.state === 'running'; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(runtime.repo.getTask(task.taskId)?.state, 'completed'); + assert.equal(runtime.snapshot().settings.autonomousMode, false); + } finally { + runtime.shutdown(); + rmSync(root, { recursive: true, force: true }); + } +}); + +test('autonomous mode starts the queue without a manual trigger', async () => { + const root = tempDir('editorial-autonomous'); + const runtime = new EditorialRuntime(root, { EDITORIAL_PROVIDER: 'mock', EDITORIAL_AUTONOMOUS_MODE: '1' }); + try { + runtime.start(); + const project = runtime.createProject(seededBrief('Autónomo')); + const task = runtime.createTask('owner', contract(project.id)); + for (let attempt = 0; attempt < 80 && runtime.repo.getTask(task.taskId)?.state !== 'completed'; attempt += 1) await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(runtime.repo.getTask(task.taskId)?.state, 'completed'); + assert.equal(runtime.snapshot().settings.autonomousMode, true); + } finally { + runtime.shutdown(); + rmSync(root, { recursive: true, force: true }); + } +}); + +test('owner can switch the current runtime between autonomous and supervised modes', () => { + const root = tempDir('editorial-mode-switch'); + const runtime = new EditorialRuntime(root, { EDITORIAL_PROVIDER: 'mock', EDITORIAL_AUTONOMOUS_MODE: '0' }); + try { + runtime.setAutonomousMode(true); + assert.equal(runtime.config.autonomousMode, true); + runtime.setAutonomousMode(false); + assert.equal(runtime.config.autonomousMode, false); + } finally { + runtime.shutdown(); + rmSync(root, { recursive: true, force: true }); + } +}); + +test('OpenAI adapter sends documented request shape without calling a real service', async () => { + const requests: Array<{ url: string; init: RequestInit }> = []; + const provider = new OpenAIEditorialProvider({ apiKey: 'api-key-test-only', baseUrl: 'https://example.invalid/v1', fetchImpl: async (input, init = {}) => { + requests.push({ url: String(input), init }); + if (String(input).endsWith('/images/generations')) return new Response(JSON.stringify({ data: [{ b64_json: Buffer.from('test-image').toString('base64') }] }), { status: 200, headers: { 'content-type': 'application/json' } }); + return new Response(JSON.stringify({ output_text: 'texto de prueba', usage: { input_tokens: 3, output_tokens: 4 } }), { status: 200, headers: { 'content-type': 'application/json' } }); + }}); + const text = await provider.generateText({ systemPrompt: 'sistema', prompt: 'usuario', model: 'model-test' }); + const image = await provider.generateImage({ prompt: 'imagen', model: 'image-test' }); + assert.equal(text.text, 'texto de prueba'); + assert.equal(new TextDecoder().decode(image.bytes), 'test-image'); + assert.equal(requests.length, 2); + assert.match(String(requests[0].init.headers && (requests[0].init.headers as Record).authorization), /^Bearer api-key-test-only$/); +}); + +test('backup restores a separate private editorial store', () => { + const root = tempDir('editorial-backup-source'); + const restored = tempDir('editorial-backup-restored'); + const backup = tempDir('editorial-backup-copy'); + const runtime = new EditorialRuntime(root, { EDITORIAL_PROVIDER: 'mock' }); + try { + runtime.createProject(seededBrief('Persistente')); + const manifest = runtime.backup(backup); + assert.ok(manifest.files.some((file) => file.endsWith('editorial.sqlite'))); + } finally { + runtime.shutdown(); + } + restoreEditorialBackup(backup, restored); + const reopened = new EditorialRuntime(restored, { EDITORIAL_PROVIDER: 'mock' }); + try { + assert.equal(reopened.snapshot().projects.length, 1); + assert.equal(reopened.snapshot().projects[0].title, 'Persistente'); + } finally { + reopened.shutdown(); + rmSync(root, { recursive: true, force: true }); + rmSync(restored, { recursive: true, force: true }); + rmSync(backup, { recursive: true, force: true }); + } +}); + +test('Mastodon connector marks a post only after a remote id and owner approval', async () => { + const root = tempDir('editorial-social'); + const runtime = new EditorialRuntime(root, { EDITORIAL_PROVIDER: 'mock', MASTODON_BASE_URL: 'https://social.example', MASTODON_ACCESS_TOKEN: 'token-test-only' }, { fetchImpl: async () => new Response(JSON.stringify({ id: 'remote-1', url: 'https://social.example/@owner/remote-1' }), { status: 200, headers: { 'content-type': 'application/json' } }) }); + try { + const project = runtime.createProject(seededBrief('Social')); + const post = runtime.marketing.createPost(project, 'Mensaje autorizado'); + await assert.rejects(() => runtime.marketing.publishPost(post.id), /approval/i); + assert.equal(runtime.marketing.approvePost(post.id), true); + const published = await runtime.marketing.publishPost(post.id); + assert.equal(published.state, 'published'); + } finally { + runtime.shutdown(); + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/vite.config.ts b/vite.config.ts index 38f15039c..f52514751 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -57,6 +57,7 @@ export default defineConfig({ login: resolve(import.meta.dirname, 'src/client/login.html'), claim: resolve(import.meta.dirname, 'src/client/claim.html'), join: resolve(import.meta.dirname, 'src/client/join.html'), + editorial: resolve(import.meta.dirname, 'src/client/editorial.html'), }, }, },