尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證正在載入完整 README。 以下為已儲存的摘錄。
Compañero de charlas por voz para registrar la historia de vida de tu papá. 1. Abre .env y reemplaza pega_aqui_tu_key_nueva por tu API key de Claude. 2. (Opcional pero recomendado) Configurá la voz natural con Azure Speech — ver abajo. Sin esto, la app usa la voz del sistema (más robótica) como respaldo automático. Conseguir la voz natural (ElevenLabs, gratis) — recomendado Más simple que Azure: solo mail y contraseña, sin tarjeta para el nivel gratis (10.000 caracteres/mes, suficiente para probar).檢視完整 README ↗
Compañero de charlas por voz para registrar la historia de vida de tu papá.
.env y reemplaza pega_aqui_tu_key_nueva por tu API key de Claude.Más simple que Azure: solo mail y contraseña, sin tarjeta para el nivel gratis (10.000 caracteres/mes, suficiente para probar).
.env:
ELEVENLABS_API_KEY → tu API keyELEVENLABS_VOICE_ID → el Voice ID que copiasteELEVENLABS_VOICE_ID_MASCULINA (opcional) → la segunda voz, para quien elija "Masculina" en Opciones avanzadas > Voz. Si no la pones, usa 57D8YIbQSuE3REDPO6Vm. La elección se guarda por bitácora (users.voz / bitacoras.voz), POST /api/voz; /api/speak recibe voz con cada audio. Con voz masculina la IA también habla de sí misma en masculino ("entrevistador"). /api/admin/voz-debug prueba las dos voces.Si el uso diario supera el nivel gratis, el plan Starter son $5 USD/mes (30.000 caracteres) — igual muy barato para este uso.
Modelo de voz: por defecto la app usa Eleven v4 Turbo (eleven_v4_turbo): más expresivo que Flash, mismo precio de lista ($0,04 por 1.000 caracteres) pero más lento (~1,8 s contra ~0,6 s por frase). Para cambiarlo sin tocar código, definir ELEVENLABS_MODEL_ID en Vercel (por ejemplo eleven_flash_v2_5 para volver al rápido) y hacer Redeploy. Si el modelo elegido falla, la app reintenta una vez con Flash v2.5 antes de caer a la voz del sistema. La llave necesita permiso de Text to Speech y Speech to Text.
Si prefieres la voz colombiana específica de Microsoft (es-CO-SalomeNeural) y puedes acceder a Azure:
.env como AZURE_SPEECH_KEY y AZURE_SPEECH_REGION.La app usa ElevenLabs si está configurado; si no, prueba con Azure; si ninguno está configurado, usa la voz del sistema como respaldo.
cd "/Users/felipebernal/Claude Code/bitacora-viva"
npm install
npm run dev
Abre Chrome en http://localhost:3000/app.html (Chrome es el que mejor soporta el micrófono del navegador). La raíz (http://localhost:3000) muestra la landing pública con el registro; /app.html es la herramienta en sí.
bitacora.json.Todo se guarda en la nube (Postgres + Vercel Blob), no en archivos locales — así funciona igual corriendo en tu Mac o desplegado en Vercel.
sessions de la base de datos, con fecha y toda la conversación.audioFile de esa respuesta.resumen. Se actualiza solo al final de cada charla — Claude relee el resumen anterior + la charla nueva, y arma uno actualizado. Así la próxima charla no repite lo ya sabido, y las preguntas de la familia son rápidas de responder sin tener que releer todo. Si una pregunta de la familia necesita un detalle muy específico que el resumen no tiene, el sistema va solo a buscarlo en las charlas completas.DATABASE_URL.BLOB_READ_WRITE_TOKEN.ANTHROPIC_API_KEYELEVENLABS_API_KEY y ELEVENLABS_VOICE_ID (si los usas)git push, Vercel despliega solo la nueva versión.Copia DATABASE_URL y BLOB_READ_WRITE_TOKEN desde Storage en el dashboard de Vercel (click en cada base → .env.local o Quickstart) y pegalos en tu .env local. Sin esto, npm run dev sigue prendiendo pero las charlas no se van a poder guardar.
Los audios, fotos y videos viven en Cloudflare R2 (desde el 2026-10-16 no queda ningún archivo en Vercel Blob: ver "Privacidad de los archivos"). R2 da 10 millones de operaciones/mes gratis y no cobra transferencia nunca; Vercel Blob, en cambio, tiene un cupo bajo de operaciones (2.000/mes en el plan gratis) y cada lectura cuenta.
La app usa R2 cuando están definidas estas 5 variables de entorno. Si falta alguna, las subidas nuevas caen a Vercel Blob, así que no hay que quitar ninguna:
| Variable | De dónde sale |
|---|---|
R2_ACCOUNT_ID | Cloudflare → R2 → arriba a la derecha, "Account ID". |
R2_BUCKET | El nombre del bucket que creaste (ej. bitacora-viva). |
R2_ACCESS_KEY_ID / R2_SECRET_ACCESS_KEY | Cloudflare → R2 → Manage R2 API Tokens → Create API Token (permiso Object Read & Write, alcance ese bucket). El secret solo se muestra una vez. |
R2_PUBLIC_URL | En el bucket → Settings → Public access → activa R2.dev subdomain (o conecta un dominio propio). Es la URL https://<algo>.r2.dev, sin barra al final. |
Con las 5 puestas y un Redeploy, las subidas van a R2. Las URL que empiezan por *.blob.vercel-storage.com todavía se saben leer (se detecta por la URL), pero ya no queda ninguna en la base.
Un turno son 5 pasos en fila: silencio de espera (SILENCE_MS, 2 s: tiene que pasar sin voz antes de dar la respuesta por terminada) → transcribir (/api/transcribe, ~1 s) → la IA (/api/next: lecturas a la base + Claude) → la voz (/api/speak) → reproducir. Qué se hizo para acortarlo (2026-10-08):
/api/next (que guarda la historia sin audio) y, cuando las dos terminan, POST /api/story-log/audio enlaza el audio con la historia. En colaborar.html, la subida y la transcripción van en paralelo.voz:, 120/min) para poder hacerlo sin gastar los 30/min generales./api/next (resumen, contexto familiar) en vez de una tras otra./api/transcribe, /api/next y /api/speak devuelven el header Server-Timing (F12, Network, el pedido, Timing) y dejan una línea [latencia] ruta=… total=…ms db=… claude=… en los logs de Vercel: así se ve dónde se va el tiempo de verdad./api/next con stream: true, app.html). El servidor contesta una línea de JSON por evento: frase (oraciones ya listas), fin (el mensaje final con todo el postproceso y restante, lo que falta por decir) o error. El cliente (iniciarHabla) pide la voz de cada frase apenas llega y las reproduce en orden, mientras Claude sigue escribiendo. Solo se adelantan oraciones seguras: sin [FIN]/[PAUSA], sin voseo ni argentinismos y nada después de la primera pregunta (la regla de "una sola pregunta" y la corrección de dialecto se deciden con el mensaje entero). Sin stream, la ruta contesta el JSON de siempre. colaborar.html (/api/contribute-chat) todavía no lo usa.app.html, iniciarEspeculacion). Cuando la persona ya habló y lleva 900 ms callada (), sin detener la grabación se transcribe lo grabado hasta ahí, se pide la respuesta con + (que no guarda nada ni marca notas) y se va pidiendo la voz de cada frase. Si a los 2 s () no volvió a hablar, ese turno usa lo ya hecho: no vuelve a transcribir, y la respuesta y la voz ya vienen adelantadas. El guardado de la historia se confirma con . Si vuelve a hablar, todo se descarta (se gasta una transcripción y una respuesta de más; máximo 2 intentos por turno). No se especula en el primer turno, con fotos pendientes, en el modo árbol, con una nota de un familiar por contar ni cuando toca ofrecer la pausa; en esos casos el turno sigue por el camino de siempre.Lo que más pesa y es decisión de producto: el modelo de voz (ELEVENLABS_MODEL_ID: Flash v2.5 genera ~1,2 s más rápido que v4 Turbo) y los 2 s de silencio (bajarlo más corta a quien piensa a mitad de frase; ver el comentario junto a SILENCE_MS en app.html). Tests: test/velocidad-turno.smoke.js.
El árbol se actualiza solo en cada guardado de charla (/api/save, función updateFamilyTree en server.js) con una llamada a Claude. Para que cueste poco:
sessions.arbol_procesado guarda cuántos mensajes de esa sesión ya pasaron por el árbol; un guardado repetido (pausa, avance parcial, cierre) manda solo lo que falta. Si la llamada falla, no se marca como procesado y se reintenta en el siguiente guardado.hayPistasDeFamiliaOHitos: parentescos, nombres propios, años, "nací", "me casé"…). Queda la línea [arbol-consumo] … salto=sin-pistas en los logs.id | nombre | parentesco | detalles | padres (no JSON) y de la entrevistadora solo se manda el final de cada pregunta.id, más quitar_personas/quitar_eventos), no la lista completa — la salida es lo más caro y antes crecía con el tamaño de la familia. aplicarCambiosDelArbol los junta con lo que ya había; los ids son el número de línea (no el nombre: dos personas pueden llamarse igual) y si Claude repite a alguien conocido sin id se actualiza esa misma fila en vez de duplicarla.[arbol-consumo] … sin-cambios).Con una familia de ~30 personas y 20 hitos, y una sesión guardada 3 veces, la estimación pasa de ~$0,058 a ~$0,016 por sesión (~72 % menos). El consumo real se ve en /admin.html (tipo arbol). Tests: test/arbol-consumo.smoke.js.
El resumen de memoria (updateMemorySummary, tipo resumen en el panel) usa la misma idea y el mismo test:
sessions.resumen_procesado).SIN_CAMBIOS) y se agregan al final del resumen (tope de salida 300 tokens, antes 700). Solo cuando el resumen pasa de ~2.800 caracteres se hace la reescritura completa de antes (máx. 400 palabras) para consolidarlo — el resumen viaja en cada turno de la charla, así que tiene que seguir siendo corto.[resumen-consumo] … modo=delta|completo|sin-cambios y salto=pocos.Estimación para una charla de 30 intercambios guardada 3 veces: resumen ~$0,021 → ~$0,0095 (~55 % menos); árbol + resumen juntos, de ~$0,079 a ~$0,026 por charla (~68 % menos). Son estimaciones con un caso modelado: el consumo real se ve en /admin.html.
Todo lo que la app dice o escribe va en español de Colombia con tuteo: nunca voseo ("tenés", "contame", "vos"), ni argentinismos ("che", "acá", "re lindo", "auto"), ni "vosotros". Está garantizado en tres capas:
REGLA_ESPANOL_COLOMBIANO (en server.js) se suma a los 9 system prompts de Claude (charla, árbol, aportes, resumen, capítulos, clasificación, extracción) y a los dos correctores. Para cambiar la regla se edita en un solo lugar./api/next) y en los aportes (/api/contribute-chat) pasa por asegurarEspanolColombiano: si el detector (detectarFueraDeColombia) encuentra algo, se pide una reescritura mínima y, si esa falla, se reemplaza de forma determinista. Cada activación deja una línea [dialecto] en los logs de Vercel.test/espanol-colombiano.smoke.js falla si algún prompt queda sin la regla o si una página trae voseo; test/next.smoke.js prueba la corrección en vivo.Lo que NO se toca: lo que dice la persona (su transcripción y sus citas) se conserva tal cual. En Colombia el voseo es real en varias regiones (Antioquia, Valle, Eje Cafetero…): quien dice "vos" no está hablando argentino. La transcripción la hace ElevenLabs (scribe_v1, language_code: spa) y es literal.
La landing promete que los archivos son privados y que no existe un enlace público. Para que sea verdad hacen falta dos pasos que no se pueden hacer desde el código. Los dos ya se hicieron el 2026-10-16: el bucket de R2 está cerrado al público y lo viejo de Vercel Blob se migró a R2 (el dry run dio 0 filas en todas las tablas y r2PublicoAbierto: false). Quedan pendientes vaciar el store de Vercel Blob y borrar la rama de respaldo que se sacó en Neon antes de migrar. Los pasos quedan aquí por si hay que repetirlos (por ejemplo, si alguna vez faltan las variables R2_* y algo vuelve a caer en Blob):
aws4fetch, firmado), y solo entrega un archivo por /api/media-file después de comprobar sesión y permiso. No borrar R2_PUBLIC_URL de Vercel: se sigue usando para armar la URL que se guarda en la base.// 1) Solo contar (no toca nada) — también dice si R2 sigue abierto al público.
await fetch('/api/admin/migrar-blob-a-r2', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }).then(r => r.json())
// 2) Migrar un lote (máx. 30 archivos por pedido). Repetir hasta que el paso 1 dé 0 en todo.
await fetch('/api/admin/migrar-blob-a-r2', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ confirmar: true, limite: 10 }) }).then(r => r.json())
Cada archivo se copia a R2 con la misma clave, se cambia la URL en todas las filas que lo usan (story_log, family_notes, media, diario_bebe_entradas, bitacoras.mensaje_regalo_url, book_audio_links —las copias de audio de los QR del libro— y las fotos dentro de book_editor_drafts) y recién después se borra el original de Blob; si algo falla queda tal cual y aparece en fallidas. Cuando el paso 1 dé r2PublicoAbierto: false y 0 filas pendientes, el store de Blob puede vaciarse.
El botón Exportar en PDF está en Mi libro (public/eco-libro-exportar.js). Si la compra de prueba no está aprobada, abre la ventana de pago; al darle al botón de pago se cierra sola y se abre de una vez la ventana de impresión del navegador con el libro completo ("Guardar como PDF"), sin pasos de más. Lo mismo pasa si se compra desde el botón del editor. Si ya está comprado, exporta directo. Sin capítulos no pide pago: avisa que primero hay que generarlos. Por ahora el PDF sale de la impresión del navegador (no hay un generador de PDF en el servidor). Prueba: npm run test:libro-exportar.
Un QR del libro apunta a /escuchar.html?t=<token>: quien lo escanea oye las grabaciones originales de ese capítulo sin cuenta. El token (48 hex al azar) es la llave: la cuenta dueña lo crea (POST /api/book-qr/chapters/:id), lo comprueba (GET /api/book-qr/owned/:token) y lo retira (DELETE /api/book-qr/chapters/:id, desde el editor del libro; los QR ya impresos dejan de funcionar). Las rutas públicas son GET /api/book-qr/listen/:token y GET /api/book-qr/audio/:token/:n: entregan por posición y solo del dueño del enlace, con soporte de rangos (Safari), tope de 240 pedidos por minuto por IP y noindex. Los archivos en sí siguen privados (/api/media-file pide sesión). Prueba: test/book-qr-publico.smoke.js.
Cualquier cuenta puede crear el diario de un bebé desde el menú Diario de un bebé (/diario-bebe.html): nombre, niña/niño/sin decir y, si ya nació, la fecha (el diario pasa de "en camino" a "ya llegó"). Una vez creado, aparece un botón con el nombre del bebé dentro de la sesión (public/eco-bebe.js): al tocarlo la pantalla pasa a una paleta rosada tenue (public/eco-bebe.css, clase eco-bebe-activo), se ofrece Subir una foto de hoy (se guarda en el diario apenas se elige) y la nota se cuenta por voz con el botón redondo de ECO: al terminar de hablar se guardan solos el audio original y su transcripción en esa misma entrada ("contar más" suma otra grabación; "borrar y volver a contar" la limpia). La charla se enfoca en el día a día del bebé (modoBebe: true en /api/next agrega instruccionModoBebe al system y cambia el saludo). Lo que se conversa por voz en este modo va al libro de siempre y también al diario: cada cosa que la persona cuenta (desde 25 letras) queda como una entrada "De la charla con ECO" (origen = 'charla'), con la misma grabación del libro (/api/story-log/audio la suma a las dos). Borrar esa entrada del diario nunca borra la grabación, que sigue en el libro. En el modo, la persona es el papá o la mamá según el trato que pidió; si cuenta que el bebé nació, ECO pregunta la fecha y el diario pasa solo a "ya nació" ([NACIO:AAAA-MM-DD], ver instruccionModoBebe).
Datos y rutas (bebe-routes.js, tablas diario_bebe y diario_bebe_entradas): GET/PUT /api/bebe, POST /api/bebe/foto (4 MB, la página comprime la foto), POST /api/bebe/audio (nota de voz original, 4 MB), POST /api/bebe/entradas, PATCH /api/bebe/entradas/:id (texto, agregarAudio, borrarAudios), DELETE /api/bebe/entradas/:id, GET /api/bebe/foto/:id?owner= y GET /api/bebe/audio/:id/:n?owner=. Los audios van en diario_bebe_entradas.audio_urls (JSON) y se borran con la entrada. Las fotos y notas de voz viven en media/<id>/bebe/ y solo se entregan por el diario. Los familiares invitados (colaboradores o invitados con enlace) VEN el diario (/diario-bebe.html?owner=<id>, enlace en colaborar.html vía eco-bebe-familia.js) pero no escriben: toda escritura va a la bitácora activa de quien llama. Se borra con la cuenta, el reinicio de la bitácora y la limpieza de admin. Pruebas: test/diario-bebe.smoke.js, test/diario-bebe.playwright.js (npm run test:diario-bebe) y los casos modo bebé de test/next.smoke.js.
En Perfiles hay una sección "Agregar una mascota" (nombre, perro/gato/otro, raza, macho o hembra, fecha). Es un subperfil con bitacoras.tipo = 'mascota' (más especie y raza). Como la mascota no puede hablar, quien la administra sí puede narrar su vida (requireAuth pone puedeNarrar = true solo para mascotas; las personas administradas siguen sin poder). En /api/next la charla agrega instruccionMascota (tercera persona, centro en su vida con la familia, sin trato de persona) y arranca con inicioMascota; el resumen de memoria y el aporte de familiares (/api/contribute-chat) también se adaptan. Los familiares aportan con el enlace del perfil igual que en cualquier subperfil. La ficha (nombre, especie, raza) va como dato no confiable. Pruebas: casos de mascota en test/subperfiles.smoke.js y test/perfil-mascota.playwright.js (npm run test:perfil-mascota). Los capítulos del libro todavía usan el texto pensado para personas.
public/eco-telefono.js): menú con bandera (Colombia +57, Estados Unidos +1) y solo los 10 dígitos. Se usa al invitar (pantalla principal y Colaboración) y en el perfil de la cuenta (que ahora guarda el celular). Al servidor viaja completo (+573001234567). Los enlaces de los mensajes salen siempre con https (urlBase: ignora un PUBLIC_BASE_URL que no sea una dirección web).POST /api/invitacion-vincular): si el celular de tu cuenta es el de la invitación, te sumas como colaborador sin cerrar sesión. Prueba: test/invitacion-vincular.smoke.js.npm run test:colaboracion.qrOff y no vuelve solo; npm run test:libro-qr); el PDF sale en A4 por defecto con márgenes en la hoja (@page), así que un capítulo largo conserva el margen en todas las páginas.POST /api/tree/merge): si una es "Yo", esa se conserva con el nombre más completo; lo que apuntaba a cualquiera de los dos nombres (hijos, recuerdos) apunta a la persona fusionada. Prueba: test/arbol-fusion.smoke.js.POST /api/bebe/archivar); archivado no sale en la pantalla principal. Las mascotas no archivadas salen como un botón en la pantalla principal (public/eco-mascotas-inicio.js).En Perfiles → Agregar un perfil hay un selector (Familiar, Mascota, Bebé) que muestra las opciones de cada tipo. Un bebé es un subperfil con bitacoras.tipo = 'bebe' y su diario en diario_bebe con el mismo id (se crean juntos en POST /api/subprofiles con tipo: 'bebe', tratamiento = niña/niño y nacioEn si ya nació). Como la mascota, quien lo administra SÍ puede narrar (puedeNarrar); dentro del perfil el modo del diario (paleta rosada o azul, subir fotos, charla del día a día) está siempre activo y el servidor lo aplica aunque el cliente no lo pida (esPerfilBebe en /api/next). El trato niña/niño es del bebé: a quien habla se le trata según el trato de su propia cuenta. En la pantalla principal sale un botón por cada bebé y mascota sin archivar (public/eco-mascotas-inicio.js). Un diario creado antes dentro de la propia bitácora (menú) sigue funcionando y aparece en Perfiles; los nuevos se crean solo desde Perfiles. Pruebas: test/subperfiles.smoke.js y npm run test:perfil-mascota.
public/eco-libro.js, eco-libro-barra.css): dos grupos, "Tu libro" (Ver mis historias, Diseñar mi libro, Exportar en PDF) y "Armar capítulos" (voz del libro + Generar capítulos). Los botones que otros scripts agregan después se llevan solos al grupo del libro.public/eco-libro-fotos.js y .css): una sola manera de colocarlas, igual en el estudio, la vista del libro y "Leer capítulo": la primera foto arriba a la derecha dentro del texto (el texto la rodea y luego sigue normal), las demás repartidas en partes iguales por el capítulo, con su pie de foto editable. Ya no hay tamaño ni posición por foto; solo "Incluir" y el pie. pos: 'hide' en state.media sigue significando "no incluir". El QR existente se deja ver; "Volver a comprobar el QR" ya no existe (queda "Crear QR de voz" solo si no hay QR).public/eco-lectura.js): la lista de capítulos sigue el orden y los títulos del borrador, y "Leer capítulo" muestra el texto editado y las fotos. Prueba: npm run test:libro-fotos.public/eco-mascotas-inicio.js, eco-mascota.css): arriba de la pantalla principal van, uno al lado del otro, la persona (verde), los bebés (rosado) y las mascotas (amarillo), con emoji (bebé: 👧 👦 👶 según el sexo; mascota: 🐶 perro, 🐱 gato, 🐾 otra). En el perfil de un bebé o de una mascota la fila incluye el botón de la persona para volver; el perfil activo va resaltado. El perfil de la mascota usa una paleta café tenue (clase eco-mascota-activo), como el rosado del bebé.En Perfiles, cada perfil administrado (familiar, mascota o bebé) tiene "Editar datos" (POST /api/subprofiles/:id/editar, solo la cuenta que lo administra). Cada tipo edita lo suyo: la persona, su nombre, fecha y "para quién es" (el trato sigue en su lista); la mascota, nombre, animal, raza, sexo y fecha; el bebé, nombre, sexo y fecha de nacimiento (vacía = "en camino"; al ponerla pasa a "ya llegó"). Los cambios del bebé se guardan también en su diario. Pruebas: test/subperfiles.smoke.js y npm run test:perfil-mascota.
/admin.html)Reporte de uso y costo estimado por perfil (cuenta dueña o subperfil): tokens de Claude (incluida la parte de prompt caching), caracteres de voz (ElevenLabs/Azure), tiempo hablado, y espacio ocupado en la base de datos. Pensado para los dueños del producto, no para cuentas normales.
Dar acceso a alguien: no hay botón para esto a propósito — se activa con un UPDATE directo en la base (Neon → Query en el dashboard de Vercel, o cualquier cliente de Postgres):
UPDATE users SET is_admin = true WHERE email = '[email protected]';
La cuenta tiene que existir primero. Una vez marcada, al loguearse le va a aparecer un link a /admin.html.
Tarifas usadas para estimar el costo en $: configurables por variable de entorno — sin configurar, usa valores de referencia que conviene ajustar a la factura real:
ANTHROPIC_INPUT_PRICE_PER_1M / ANTHROPIC_OUTPUT_PRICE_PER_1M — USD por millón de tokens de entrada/salida de Claude.ANTHROPIC_CACHE_WRITE_PRICE_PER_1M / ANTHROPIC_CACHE_READ_PRICE_PER_1M — tarifa de los tokens de prompt caching que usa /api/next (ver el comentario junto a cache_control en server.js). Sin configurar, se calculan como 1.25x/0.1x del precio de entrada normal (las proporciones típicas de Anthropic).ELEVENLABS_PRICE_PER_1K_CHARS / ELEVENLABS_PRICE_PER_HOUR_STT — USD por 1000 caracteres de voz (TTS) / por hora de transcripción (STT). Esta app llama a la API de ElevenLabs (no el plan de consumidor con "créditos") — esa tarifa es la misma sin importar el plan contratado (Free/Starter/Creator/...), confirmado por Felipe el 2026-10-08 contra su cuenta: $0.04/1000 caracteres (Flash v2.5 y v4 Turbo; antes se usaba $0.05) y $0.22/hora (Scribe v1). Promoción: Eleven v4 Turbo cuesta $0.011/1000 caracteres hasta el 12 de octubre de 2026; mientras dure, y solo si ese es el modelo en uso (ELEVENLABS_MODEL_ID), el costo que registra el panel usa esa tarifa y después vuelve solo a $0.04. Si se define ELEVENLABS_PRICE_PER_1K_CHARS, esa manda sobre todo.ADMIN_ALERT_THRESHOLD_USD_30D (opcional) — si un perfil supera este monto en el rango de fechas elegido en el panel, se resalta con un aviso. Sin configurar, no se resalta a nadie.El consumo de Claude/voz solo queda registrado desde que se activó esta medición (no hay forma de reconstruir tokens de charlas viejas); el tamaño en la base de datos, en cambio, se calcula sobre los datos tal como están hoy, así que sí incluye lo histórico.
Si se edita el <script>/<style> de admin.html, correr node tools/actualizar-hashes-vercel.js antes de commitear (mismo criterio que el resto de public/, ver test/hashes-csp-vercel.smoke.js).
La app se llama Eco (manual de marca de octubre 2026). Slogan principal: "Lo que cuentas, se queda." Colores: musgo #4F5D3A, crema #F6EEDC, dorado #D9A441 (acento sobre musgo), ocre #B7791F (acento sobre crema), musgo oscuro #3B4429, arena #EADFC4, tierra #5B5A44. Tipografías del manual: Fraunces SemiBold (títulos) y Lora Medium (texto); la app todavía usa las suyas.
Logo: tres anillos de árbol concéntricos (radios 28, 50 y 72 en una grilla de 200, trazo de 12 con puntas redondeadas, abertura del 28% girada 12° hacia afuera en cada anillo) y un punto central dorado de radio 13. Los archivos están en public/images/eco/ (SVG: ícono, ícono invertido, ícono mínimo para menos de 16 px, símbolo, horizontal, y sus versiones clara y de una sola tinta) y en public/images/ (PNG: favicon-16/32/192/512, apple-touch-icon, logo-icon, logo-full). La palabra "Eco" va convertida a trazos. Todo se regenera con node tools/generar-logos-eco.js (la geometría está en ese archivo). No estirar, recolorear, ponerle sombras ni ondas de sonido; tamaño mínimo: 16 px el símbolo, 120 px el horizontal.
El botón de hablar es el logo animado (app.html, animación "Una gota que despierta el eco"): en el turno de la persona (data-state="listening") corre la intro (cae la gota, salen las ondas, los anillos se abren) y luego el punto late y las ondas hacen vibrar los anillos; en el turno de la IA (speaking/thinking) los anillos se cierran y el botón se pone negro; al volver el turno de la persona la animación arranca otra vez. Tiempos del original por 0,7. Con "reducir movimiento" queda quieto. La pantalla principal cabe en una sola vista sin scroll y el botón crece con el alto de la ventana (150 a 400 px). Pruebas: npm run test:orbe y npm run test:pantalla.
Link viejo -> link nuevo. Las páginas estáticas las sirve Vercel sin pasar por Express, así que para que quien tiene el link viejo (bitacora-viva.vercel.app) entre al nuevo hacen falta dos cosas: (1) en Vercel, la variable DOMINIO_NUEVO (ej. eco.co); (2) en vercel.json, al principio de routes: { "src": "/(.*)", "has": [{ "type": "host", "value": "bitacora-viva.vercel.app" }], "dest": "/api/redirigir-dominio?ruta=$1" }. Esa ruta responde una redirección permanente (308) al dominio nuevo conservando la ruta y los parámetros (?invitacion=, ?codigo=, el token de un enlace mágico viejo). No agregar la regla de vercel.json antes de tener el dominio nuevo y la variable puesta: sin ellas el sitio viejo dejaría de abrir. Las sesiones no pasan de un dominio a otro: quien ya había entrado tendrá que iniciar sesión de nuevo.
Quien aporta historias sin cuenta entra con un enlace personal que crea el dueño (o quien administra el subperfil) desde "Invitar a mi círculo a colaborar": escribe el nombre y el celular de la persona, la app crea /colaborar.html?invitacion=… y abre WhatsApp con el chat de ese número y el mensaje ya escrito. El celular es único por bitácora (tabla invitados); el mismo celular es la misma persona.
guestId y cada aporte guarda family_notes.guest_id: dos invitados llamados igual no ven ni tocan los aportes del otro (antes se comparaba por nombre).guestId, recupera sus aportes).POST /api/guest-start pide invitacion); sigue sirviendo para registrarse con cuenta y para colaboradores con cuenta. Rotar el código familiar no corta a los invitados personales; quitar su acceso sí.guestId) siguen funcionando hasta que expiren (30 días) o se rote el código familiar, y solo ven los aportes viejos sin guest_id de su mismo nombre./api/contribute-chat, draftId) exige ser quien lo escribió (contributed_by / guest_id); un draftId ajeno no modifica nada y se guarda como aporte nuevo./api/media-file ya no deja a un colaborador o invitado abrir cualquier archivo de la familia. El dueño (y quien administra el subperfil, y el narrador de su propio enlace) ve todo; un colaborador solo ve lo que subió él. Cada subida de aporte lleva la huella de quien la hizo en la ruta (audio/aportes/<dueño>/<huella>/…, media/<dueño>/<huella>/…; u<id> cuenta, g<id> invitación personal), y los archivos de antes de las huellas se reconocen solo si aparecen en sus propios aportes. Así un aporte privado de otra persona nunca le llega a nadie más que a su autor y al dueño.Todo el que entra a Eco marca un checkbox con los Términos y la Política de Privacidad, y el servidor lo exige (acceptTerms === true):
users.created_at es la fecha de registro; users.terms_accepted_at y users.terms_version son la aceptación.colaborar.html): invitados.created_at (cuando lo invitaron), invitados.terms_accepted_at y invitados.terms_version.app.html?codigo=): bitacoras.narrador_terms_accepted_at y bitacoras.narrador_terms_version.consentimientos (tipo, id de quien aceptó, bitácora, versión, fecha) y como línea [consentimiento] tipo=… sujeto=… version=… aceptado=… en el log; el registro de una cuenta deja además [registro] userId=… registrada=…. La tabla no lleva nombre, correo ni teléfono, y no se borra al borrar la cuenta (es la prueba de la autorización).TERMS_VERSION en server.js./api/register (clave SETUP_KEY) no pasan por ningún formulario, así que no tienen fecha de aceptación.La primera vez que alguien abre su chat ve una intro (public/eco-intro.js + eco-intro.css): (1) si hay un mensaje de regalo, lo oye con la voz de quien se lo dejó; (2) qué es Eco en una frase y los dos botones (tu turno / turno de Eco); (3) dos pruebas de micrófono ("di tu nombre" y "di hola Eco", con /api/transcribe); (4) empezar. Se puede saltar y se repite desde Cuenta → Opciones avanzadas → Tutorial.
users.intro_vista_at / bitacoras.intro_vista_at (POST /api/intro/visto); GET /api/intro dice si está pendiente (?repetir=1 la devuelve siempre).public/perfilar.html (hasta 2 minutos). Rutas GET/POST/DELETE /api/subprofiles/:id/mensaje-regalo; el audio queda en audio/<id del subperfil>/regalo/ (lo abren quien administra y la persona del enlace, nadie más); columnas bitacoras.mensaje_regalo_url/_de/_at. Un mensaje nuevo vuelve a dejar la intro pendiente y borra el archivo anterior.eco-tour.js) espera a que termine la intro (document.documentElement.dataset.ecoIntro).test/intro-regalo.smoke.js (rutas), npm run test:intro y npm run test:perfilar-regalo (navegador, micrófono falso).Cada día (Vercel Cron → GET /api/cron/reminders, 14:00 UTC = 9:00 Colombia) la app busca a quién le toca un recordatorio para seguir contando su historia:
RESEND_API_KEY, RESEND_FROM). El usuario prende/apaga esto y elige cada cuántos días desde el menú de Cuenta.wa.me por persona que abre el chat con el mensaje ya redactado; el dueño abre cada uno y toca enviar desde su WhatsApp Business. El resumen llega por dos canales (los dos opcionales, se pueden usar juntos):| Variable | Para qué |
|---|---|
CALLMEBOT_PHONE | Número del dueño (con código de país) al que llega el resumen por WhatsApp. |
CALLMEBOT_APIKEY | Clave de CallMeBot. Se obtiene una sola vez: agendas el número que indica su página, le mandas por WhatsApp I allow callmebot to send me messages, y te responde con la clave. Servicio gratuito de terceros, uso personal, con tope de mensajes al día — un resumen diario está muy por debajo. |
WHATSAPP_DIGEST_EMAIL | Correo del dueño para recibir el mismo resumen por correo (necesita RESEND_API_KEY). Sirve de respaldo si CallMeBot falla. |
Si no se configura ninguno de los tres, el cron sigue mandando los recordatorios por correo normalmente y el resumen de WhatsApp no se manda.
Cargar los números: en /admin.html, sección "Recordatorios por WhatsApp", hay una tabla para escribir el teléfono de cada perfil (cuenta o subperfil) y marcar quién quiere el recordatorio por ese canal. Los usuarios también lo pueden poner ellos desde su perfil. Quien tiene WhatsApp activo no recibe el recordatorio por correo, para no avisar dos veces. Desde esa misma sección se puede ver a quién le toca hoy y disparar el resumen a mano (botón "Enviar el resumen ahora"). Para probar sin esperar los 14 días de inactividad, marca "ignorar los días de espera" — incluye a cualquier perfil con número + opt-in activo (respeta igual a quien haya apagado los recordatorios).
Con WHATSAPP_TOKEN y WHATSAPP_PHONE_NUMBER_ID configurados, el cron diario le escribe directo a cada persona con opt-in + teléfono, usando una plantilla aprobada por Meta. Lo que Meta rechaza (número inválido, sin WhatsApp, plantilla caída) cae en el resumen manual de arriba, y solo se registra en whatsapp_reminder_log lo que Meta aceptó (así cada persona espera su frecuencia). Máximo 200 por corrida. El botón de prueba de /admin nunca escribe a los usuarios, solo arma el resumen.
| Variable | Para qué |
|---|---|
WHATSAPP_TOKEN | Token permanente de un usuario del sistema de Meta Business (no el token temporal de 24 h del panel de pruebas). |
WHATSAPP_PHONE_NUMBER_ID | ID del número de WhatsApp Business (no es el teléfono: es el ID que muestra el panel de WhatsApp en Meta for Developers). |
WHATSAPP_TEMPLATE_NAME | Nombre de la plantilla aprobada. Por defecto recordatorio_bitacora. |
WHATSAPP_TEMPLATE_LANG | Idioma con el que se creó la plantilla. Por defecto es_CO. |
WHATSAPP_API_VERSION | Versión de la API de Graph. Por defecto v21.0. |
Pasos (una sola vez):
recordatorio_bitacora (idioma Español - Colombia, categoría Utilidad; Meta puede reclasificarla como Marketing, que cuesta más). Cuerpo, con una sola variable:
Hola {{1}}, ¿cómo vas? Hace unos días no grabas una historia en tu bitácora. Cuando tengas un ratico, entra y cuéntame algo, no tiene que ser largo. Si prefieres no recibir estos avisos, apágalos en tu perfil. Un abrazo.
y esperar la aprobación.whatsapp_business_messaging y whatsapp_business_management) y pegar las variables en Vercel./admin, la sección de WhatsApp debe mostrar "Envío automático" en verde. Probar con tu propio número (opt-in + teléfono) y mirar el JSON del cron (whatsappApi).El número tiene que traer código de país; un celular colombiano de 10 dígitos que empieza por 3 se completa con 57. Todavía no se procesan las respuestas de la gente (no hay webhook): quien no quiere más avisos los apaga en su perfil.
Antes de tocar la Raspberry Pi, prueba el encoder en tu Mac:
http://localhost:3000/app.html en Chrome y abre la consola (Cmd+Option+J).document.addEventListener('keydown', (e) => console.log('Tecla detectada:', e.key));
Tecla detectada: Enter o Tecla detectada: 1.Enter, abre app.html, busca la línea const BUTTON_KEY = 'Enter'; y reemplázala por la tecla que detectaste (por ejemplo '1' o ' ' para espacio).Con esto ya puedes probar toda la charla apretando solo el botón físico, sin tocar la pantalla.
bitacora-viva a la Pi (por USB, o scp, o clonando un repo si lo subes a GitHub).npm install y prueba npm run dev para confirmar que arranca igual que en tu Mac.http://localhost:3000/app.html (no a la raíz, que ahora muestra la landing pública), configurado para abrir solo al encender.Avisame cuando tengas la Pi en mano y armamos el paso 2 en detalle (el service de systemd y el modo kiosco exactos dependen de qué versión de Raspberry Pi OS instales).
ESPECULAR_MS/api/nextespeculativo: trueSILENCE_MSPOST /api/next/guardarrateLimitTurno, 90/min por IP): transcribir, /api/next, /api/next/guardar, subir el audio y enlazarlo. Con los 30/min generales una charla ágil (o varias personas en la misma red del celular) se topaba con el 429 y la app mostraba "problema en el servidor". El cliente además reintenta una vez un 429 o un 5xx antes de mostrar el error, y el mensaje trae el código HTTP.RECORTE_FIN_VOZ_MS, 120 ms, en app.html): algunos audios de voz terminan con un poco de estática después de la última palabra y en el celular se oía justo al final de lo que dice la IA. Solo se recorta el último pedazo de cada respuesta. Si no es suficiente, probar ELEVENLABS_MODEL_ID=eleven_flash_v2_5 en Vercel para saber si la estática viene de v4 Turbo.