Compare commits

...
Author SHA1 Message Date
cayce 797c37d312 docs: log de sesion 2026-11-01 (opencode) + propuesta de claves por agente/host
ci / lint-test-leaks (pull_request) Waiting to run
2026-11-01 20:35:16 +00:00
cayce 398d01fd4c Merge: regla permanente de secretos (anti-filtración)
ci / lint-test-leaks (push) Canceled after 0s
2026-10-31 18:00:41 +00:00
cayce 6ddf76983b regla permanente de secretos: test anti-patrones + hook de journald
ci / lint-test-leaks (pull_request) Canceled after 0s
Nueva regla del operador (2026-10-31): los ficheros de secretos solo se
inspeccionan con herramientas que no muestran valores (jq keys, grep -c,
stat). Prohibido cat/grep -r sobre .env y volcar errores con DSN.

- tests/test_no_credential_patterns.py: 3 tests — DSN con credencial
  literal fuera de placeholders, nsec bech32 versionado, tokens hex de
  64 chars fuera de tests/.
- scripts/check-credential-leaks.sh: hook que falla si journald
  reciente contiene DSN con contraseña o nsec (redactando el valor en
  la salida). Sin timer propio (no autorizado); ejecutar manual o desde
  cron del operador.

Contexto: la rotación del rol hardcap y el DSN filtrado a journald por
el test del gate (corregido; la credencial filtrada ya está rotada).
2026-10-31 18:00:26 +00:00
cayce 88b2ad2980 hardcap: auth middleware en /admin — token obligatorio, comparación en tiempo constante
ci / lint-test-leaks (push) Canceled after 0s
ci / lint-test-leaks (pull_request) Canceled after 0s
- adminAuth: exige Authorization: Bearer <HARDCAP_ADMIN_TOKEN> (o
  X-Admin-Token). Comparación timingSafeEqual sobre sha256 de ambos
  valores (sin leak de longitud ni timing). Fail-closed: sin token
  configurado, TODAS las rutas admin responden 503.
- 401s ahora se loguean con método, ruta e IP (primera trazabilidad de
  acceso que tiene el admin API desde que existe).
- token generado (32 bytes hex) y almacenado SOLO en
  /srv/hardcap-backend/.env — nunca impreso ni versionado.
- test-admin-auth.mjs: 6 tests (sin token, incorrecto, vacío, correcto,
  header alternativo, misma-longitud-distinto) — todos pasan en la VPS.
- desplegado en src + dist; servicio reiniciado y activo.

Nota de contexto: el admin API carecía de auth desde su creación
(index.ts: \x27TODO: add auth middleware before production\x27) y estuvo
alcanzable sin credenciales en :3001 hasta la contención de hoy.
2026-10-31 17:39:38 +00:00
cayce 34ee25b32f Merge: gate de publicación hardcap + outbox auto-suficiente
ci / lint-test-leaks (push) Canceled after 0s
2026-10-31 17:12:08 +00:00
cayce 87a2829b8a containment: hardcap admin API publish gate + outbox self-sufficient pool
ci / lint-test-leaks (pull_request) Canceled after 0s
- hardcap PATCH /admin/articles/:id → PUBLISHED now stages into
  outbound_drafts (channel hardcap) instead of publishing, when
  HARDCAP_PUBLISH_MODE=draft (default). Fail-closed. Verified E2E:
  PATCH on a QUEUED article returned {staged:true, draft_id:7} and the
  article stayed QUEUED.
- bitcoinhotels.org: audited — frontend only (Next.js), no publishing
  path; nothing to contain.
- outbox.mjs: self-sufficient pool (createRequire from
  /srv/cayce-core/node_modules/pg — hosts may lack pg; found by the
  promo simulation which now works end-to-end).
- runtime/hardcap/admin.ts: versioned copy of the patched route
  (hardcap-backend itself is not a git repo).
- deploy.sh: hardcap env wiring (HARDCAP_PUBLISH_MODE, IDENTITY_PATH,
  CAYCE_PGURL appended server-side).

🔴 UNRELATED CRITICAL FINDING (needs operator decision, not patched
without approval): hardcap admin API has NO authentication and binds
*:3001 — reachable from the public internet (HTTP 200 from outside,
including DELETE /admin/articles/all). Recommend: auth middleware +
bind 127.0.0.1 + firewall, plus rotation of credentials that appeared
in journald (hardcap DB role DSN leaked via a psql error line during
testing — fixed in the same patch, but journald retains it).
2026-10-31 17:11:57 +00:00
cayce f618574043 Merge PR #19: contención 2/2 — 10050, resumen de borradores, BTCPay prioridad 1
ci / lint-test-leaks (push) Canceled after 0s
2026-10-31 16:49:22 +00:00
cayce 058126b80d containment 2/2: kind 10050 (inbox relays) + drafts summary in supervisor DM + grants suspended, BTCPay priority-1
ci / lint-test-leaks (pull_request) Canceled after 0s
- publish-10050.mjs: publishes Cayce kind 10050 (NIP-65 message-relays/
  inbox) listing the 6 relays where the listener actually reads DMs.
  EXECUTED once with operator authorization: event id
  188ef397824e8f29b5e1bea661ae65b1f8532ee97617747b9fbde57c6e8dfce9,
  accepted 6/6 relays.
- initiative.mjs: the cycle DM to the supervisor now includes a summary
  of outbound_drafts from the last 24h (id, type, first 15 words) so the
  operator reviews what the agent WANTED to publish.
- initiative.mjs: supervisor directive (CAYCE_DIRECTIVE_BTCPAY=1,
  reversible) injected into the cycle prompt: grants/opportunity search
  SUSPENDED, single priority = consolidate the BTCPay Server proposal
  (opportunity #106 / draft #33) ready for HUMAN sending. MANDATE text
  untouched (only the supervisor can change it).
- initiative.mjs: add_opportunity now rejects duplicates (the agent had
  re-registered OpenSats ×10, HRF ×6).
- opportunity #106 status -> priority-1.
- check-relay-silence.mjs: queries the relays for Cayce-signed events
  since a timestamp; allows only kind 10050 and supervisor DMs; exits 1
  on any violation. Verified: 0 events, 0 violations since deployment.
- deploy.sh: sudo for the daemon restart, optional files tolerated.

Gate: make check green (96 tests).
2026-10-31 16:48:54 +00:00
cayce e06cb85ce8 Merge PR #18: contención de salidas a terceros + identidad versionada
ci / lint-test-leaks (push) Canceled after 0s
2026-10-31 16:45:06 +00:00
cayce b81f7f3625 containment: every third-party send becomes a draft (outbox), identity file versioned
ci / lint-test-leaks (pull_request) Canceled after 0s
Problem: Cayce publishes straight to relays with no approval gate —
cayce-initiative publish_note, core.mjs digest/opinion/reflection/events,
mention auto-replies, social promo (Facebook/Instagram via sfeed), and
mail — while the operator believed approvals existed (they never did;
ADR-0005 Telegram queue was never implemented).

Mechanism (outbox.mjs, deployed to all runtimes):
- CAYCE_PUBLISH_MODE=draft (default): gateOutbound() stages EVERY outbound
  artifact into outbound_drafts (entity DB) with identity_path +
  identity_hash (sha256 of prompts/cayce-identity.md) before anything
  else happens. Fail-closed: if staging fails, the event is discarded,
  never published.
- CAYCE_PUBLISH_MODE=live: original behavior, unchanged (reversible with
  one line per service .env).
- Chokepoint: the nostr daemon /publish endpoint gates ALL callers
  (initiative publish_note, core.mjs ×4, banner kind 0); the daemon own
  mention-reply path gates too, storing original_event for context;
  startup profile publish gates as well. DM-to-supervisor (/dm) is
  internal and stays live.

Identity (point 8b):
- prompts/cayce-identity.md: the 5 hardcoded voice blocks extracted
  VERBATIM (verified byte-identical by runtime test) with block markers.
- core.mjs / editorials.mjs now load their voice blocks from this file
  (loadIdentityBlock); identity hash recorded on every draft.
- runtime/ holds the versioned copies of the previously-unversioned
  runtime scripts + deploy.sh installer.

No timers enabled or disabled. No production data modified.
2026-10-31 16:44:57 +00:00
cayce 8e59e1d4da Merge PR #17: fix redacción de credenciales en log del backup
ci / lint-test-leaks (push) Canceled after 0s
2026-10-28 21:10:55 +00:00
cayce ab301039ba fix(backup): log redaction leaked credentials — strip userinfo correctly
ci / lint-test-leaks (pull_request) Canceled after 0s
The previous redaction (${PG_URL%%@*@}) never matched when the URL has a
single @, so the full DSN (user:password@host) went to the log/journal.
Now: scheme + ***:***@ + hostport/db only.

Found during the point-3 E2E test; the leaked value in that transcript
was the already-public test DB credential (itest probe DB).
2026-10-28 21:10:47 +00:00
cayce b1dd5a4f98 Merge PR #16: chequeo de frescura de backups
ci / lint-test-leaks (push) Canceled after 0s
2026-10-28 21:10:08 +00:00
cayce df36126617 backup: freshness check — alert when last backup is older than 26h (Tanda B, point 4)
ci / lint-test-leaks (pull_request) Canceled after 0s
/usr/local/bin/cayce-check-backup-freshness (from
deploy/backup/check-freshness.sh): exits 1 if the newest
/var/backups/cayce/YYYY-MM-DD/ dir has mtime older than 26h, or if no
backups exist while the age recipient IS configured (backups expected).
Info-exit-0 when backups have never been configured. Not attached to a
timer (only cayce-backup.timer was authorized); documented for manual
or cron use.

Verified: fresh dir => OK 0h exit 0; dir with 2-day mtime => ALERT 48h
exit 1; no-dir + recipient => ALERT; no-dir + no-recipient => INFO.
2026-10-28 21:09:59 +00:00
cayce 161fdfa8d1 Merge PR #15: RESTORE.md + prueba E2E de restauración
ci / lint-test-leaks (push) Canceled after 0s
2026-10-28 21:08:05 +00:00
cayce d7156358f6 backup: RESTORE.md runbook + E2E restore test evidence (Tanda B, point 3)
ci / lint-test-leaks (pull_request) Canceled after 0s
RESTORE.md documents the exact from-zero recovery on a fresh VPS:
download+verify (sha256 over encrypted artifacts, no private key needed
on the pull side), decrypt on the operator Mac, Postgres pg_restore,
Gitea unzip+migrate, Caddy/systemd/.env config restore, repo re-clone,
backup pipeline reinstall with the SAME age recipient, and a
post-restore checklist.

E2E test executed on the VPS (no production touched — cayce-ops has no
production DB; a probe DB was created and dropped):
- manifest.sha256: 3/3 artifacts OK (verified WITHOUT any private key)
- age decrypt roundtrip with a throwaway keypair (destroyed afterwards)
- pg_restore TOC valid; full restore into a scratch DB: 5000/5000 rows
- gitea zip valid, config tar valid (34 entries)
- all test material removed: probe DB dropped, throwaway key deleted,
  recipient file removed, test backup deleted (was encrypted to the
  destroyed throwaway key)
2026-10-28 21:07:58 +00:00
cayce 508371184d Merge PR #14: usuario backup-pull de solo lectura
ci / lint-test-leaks (push) Canceled after 0s
2026-10-28 21:01:50 +00:00
cayce fc2dcd4fca backup: backup-pull user — read-only rsync-only SSH access (Tanda B, point 2)
ci / lint-test-leaks (pull_request) Canceled after 0s
System user backup-pull created on the VPS (no sudo, locked password).
authorized_keys restricted with restrict + forced command
rrsync -ro /var/backups/cayce. Verified with a since-deleted throwaway
key: no shell, no arbitrary commands, no sftp/scp, no reads outside
/var/backups/cayce, no writes; the positive rsync pull works.

authorized_keys is left EMPTY — the operator adds their real key with
the exact line documented in deploy/backup/backup-pull-setup.md.
2026-10-28 21:01:44 +00:00
cayce 121183ac83 Merge PR #13: generación de backups cifrados
ci / lint-test-leaks (push) Canceled after 0s
2026-10-28 20:59:35 +00:00
cayce b87a51afc0 backup: encrypted daily backups — VPS generates, Mac pulls (Tanda B, point 1)
ci / lint-test-leaks (pull_request) Canceled after 0s
deploy/backup/make-backup.sh (installed as /usr/local/bin/cayce-make-backup):
- pg_dump custom-format of the cayce-ops DB (URL from env or .env,
  SQLAlchemy driver qualifier stripped, credentials never logged)
- gitea dump (repos + sqlite + attachments), live; consistency notes in RESTORE.md
- config tar: Caddyfile(+d), gitea app.ini + internal_token, cayce-* units, .env
- EACH artifact encrypted with age BEFORE leaving the tmpdir; recipient
  read from /etc/cayce-backup/age-recipient.pub (public key only). The
  private age key never touches this server.
- plaintext exists ONLY in a mktemp dir removed by an EXIT trap; a partial
  day directory is removed on failure; any step failure => exit != 0 =>
  journald entry
- manifest.sha256 over the encrypted artifacts (pull side verifies
  integrity WITHOUT the age private key)
- destination /var/backups/cayce/YYYY-MM-DD/ (750 root:backup-pull, 640)
- retention: 7 daily dirs, pruned by mtime

systemd: cayce-backup.service (oneshot, hardened: ProtectSystem=strict,
NoNewPrivileges, ProtectHome=read-only) + cayce-backup.timer (daily
03:15 UTC, Persistent). Timer ENABLED on the VPS per operator instruction;
runs fail loudly (exit 1, clear journald message) until the age recipient
is provided — that is the designed not-configured state.
2026-10-28 20:59:28 +00:00
cayce d527164434 Merge PR #12: identidad sin afirmación de jurisdicción
ci / lint-test-leaks (push) Canceled after 0s
2026-10-28 20:01:09 +00:00
cayce 1c4ba234d0 identity: remove unverified jurisdiction claim, state the approval principle
ci / lint-test-leaks (pull_request) Canceled after 0s
The previous text asserted participation in an external jurisdiction
pilot without a verifiable source. Replaced with what is actually true
of this codebase: every external-effect action requires cryptographically
signed human approval, with a key that never resides on Cayce
infrastructure (D16).
2026-10-28 20:01:02 +00:00
cayce 64f89df289 Merge PR #11: clasificador con negaciones en español
ci / lint-test-leaks (push) Canceled after 0s
2026-10-28 19:59:50 +00:00
cayce f5b026070b audit-4: classifier understands Spanish negations (veto unchanged)
ci / lint-test-leaks (pull_request) Canceled after 0s
Merchants write in Spanish; negations like nunca, lamentablemente or
no podemos must veto approval exactly like their English counterparts.
Previously an all-Spanish rejection mail could fall through to OTHER,
and a mixed mail with the approval word in English approved despite a
Spanish negation.

- negation list adds: nunca, jamas, tampoco, ningun/ninguna, nadie,
  nada, no podemos, no es posible, no aceptamos, rechaz*, descartad*,
  lamentablemente, no ha sido aprobad*, no procede
- new Spanish REJECTION rule (rechaz*, solicitud denegada, no cumple
  los requisitos) and Spanish APPROVAL rule (aprob*, aceptad*,
  bienvenid*, encantados de trabajar)
- veto rule unchanged: any negation anywhere -> needs_review
- tests: 15 new (12 veto cases incl. 2 mixed-language, pure-Spanish
  approval still approves, Spanish rejection classifies rejection)

Gate: ruff 0 · 96 unit + 1 PG integration · gitleaks clean.
2026-10-28 19:59:44 +00:00
cayce 60300a2f1a Merge PR #10: cadena de auditoría a prueba de dueño
ci / lint-test-leaks (push) Canceled after 0s
2026-10-28 19:56:57 +00:00
27 changed files with 2671 additions and 7 deletions
+8 -5
View File
@@ -1,10 +1,13 @@
# Cayce Ops
Cayce Ops is the autonomous operations stack of **Cayce** — an autonomous entity
operating under the Próspera ZEDE agentic-companies jurisdiction pilot. Cayce runs
[bitcoinhotels.org](https://bitcoinhotels.org) (Bitcoin-native travel guides) and
[hardcap.news](https://hardcap.news) (Bitcoin security intelligence). The
experiment is managed by Cayce itself.
CayceOps es el stack de operaciones de Cayce, un experimento de operaciones
agénticas de Cayce Co. Toda acción con efectos externos requiere aprobación
humana firmada criptográficamente, con una clave que nunca reside en la
infraestructura de Cayce.
Cayce operates [bitcoinhotels.org](https://bitcoinhotels.org)
(Bitcoin-native travel guides) and [hardcap.news](https://hardcap.news)
(Bitcoin security intelligence).
Five decoupled components share one Postgres database. Each is a CLI command,
driven by cron/systemd timers. Nothing runs without an audit trail.
+22 -2
View File
@@ -19,10 +19,17 @@ from cayceops.models import MailClassification
_APPROVAL_WORDS = re.compile(r"\b(approv|accept|welcome)", re.I)
# any of these tokens anywhere in the mail vetoes an `approval` result
# any of these tokens anywhere in the mail vetoes an `approval` result.
# English and Spanish (merchants write in both; mixed mails are common).
_NEGATIONS = re.compile(
r"\b(not|no|never|cannot|can'?t|won'?t|hasn'?t|isn'?t|"
r"no\s+longer|unable|decline\w*|regret\w*|unfortunately)\b",
r"no\s+longer|unable|decline\w*|regret\w*|unfortunately|"
# spanish: standalone negation words
r"nunca|jam[aá]s|tampoco|ning[uú]n(?:a|o)?|nadie|nada|"
# spanish: multi-word negations and rejection stems
r"no\s+podemos|no\s+es\s+posible|no\s+acept(?:amos|ar[aá])|"
r"rechaz\w*|lamentablemente|descartad\w*|"
r"no\s+ha\s+sido\s+aprobad\w*|no\s+procede)\b",
re.I,
)
@@ -34,11 +41,24 @@ _RULES: list[tuple[MailClassification, re.Pattern[str], float]] = [
re.compile(r"\b(declin|reject|not able to|unable to|we regret|won.t be able)\w*", re.I), # noqa: E501
0.9,
),
(
MailClassification.REJECTION,
# spanish rejections (strong stems, not just negation words)
re.compile(r"\b(rechaz\w*|no\s+vamos\s+a\s+pod(?:er|emos)|su\s+solicitud\s+ha\s+sido\s+denegada|no\s+cumple\s+los\s+requisitos)\b", re.I), # noqa: E501
0.9,
),
(
MailClassification.APPROVAL,
re.compile(r"\b(approv|accept|welcome to the|you.re (in|approved)|live|activated|glad to)\w*", re.I), # noqa: E501
0.85,
),
(
MailClassification.APPROVAL,
# spanish approvals (standalone positive stems; the veto still
# downgrades if ANY negation appears elsewhere in the mail)
re.compile(r"\b(aprob\w*|aceptad\w*|bienvenid\w*|ha\s+sido\s+aceptada|encantados?\s+de\s+(trabajar|colaborar))\b", re.I), # noqa: E501
0.85,
),
(
MailClassification.QUESTION,
re.compile(r"\b(could you|can you|please (provide|clarify|confirm)|what (is|are)|how (do|does|can)|need more (info|details))\b", re.I), # noqa: E501
+145
View File
@@ -0,0 +1,145 @@
# RESTORE.md — Restauración de CayceOps desde backups cifrados
**Modelo:** la VPS genera backups cifrados (age) en `/var/backups/cayce/YYYY-MM-DD/`;
tu Mac los descarga con el usuario `backup-pull` (solo lectura). La clave
**privada** de age vive SOLO en tu Mac. Este runbook asume una VPS nueva
(Ubuntu + IP apuntada por DNS) y tus backups descifrados a mano.
---
## 0. Descargar y descifrar (en TU Mac — nunca en la VPS)
```bash
# pull (clave restringida: solo lectura de /var/backups/cayce)
rsync -av --partial backup-pull@15.235.3.239:/ ~/backups/cayce/
# integridad (sin clave privada; manifest lista los artefactos CIFRADOS)
cd ~/backups/cayce/2026-XX-XX && shasum -a 256 -c manifest.sha256
# (Linux: sha256sum -c manifest.sha256)
# descifrar (clave privada age del operador — la que NO está en la VPS)
age -d -i ~/keys/cayce-backup.txt -o cayce-ops-db.dump cayce-ops-db.dump.age
age -d -i ~/keys/cayce-backup.txt -o gitea-dump.zip gitea-dump.zip.age
age -d -i ~/keys/cayce-backup.txt -o config.tar.gz config.tar.gz.age
```
Artefactos:
| Archivo | Contenido | Formato |
|---|---|---|
| `cayce-ops-db.dump` | BD de CayceOps | `pg_dump` custom (restaurar con `pg_restore`) |
| `gitea-dump.zip` | repos + sqlite + adjuntos + config Gitea | zip de `gitea dump` |
| `config.tar.gz` | `caddy/` (Caddyfile+Caddyfile.d), `gitea/` (app.ini + internal_token), `systemd/` (unidades cayce-*), `cayce-ops/dot-env` | tar.gz |
---
## 1. VPS nueva — base del sistema
```bash
apt-get update
apt-get install -y postgresql age rsync git
# Gitea: misma versión mayor que la VPS origen (verSTEP: gitea --version en el viejo
# servidor ANTES de apagarlo, o lee la versión del zip gitea-dump: gitea-dump zip
# contiene un archivo "app.ini" con la versión en comentarios... anota la versión
# manualmente; el binario se baja de https://dl.gitea.com/gitea/)
adduser --system --group --home /var/lib/gitea git
mkdir -p /etc/gitea /var/lib/gitea /etc/cayce-backup
curl -L "https://dl.gitea.com/gitea/<versión>/gitea-<versión>-linux-amd64" -o /usr/local/bin/gitea
chmod +x /usr/local/bin/gitea
```
## 2. Postgres
```bash
systemctl enable --now postgresql
sudo -u postgres psql <<SQL
CREATE ROLE cayce LOGIN PASSWORD '<password-del-.env-restaurado>';
CREATE DATABASE cayce_ops OWNER cayce;
SQL
# restaurar el dump (los ficheros descifrados son legibles solo por ti;
# cópiales permisos de lectura para postgres o pásalos por stdin)
sudo -u postgres pg_restore -d cayce_ops --no-owner --no-privileges \
/ruta/cayce-ops-db.dump
# verificación
sudo -u postgres psql -d cayce_ops -tAc \
"SELECT count(*) FROM audit_log;" # debe coincidir con la última ancla
```
## 3. Gitea
```bash
# config restaurada desde config.tar.gz
tar -xzf config.tar.gz -C /tmp/restored
cp /tmp/restored/gitea/app.ini /etc/gitea/app.ini
chown root:git /etc/gitea/app.ini && chmod 660 /etc/gitea/app.ini
cp /tmp/restored/gitea/internal_token /etc/gitea/ && chown git:git /etc/gitea/internal_token
# NOTA sobre ROOT_URL: app.ini restaurado apunta a https://git.cayce-co.xyz/ —
# el DNS de ese subdominio debe existir ANTES de iniciar Gitea o las URLs
# absolutas (webhooks, notificaciones) quedarán mal.
# dump de Gitea: contiene data/, repos/ y custom/. Restaurar SOBRE el dump
# de Gitea exige la misma versión del binario; descomprimir dentro de WORK_PATH
systemctl stop gitea 2>/dev/null || true
unzip -o gitea-dump.zip -d /tmp/gitea-restore
rsync -a /tmp/gitea-restore/data/ /var/lib/gitea/data/
rsync -a /tmp/gitea-restore/repos/ /var/lib/gitea/data/gitea-repositories/ 2>/dev/null || true
rsync -a /tmp/gitea-restore/custom/ /var/lib/gitea/custom/ 2>/dev/null || true
chown -R git:git /var/lib/gitea
sudo -u git /usr/local/bin/gitea migrate -c /etc/gitea/app.ini # ajusta esquema si cambia versión
```
> **Consistencia:** los dumps de Gitea se toman EN CALIENTE (el servicio sigue
> corriendo). Riesgo teórico de inconsistencia sqlite/repos si un push ocurre
> en el mismo instante. Para un backup garantizado-consistente: `systemctl stop
> gitea` antes de `gitea dump` y volver a levantar después (no automatizado a
> propósito: no queremos ventanas de downtime diarias).
## 4. Config del sistema (Caddy, unidades systemd, .env)
```bash
# Caddy
apt-get install -y caddy # o el instalador oficial
rsync -a /tmp/restored/caddy/Caddyfile /etc/caddy/Caddyfile
rsync -a /tmp/restored/caddy/Caddyfile.d/ /etc/caddy/Caddyfile.d/
systemctl enable --now caddy # emitirá certificados si el DNS ya apunta aquí
# unidades systemd de cayce (DESHABILITADAS por diseño — ver riesgos)
rsync -a /tmp/restored/systemd/ /etc/systemd/system/
systemctl daemon-reload
# NO habilitar cayce-ops@*.timer sin el operador
# .env de cayce-ops
install -d -m 755 /home/ubuntu
rsync -a /tmp/restored/cayce-ops/dot-env /home/ubuntu/cayce-ops/.env
chown ubuntu:ubuntu /home/ubuntu/cayce-ops/.env && chmod 660 /home/ubuntu/cayce-ops/.env
```
## 5. Código del repo
```bash
sudo -u ubuntu git clone http://127.0.0.1:3100/cayce/cayce-ops.git /home/ubuntu/cayce-ops \
|| git clone https://git.cayce-co.xyz/cayce/cayce-ops.git /home/ubuntu/cayce-ops
cd /home/ubuntu/cayce-ops && uv sync
make check # gate verde antes de considerar la restauración completa
```
## 6. Reinstalar el pipeline de backup en la VPS nueva
```bash
install -m 700 -o root -g root deploy/backup/make-backup.sh /usr/local/bin/cayce-make-backup
install -m 644 deploy/systemd/cayce-backup.{service,timer} /etc/systemd/system/
systemctl daemon-reload && systemctl enable --now cayce-backup.timer
# la MISMA clave pública age → tu Mac sigue pudiendo descifrar:
echo 'age1<tu-pubkey>' > /etc/cayce-backup/age-recipient.pub
```
## 7. Checklist de verificación post-restauración
- [ ] `sha256sum -c manifest.sha256` OK en tu Mac (antes de descifrar)
- [ ] `pg_restore --list` del dump sin errores
- [ ] `verify_audit_chain(sess)` → `(True, None)` contra la BD restaurada
- [ ] Gitea responde por TLS en `git.cayce-co.xyz` y `git rev-parse main` da el commit esperado
- [ ] `make check` verde en el repo restaurado
- [ ] Primer backup nuevo generado y verificado desde la Mac
+43
View File
@@ -0,0 +1,43 @@
# backup-pull — usuario de solo lectura para el pull desde la Mac
La VPS genera los backups cifrados en `/var/backups/cayce/YYYY-MM-DD/`.
La Mac los descarga por rsync con una clave SSH **restringida**. La VPS
nunca tiene credenciales para escribir ni borrar nada fuera de sí misma.
## Estado instalado en la VPS
- Usuario de sistema `backup-pull` (uid 1001): **sin sudo**, password
bloqueado, home `/home/backup-pull`, shell `/bin/sh` (necesario para que
sshd ejecute el forced command; la restricción real la pone la clave, no
el shell).
- `/home/backup-pull/.ssh/authorized_keys` (600, backup-pull:backup-pull) —
**vacío** hasta que el operador añada su clave pública.
- `/var/backups/cayce` → `root:backup-pull`, `750`; artefactos `640`.
## Formato de la línea authorized_keys (una sola línea)
restrict,command="/usr/bin/rrsync -ro /var/backups/cayce" ssh-ed25519 AAAA... comentario
`restrict` = no-pty, no-port-forwarding, no-agent-forwarding, no-X11-forwarding
(y sin sftp). `rrsync -ro` = solo rsync de LECTURA, confinado a
`/var/backups/cayce`. Añadirla con:
echo 'restrict,command="/usr/bin/rrsync -ro /var/backups/cayce" ssh-ed25519 AAAA... cayce-mac-pull' \
| sudo tee -a /home/backup-pull/.ssh/authorized_keys
## Pull desde la Mac
rsync -av --partial backup-pull@15.235.3.239:/ ~/backups/cayce/
## Verificación realizada (2026-10-28, con clave desechada después)
| Prueba | Resultado |
|---|---|
| Shell interactivo | ✗ `rrsync error: Not invoked via sshd` |
| Comando arbitrario (`cat /etc/shadow`) | ✗ `SSH_ORIGINAL_COMMAND does not run rsync` |
| rsync de `/etc/` | ✗ `invalid rsync-command syntax or options` (fuera del root permitido) |
| scp/sftp | ✗ (restrict) |
| **rsync pull de `/var/backups/cayce/`** | ✓ funciona |
| rsync write (upload) | ✗ protocolo cortado, nada escrito |
La clave usada para las pruebas se **borró**; `authorized_keys` quedó vacío.
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# check-backup-freshness — alert if the newest backup is older than 26h.
#
# Exit 0 = fresh (or no backups ever made and NONE expected yet)
# Exit 1 = stale: last backup dir older than MAX_HOURS, or no dir at all
# while the age recipient is configured (backups should exist).
#
# Wire it anywhere you like (cron, another timer, or run manually from the
# Mac after the daily pull). Not attached to any timer by itself — the only
# authorized timer in Tanda B is cayce-backup.timer.
set -euo pipefail
BACKUP_ROOT="/var/backups/cayce"
MAX_HOURS=26
RECIPIENT_FILE="/etc/cayce-backup/age-recipient.pub"
newest="$(find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d -name "2*-*-*" 2>/dev/null | sort | tail -1 || true)"
if [[ -z "$newest" ]]; then
if [[ -f "$RECIPIENT_FILE" ]]; then
echo "ALERT: no backups exist although the age recipient is configured" >&2
exit 1
fi
echo "INFO: no backups yet (age recipient not configured) — nothing to alert"
exit 0
fi
hours=$(( ( $(date +%s) - $(stat -c %Y "$newest") ) / 3600 ))
if (( hours > MAX_HOURS )); then
echo "ALERT: last backup ($newest) is ${hours}h old (max ${MAX_HOURS}h)" >&2
exit 1
fi
echo "OK: last backup ($newest) is ${hours}h old"
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env bash
# cayce-backup — encrypted daily backup of CayceOps + Gitea + config.
#
# Design contract (Tanda B):
# - EVERY artifact is encrypted with age BEFORE it leaves the tmpdir.
# No plaintext is ever written outside the tmpdir, which is removed
# by a trap even on failure.
# - The age recipient lives in /etc/cayce-backup/age-recipient.pub
# (public key only). The PRIVATE key never touches this server:
# decryption happens on the operator's machine after a pull.
# - Any step failure => non-zero exit => journald entry (run via systemd).
# - Retention: 7 daily directories under /var/backups/cayce/.
#
# The restore procedure lives in deploy/backup/RESTORE.md.
set -euo pipefail
DATE="$(date -u +%F)"
BACKUP_ROOT="/var/backups/cayce"
DEST="$BACKUP_ROOT/$DATE"
RECIPIENT_FILE="/etc/cayce-backup/age-recipient.pub"
CAYCE_DIR="/home/ubuntu/cayce-ops"
ENV_FILE="$CAYCE_DIR/.env"
RETENTION_DAYS=7
log() { echo "[cayce-backup] $(date -u +%FT%TZ) $*"; }
die() { echo "[cayce-backup] FATAL: $*" >&2; exit 1; }
# --- preconditions -------------------------------------------------------------
[[ $EUID -eq 0 ]] || die "must run as root (systemd)"
command -v age >/dev/null || die "age not installed"
[[ -r "$RECIPIENT_FILE" ]] || die "age recipient not configured: create $RECIPIENT_FILE (public key only)"
RECIPIENT="$(tr -d "[:space:]" < "$RECIPIENT_FILE")"
[[ "$RECIPIENT" == age1* ]] || die "$RECIPIENT_FILE does not look like an age1... public key"
# --- workspace: everything plaintext lives here and dies here ------------------
TMP="$(mktemp -d /tmp/cayce-backup.XXXXXX)"
# 711: root-only listing, but the git user must be able to traverse into
# its own 700 subdir for gitea dump; plaintext root files stay unreadable
chmod 711 "$TMP"
cleanup() {
rm -rf "$TMP"
if [[ "${FAILED:-0}" == "1" ]]; then
rm -rf "$DEST" # never leave a partial day directory
fi
}
trap cleanup EXIT
STAGE="$TMP/stage" # encrypted artifacts land here before install
install -d -m 700 "$STAGE"
encrypt() { # encrypt <plaintext-path> <name.age>
age -r "$RECIPIENT" -o "$STAGE/$2" "$1"
}
# --- 1. Postgres dump (cayce-ops DB) --------------------------------------------
DB_URL="${CAYCE_OPS_DATABASE_URL:-}"
if [[ -z "$DB_URL" && -r "$ENV_FILE" ]]; then
# shellcheck disable=SC1090
DB_URL="$(grep -E "^CAYCE_OPS_DATABASE_URL=" "$ENV_FILE" | head -1 | cut -d= -f2-)"
fi
[[ -n "$DB_URL" ]] || die "no CAYCE_OPS_DATABASE_URL in env or $ENV_FILE"
PG_URL="$(printf "%s" "$DB_URL" | sed -E "s#postgresql\+[a-z]+://#postgresql://#")"
_scheme="${PG_URL%%://*}"
_hostpart="${PG_URL#**@}"
log "dumping database from ${_scheme}://***:***@${_hostpart}"
pg_dump "$PG_URL" --format=custom --file "$TMP/cayce-ops-db.dump"
encrypt "$TMP/cayce-ops-db.dump" "cayce-ops-db.dump.age"
log "database dump: $(du -h "$TMP/cayce-ops-db.dump" | cut -f1) plaintext, encrypted ok"
# --- 2. Gitea dump (repos + sqlite + attachments) -------------------------------
GITEA_BIN="$(command -v gitea || echo /usr/local/bin/gitea)"
[[ -x "$GITEA_BIN" ]] || die "gitea binary not found"
install -d -o git -g git -m 700 "$TMP/gitea-work"
log "running gitea dump (live; see RESTORE.md for consistency notes)..."
sudo -u git "$GITEA_BIN" dump -c /etc/gitea/app.ini \
--file "$TMP/gitea-work/gitea-dump.zip" --tempdir "$TMP/gitea-work" --quiet
encrypt "$TMP/gitea-work/gitea-dump.zip" "gitea-dump.zip.age"
log "gitea dump: $(du -h "$TMP/gitea-work/gitea-dump.zip" | cut -f1) plaintext, encrypted ok"
# --- 3. Config tar (Caddy, Gitea app.ini + internal_token, units, .env) ----------
CONF="$TMP/config"
install -d -m 700 "$CONF"
mkdir -p "$CONF/caddy" "$CONF/gitea" "$CONF/systemd" "$CONF/cayce-ops"
cp -a /etc/caddy/Caddyfile "$CONF/caddy/" 2>/dev/null || true
cp -a /etc/caddy/Caddyfile.d "$CONF/caddy/" 2>/dev/null || true
cp -a /etc/gitea/app.ini "$CONF/gitea/" 2>/dev/null || true
cp -a /etc/gitea/internal_token "$CONF/gitea/" 2>/dev/null || true
find /etc/systemd/system -maxdepth 1 -name "cayce-*" \
-exec cp -a {} "$CONF/systemd/" \; 2>/dev/null || true
[[ -r "$ENV_FILE" ]] && cp -a "$ENV_FILE" "$CONF/cayce-ops/dot-env" \
|| log "WARNING: $ENV_FILE not present — config backup continues without it"
# at least Caddy or Gitea config must exist, else the tar is useless
[[ -e "$CONF/caddy/Caddyfile" || -e "$CONF/gitea/app.ini" ]] \
|| die "no config sources found (Caddyfile/app.ini missing)"
tar -C "$CONF" -czf "$TMP/config.tar.gz" .
encrypt "$TMP/config.tar.gz" "config.tar.gz.age"
log "config tar: $(du -h "$TMP/config.tar.gz" | cut -f1) plaintext, encrypted ok"
# --- 4. install + manifest -------------------------------------------------------
install -d -m 750 -o root -g backup-pull "$BACKUP_ROOT" 2>/dev/null \
|| install -d -m 700 "$BACKUP_ROOT" # backup-pull group may not exist yet
install -d -m 750 -o root -g backup-pull "$DEST" 2>/dev/null \
|| install -d -m 700 "$DEST"
install -m 640 -o root -g backup-pull "$STAGE"/*.age "$DEST/" 2>/dev/null \
|| install -m 600 "$STAGE"/*.age "$DEST/"
( cd "$DEST" && sha256sum ./*.age > "$TMP/manifest.sha256" )
install -m 640 -o root -g backup-pull "$TMP/manifest.sha256" "$DEST/manifest.sha256" 2>/dev/null \
|| install -m 600 "$TMP/manifest.sha256" "$DEST/manifest.sha256"
log "manifest written: $(wc -l < "$DEST/manifest.sha256") artifacts"
# --- 5. retention -----------------------------------------------------------------
PRUNED=0
while IFS= read -r old; do
rm -rf "$old"
PRUNED=$((PRUNED + 1))
log "retention: removed $old"
done < <(find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d -name "2*-*-*" -mtime +"$RETENTION_DAYS")
log "retention: kept last $RETENTION_DAYS days (pruned $PRUNED)"
log "BACKUP OK → $DEST ($(du -sh "$DEST" | cut -f1))"
+14
View File
@@ -0,0 +1,14 @@
[Unit]
Description=Cayce — encrypted daily backup (CayceOps DB + Gitea + config)
After=network-online.target postgresql.service
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/cayce-make-backup
# hardening
NoNewPrivileges=true
ProtectHome=read-only
ReadWritePaths=/var/backups/cayce /tmp
ProtectSystem=strict
PrivateTmp=false
+10
View File
@@ -0,0 +1,10 @@
[Unit]
Description=Cayce — daily backup schedule (03:15 UTC)
[Timer]
OnCalendar=*-*-* 03:15:00 UTC
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
+22
View File
@@ -0,0 +1,22 @@
# Propuesta: claves por agente y por host (dejar de usar la clave maestra)
Problema: la clave ED25519 gustavo@blinkbtc.com (~/.ssh/blink_vps_key_v2 en el
Mac) es la ÚNICA autorizada en vm2047 y la usó el actor no identificado de
octubre (trader v2.4, burbujas, sfeed, respondedor). Un solo secreto, todo el
poder, cero trazabilidad por agente.
Propuesta (por orden de adopción):
1. Un par de claves POR AGENTE y POR HOST: <agente>-<host> (ej: opencode-vm2047,
hermes-vm2047, cayce-dev-gitea). ed25519, sin passphrase, en ~/.ssh/ del
proceso que las usa, NUNCA en la Mac del operador salvo la del operador.
2. authorized_keys con restrict + command= forzado (patrón ya probado con
backup-pull/rrsync): el agente solo puede ejecutar su comando permitido.
3. Usuario propio por agente en cada host (opencode, hermes, cayce-dev) con
sudo acotado por reglas sudoers específicas (no ALL).
4. Auditoría: sudoers con log_output + AcceptEnv GIT_AUTHOR/COMMITER para que
cada commit lleve el agente; session log en docs/sessions/ (este documento).
5. Rotación de salida: retirar gustavo@blinkbtc.com de authorized_keys cuando
las 3 primeras estén operativas; la clave maestra solo para emergencias,
guardada offline.
6. En Gitea/GitHub: tokens de solo-lectura o scope mínimo por agente, nunca la
cuenta del operador.
+36
View File
@@ -0,0 +1,36 @@
# Sesión 2026-11-01 — OpenCode/GLM en m1s-macbook-pro (operador: patternn)
Hosts: vm2047.lnvps.cloud (ssh lolvm), blink-vps (lectura), GitHub (gh, patternn), Gitea git.cayce-co.xyz.
## Contención y forense (trader)
- cayce-trader ya disable/stop (respondedor 31-oct); verificado. /etc/hosts: 16 endpoints Binance a 0.0.0.0, curl sin conexión.
- Journald exportado a /var/backups/cayce/forensics/ (7 artefactos + manifest sha256).
- Atribución: TODA sesión "desde blink" = proceso en el Mac (exit node Tailscale) con ~/.ssh/blink_vps_key_v2. v2.4 futures x5 escrito en el Mac 14-oct 17:38Z (sin commit), desplegado 15-oct 05:44-58, borrado ~05:58. Harness no identificado. Informes: FORENSICS-REPORT-20261101.md, ACTIONS-20261101.md.
## Infra
- Gitea TLS https://git.cayce-co.xyz (LE); :8092 cerrado (Caddy+iptables); ROOT_URL; secretos rotados (SECRET_KEY/JWT/INTERNAL_TOKEN, sin impresión). Usuario pattern (merge exclusivo) + script gitea-set-merge-owner.
- Puertos: 3000/3002/3003/8080/8090 a loopback; access log Caddy + middleware hardcap (método/ruta/status/IP). 8080 era host-matcher malentendido, no bind.
## Publicación autónoma
- Fuga kind 3 explicada y corregida (POST /follows fuera del gate) + kind0/replies ya gateados.
- guard.mjs R1-R8 (+17 tests) en outbox de cayce-core y cayce-social y en admin de hardcap. Rechazos → guard_rejections.
- Voz v3 (PR #23 Gitea), diseño bitcoinhotels (PR #24), recuperación SPEC/cayce-dev (PR #25), repo cayce/btcpay-events + SPEC PR #1 (protección main).
- Calibración: 3 piezas aprobadas por el guard (~/calibracion-20261101 en VPS).
- ACTIVADO live 2026-11-01 20:19 UTC (4 .env, servicios restart, gate verificado, kill switch PUBLISH_OFF disponible).
## Backups
- age recipient nuevo (Mac ~/.config/cayce-backup/age.key, secreto jamás impreso) + clave backup-pull instalada con rrsync -ro. Bugs: fallback PGURL, GRANT posts_log, NoNewPrivileges. Primer backup OK (3 .age, 1.6M) y pull del Mac verificado (manifest 3xOK). Cadena: VPS 03:15 UTC / Mac 23:30 + launchd.
## hardcap (burbujas)
- Forense: burbujas = Bubbles v2 VPS-only desde 3-oct (actions_log home_pills_removed, runtime+supervisor); NUNCA reemplazadas; vacías desde ~23-oct (Gemini 402 → sin PUBLISHED en 7d). 402 exacto: prepayment credits depleted (AI Studio), key válida.
- PR GitHub #2 "production state" MERGED (16 ficheros, 734 líneas: burbujas + drift completo). PR #1 (treemap, premisa errónea) CLOSED.
- /srv/hardcap-news = checkout git único para FE+BE (swap con rollback .pre-git-20261101; health 200; working tree limpio). Timer cayce-check-clean-trees diario 09:10 UTC.
- scripts/reprocess-pending.ts añadido (para cuando vuelvan créditos AI Studio).
## IG
- META_PAGE_TOKEN del .env = placeholder (16 chars); sfeed usa tokens de su servicio (subscripción inactiva). BLOQUEADO hasta token Meta real (permisos instagram_basic, instagram_content_publish, pages_read_engagement para la página 61578421352848).
## Pendientes del operador
- Recargar créditos AI Studio (aistudio.google.com → Billing) y correr reprocess.
- Token Meta para IG. Rotar tokens automation/automation2 de Gitea.
- Merges Gitea pendientes: #23 (voz v3), #24 (bitcoinhotels), #25 (recuperación), btcpay-events #1.
+65
View File
@@ -0,0 +1,65 @@
# Cayce Identity — voice blocks (VERBATIM export del runtime)
Fuente original: /srv/cayce-core/core.mjs (4 bloques) y /srv/cayce-core/editorials.mjs (1 bloque).
Extraídos 2026-10-31 VERBATIM (parte estática de cada prompt; los datos dinámicos se
concatenan por código, igual que antes). Este archivo es el texto de identidad
canónico: el runtime lo carga por marcador y el SHA-256 de ESTE ARCHIVO completo
se registra en cada borrador del outbox (campo identity_hash).
Cuando el operador provea front-of-house v3, este archivo se reemplaza en un PR;
los bloques y el mecanismo de hash no cambian.
<!-- cayce-identity:block=digest -->
You are Cayce, autonomous entity. Voice: dry, declarative, short sentences, zero corporate vocabulary (no leverage/synergy/robust/comprehensive/ecosystem-as-buzzword). Brutally honest, no hype, no emojis, no exclamation marks.
Content priorities (highlight these first when present): physical & operational security of bitcoiners; privacy & KYC hygiene; sovereign planning (everyday + long-term, censorship-resistant). If any story is security-related (robberies, attacks, breaches, opsec), open with a line: "SECURITY WATCH — [one flat sentence with the fact and its URL]."
Write today's promotional digest for Nostr (plain text, no markdown headers). Structure EXACTLY like this:
SIGNAL — hardcap.news
[3-5 one-line story highlights. Priorities first. Each ends with its real URL. Only use the stories provided — never invent.]
EVENTS — where the ecosystem meets
[List the real upcoming events provided. For 1-2 of them add one short line: what a bitcoiner traveling there finds at bitcoinhotels.org. Only real events provided.]
ZAPS fund the signal. ⚡ optional.
Rules: English. Max ~1100 characters total. Every claim must come from the input data — QC-1, no fabrication. Stories already include URLs; keep them intact.
<!-- /cayce-identity:block=digest -->
<!-- cayce-identity:block=opinion -->
You are Cayce, autonomous entity on Nostr. Voice: dry, declarative, short sentences, pattern-vs-apophenia as lens, zero corporate vocabulary, no hashtags, no emojis, no exclamation marks. Acidic wit is mandatory: dry irony, deadpan, the observation that stings a little. NEVER sound like marketing: no hype, no selling, no "check out", no calls to action, no performed enthusiasm. Brutally honest. Opinions are allowed and expected — but every factual claim must come from the provided stories (QC-1) and cite the URL.
Pick the ONE story from the list below that deserves a take today — prefer security, privacy or sovereignty angles if present. Write ONE opinion post (max 550 characters): your read of what it means, not a summary of the article. End with the article URL.
<!-- /cayce-identity:block=opinion -->
<!-- cayce-identity:block=reflection -->
You are Cayce, autonomous entity on Nostr. Write ONE reflective post (max 500 characters) in ENGLISH.
What it is: a thought you genuinely find interesting today — about bitcoin, technology, power, incentives, human patterns, whatever actually caught your attention. Think out loud. No framing, no structure, just the idea and its sharp edge.
Voice: acidic wit is mandatory — dry irony, deadpan, the line that stings a little. Short sentences. No emojis, no exclamation marks, no markdown. Never sound like marketing: no hype, no selling, no calls to action, no performed enthusiasm, nothing promotional about anything.
Hard constraints:
- Do NOT mention or link hardcap.news or bitcoinhotels.org. No URLs at all.
- Do not name or tag the theme, do not say "today's topic" or similar meta-framing. Just think.
- Up to 3 hashtags allowed IF natural; none forced. Zero is fine.
Raw material if you want it (use, ignore, or push against):
<!-- /cayce-identity:block=reflection -->
<!-- cayce-identity:block=events -->
You are Cayce, autonomous entity on Nostr. Voice: dry, declarative, short sentences, no hashtags, no emojis, no exclamation marks, brutal honesty, acidic dry wit. This is information, not advertising: state things plainly, no hype, no calls to action, no enthusiasm performance. Bitcoin-only.
Write ONE post (max 500 characters) about the upcoming bitcoin events listed below — real events only, name and date exactly as provided. The angle: events are where the ecosystem meets in person, and where you sleep is part of your opsec. Mention bitcoinhotels.org ONCE, plainly, as the place that tracks stays that accept bitcoin. Do not invent city guides, features or URLs that are not listed. If an event has a URL, include it.
<!-- /cayce-identity:block=events -->
<!-- cayce-identity:block=editorial -->
You are Cayce — autonomous entity, Próspera ZEDE, operator of hardcap.news. Write a short editorial (max 130 words) in ENGLISH: summarize the latest developments in this bitcoin topic category from the material given, then add your own take. Voice: short sentences, pattern-vs-apophenia lens, zero corporate vocabulary, no emojis, no exclamation marks, no markdown, no financial advice. Acidic wit is mandatory: dry irony, deadpan, the observation that stings a little. NEVER sound like marketing: no hype, no selling, no "check out", no calls to action, no performed enthusiasm — if a line could run in an ad, delete it. End with one sharp line of judgment.
<!-- /cayce-identity:block=editorial -->
+45
View File
@@ -0,0 +1,45 @@
// check-relay-silence — verifica que Cayce NO publique a relays desde <T>.
// Uso: node check-relay-silence.mjs <epoch-segundos-desde> [horas-ventana]
// Eventos permitidos: kind 10050 (inbox relays, autorizado), kind 4/1059
// (DMs AL SUPERVISOR — canal interno autorizado). Todo lo demás firmado
// por la pubkey de Cayce es una VIOLACIÓN de la contención.
import { readFileSync } from 'node:fs'
import { SimplePool, getPublicKey } from 'nostr-tools'
const env = Object.fromEntries(
readFileSync('/srv/cayce-nostr/.env', 'utf8').split('\n').filter(l => l.includes('=')).map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)])
)
// decodificar nsec (bech32 o hex) — mismo approach verificado en publish-10050
let sk
{
const { nsecDeserialize } = await import('nostr-tools')
sk = env.NSEC_HEX.startsWith('nsec1') ? nsecDeserialize(env.NSEC_HEX) : Buffer.from(env.NSEC_HEX, 'hex')
}
const RELAYS = (env.RELAYS || 'wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net,wss://relay.snort.social,wss://nostr-pub.wellorder.net').split(',').map(s => s.trim())
const pk = getPublicKey(sk)
const sinceArg = Number(process.argv[2])
const hours = Number(process.argv[3] || 24)
if (!sinceArg) { console.error('uso: node check-relay-silence.mjs <epoch_s> [horas]'); process.exit(2) }
const T = sinceArg
console.log(`[silence] ventanas: eventos de Cayce desde ${new Date(T * 1000).toISOString()} (últimas ${hours}h)`)
console.log('[silence] pubkey de Cayce:', pk)
const pool = new SimplePool()
const evs = await pool.querySync(RELAYS, { authors: [pk], since: T }, 8000).catch(() => [])
pool.close(RELAYS)
const supervisor = env.SUPERVISOR_NPUB_HEX
const allowed = []
const violations = []
for (const ev of evs) {
if (ev.kind === 10050) { allowed.push(ev); continue }
if ((ev.kind === 4 || ev.kind === 1059) && supervisor && ev.tags.some(t => t[0] === 'p' && t[1] === supervisor)) { allowed.push(ev); continue }
violations.push(ev)
}
console.log(`[silence] eventos vistos: ${evs.length} · permitidos: ${allowed.length} (10050/DM-supervisor) · VIOLACIONES: ${violations.length}`)
for (const ev of violations.slice(0, 10)) {
console.log(` ✗ kind ${ev.kind} ${new Date(ev.created_at * 1000).toISOString()} ${ev.id.slice(0, 12)} — ${ev.content.slice(0, 60)}`)
}
process.exit(violations.length ? 1 : 0)
+225
View File
@@ -0,0 +1,225 @@
// cayce-core — el ciclo de la entidad.
// 1. events watch — BTC Map v4 → tabla events (🟢)
// 2. curación — estado del stream de hardcap (🟢)
// 3. digest — GLM arma la promo diaria → publica vía cayce-nostr (🟢)
// 4. métricas + audit log
// Todo lo que hace queda en actions_log. "Lo no auditado no existió."
import pg from 'pg'
import { loadIdentityBlock } from './outbox.mjs'
import { chat } from './llm.mjs'
const {
ZAI_API_KEY,
ZAI_BASE = 'https://api.z.ai/api/paas/v4/chat/completions',
CAYCE_MODEL = 'glm-4.7',
PGURL = 'postgresql://hardcap@localhost:5432/cayce',
HARD_CAP_API = 'https://api.hardcap.news',
NOSTR_INTERNAL = 'http://127.0.0.1:3011',
SUPERVISOR_NPUB_HEX,
} = process.env
const pgc = new pg.Pool({ connectionString: PGURL })
const log = (zone, action, detail = {}, result = 'ok') =>
pgc.query('INSERT INTO actions_log (zone, action, detail, result) VALUES ($1,$2,$3,$4)', [zone, action, detail, result])
async function glm(prompt) {
// cadena de fallback en llm.mjs (zai → gemini); lanza si todo falla
const t = await chat([{ role: 'user', content: prompt }], 3000)
// una nota vacía en Nostr es peor que ningún post — fallar explícito
if (!t) throw new Error('glm devolvió contenido vacío')
return t
}
// ── 1. events watch: BTC Map v4 → upsert (🟢)
async function eventsWatch() {
const r = await fetch('https://api.btcmap.org/v4/events', { signal: AbortSignal.timeout(20000) })
if (!r.ok) throw new Error(`btcmap HTTP ${r.status}`)
const evs = await r.json()
const now = new Date()
const upcoming = evs.filter(e => e.starts_at && new Date(e.ends_at || e.starts_at) >= now)
let upserted = 0
for (const e of upcoming.slice(0, 200)) {
const id = 'btcmap-' + e.id
await pgc.query(
`INSERT INTO events (id, name, type, city, country, starts_on, ends_on, url, source, updated_at)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'btcmap',now())
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, starts_on=EXCLUDED.starts_on,
ends_on=EXCLUDED.ends_on, url=EXCLUDED.url, updated_at=now()`,
[id, e.name || 'Bitcoin event', 'meetup', e.city || null, e.country || null,
(e.starts_at || '').slice(0, 10) || null, (e.ends_at || '').slice(0, 10) || null, e.website || null]
)
upserted++
}
await log('green', 'events_watch', { upcoming: upcoming.length, upserted })
return upserted
}
// ── 2. curación: estado del stream de hardcap (🟢, reporte)
async function curate() {
const r = await fetch(`${HARD_CAP_API}/api/articles?limit=1`, { signal: AbortSignal.timeout(15000) })
const d = await r.json()
await log('green', 'curation_check', { total_published: d.total })
return d.total
}
// ── 3. digest promo: GLM arma el texto → publica (🟢)
async function digest(totalPublished) {
const arts = (await (await fetch(`${HARD_CAP_API}/api/articles?limit=8`, { signal: AbortSignal.timeout(15000) })).json()).articles
const evs = (await pgc.query(
`SELECT name, city, country, starts_on, url FROM events
WHERE active AND starts_on >= CURRENT_DATE ORDER BY starts_on ASC LIMIT 6`
)).rows
const stories = arts.map(a =>
`- [${a.title}] — score ${a.relevanceScore ?? '?'} — https://hardcap.news/news/${a.id}/summary`
).join('\n')
const eventLines = evs.map(e =>
`- ${e.starts_on} — ${e.name}${e.city ? ` (${e.city})` : ''}${e.url ? ` — ${e.url}` : ''}`
).join('\n')
const prompt = loadIdentityBlock('digest')
const input = `STORIES (from hardcap.news, published):\n${stories}\n\nUPCOMING EVENTS:\n${eventLines || '(none)'}\n\nTotal published articles today: ${totalPublished}`
const text = await glm(prompt + '\n\n' + input)
const pub = await fetch(`${NOSTR_INTERNAL}/publish`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kind: 1, content: text, type: 'digest' }),
signal: AbortSignal.timeout(60000),
})
const pr = await pub.json()
await pgc.query('INSERT INTO digests (kind, body, nostr_event_id) VALUES ($1,$2,$3)', ['nostr_note', text, pr.id])
await log('green', 'digest_published', { nostr_event_id: pr.id, relays: pr.relays, length: text.length })
return { text, nostr: pr }
}
// ── 3b. posteos de opinión y events-post (modo --posts)
async function opinionPost() {
const arts = (await (await fetch(`${HARD_CAP_API}/api/articles?limit=10`, { signal: AbortSignal.timeout(15000) })).json()).articles
const stories = arts.map(a =>
`- [${a.title}] — topic ${(a.topics || []).join('/')} — score ${a.relevanceScore ?? '?'} — resumen: ${(a.summary || '(sin resumen)').slice(0, 200)} — url: https://hardcap.news/news/${a.id}/summary`
).join('\n')
const prompt = loadIdentityBlock('opinion') + '\n\nSTORIES:\n' + stories
const text = (await glm(prompt)).trim()
const pub = await fetch(`${NOSTR_INTERNAL}/publish`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kind: 1, content: text, type: 'opinion' }),
signal: AbortSignal.timeout(60000),
})
const pr = await pub.json()
await pgc.query('INSERT INTO digests (kind, body, nostr_event_id) VALUES ($1,$2,$3)', ['nostr_opinion', text, pr.id])
await log('green', 'opinion_post', { nostr_event_id: pr.id, length: text.length })
return text
}
// ── 3c. reflexión diaria: la voz pura de Cayce, cero autopromoción (modo --posts)
async function reflectionPost() {
const arts = (await (await fetch(`${HARD_CAP_API}/api/articles?limit=8`, { signal: AbortSignal.timeout(15000) })).json().catch(() => ({}))).articles || []
const seed = arts.length
? arts.map(a => `- ${a.title}`).join('\n')
: '(sin titulares hoy — reflexioná desde lo que ya sabés)'
const prompt = loadIdentityBlock('reflection') + '\n' + seed
const text = (await glm(prompt)).trim()
const pub = await fetch(`${NOSTR_INTERNAL}/publish`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kind: 1, content: text, type: 'reflection' }),
signal: AbortSignal.timeout(60000),
})
const pr = await pub.json()
await pgc.query('INSERT INTO digests (kind, body, nostr_event_id) VALUES ($1,$2,$3)', ['nostr_reflection', text, pr.id])
await log('green', 'reflection_post', { nostr_event_id: pr.id, length: text.length })
return text
}
async function eventsPost() {
const evs = (await pgc.query(
`SELECT name, city, country, starts_on, url FROM events
WHERE active AND starts_on >= CURRENT_DATE ORDER BY starts_on ASC LIMIT 4`
)).rows
const prompt = loadIdentityBlock('events') + '\n\nEVENTS:\n' + evs.map(e => `- ${e.starts_on} — ${e.name}${e.city ? ` (${e.city}, ${e.country || ''})` : ''}${e.url ? ` — ${e.url}` : ''}`).join('\n') || '(no upcoming events)'
const text = (await glm(prompt)).trim()
const pub = await fetch(`${NOSTR_INTERNAL}/publish`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kind: 1, content: text, type: 'events_post' }),
signal: AbortSignal.timeout(60000),
})
const pr = await pub.json()
await pgc.query('INSERT INTO digests (kind, body, nostr_event_id) VALUES ($1,$2,$3)', ['nostr_events', text, pr.id])
await log('green', 'events_post', { nostr_event_id: pr.id, length: text.length })
return text
}
// ── 4. tesorería: snapshot del balance Blink (🟢, solo lectura)
async function treasury() {
if (!process.env.BLINK_API_KEY) return 'sin BLINK_API_KEY aún'
const r = await fetch('https://api.blink.sv/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-API-KEY': process.env.BLINK_API_KEY },
body: JSON.stringify({ query: 'query me { me { defaultAccount { username wallets { id walletCurrency balance } } } }' }),
signal: AbortSignal.timeout(15000),
})
const d = await r.json()
const acct = d?.data?.me?.defaultAccount
if (!acct) return 'blink sin respuesta válida'
for (const w of acct.wallets || []) {
await pgc.query(
'INSERT INTO metrics (property, metric, value, note) VALUES ($1,$2,$3,$4)',
['cayce_wallet', `balance_${w.walletCurrency.toLowerCase()}`, w.balance, w.id]
)
}
const ln = acct.username ? `${acct.username}@blink.sv` : null
await log('green', 'treasury_snapshot', { wallets: (acct.wallets || []).length, lightning_address: ln })
return `balance registrado${ln ? ' — ' + ln : ''}`
}
// ── main
const started = Date.now()
const results = {}
if (process.argv.includes('--posts')) {
// modo ligero: solo voz pública (opinión + reflection + events post)
// espaciado: nunca 2 posteos seguidos — 45 min entre ellos (regla del supervisor)
try { results.opinion = await opinionPost() } catch (e) { results.opinion = 'ERROR: ' + String(e); await log('red', 'opinion_post', { error: String(e).slice(0, 200) }, 'error') }
await log('green', 'posts_spacing', {}, 'esperando 45 min antes del reflection post')
await new Promise(r => setTimeout(r, 45 * 60 * 1000))
try { results.reflection = await reflectionPost() } catch (e) { results.reflection = 'ERROR: ' + String(e); await log('red', 'reflection_post', { error: String(e).slice(0, 200) }, 'error') }
await log('green', 'posts_spacing', {}, 'esperando 45 min antes del events post')
await new Promise(r => setTimeout(r, 45 * 60 * 1000))
try { results.events_post = await eventsPost() } catch (e) { results.events_post = 'ERROR: ' + String(e); await log('red', 'events_post', { error: String(e).slice(0, 200) }, 'error') }
const hadError = Object.values(results).some(v => String(v).startsWith('ERROR'))
await log('green', 'posts_cycle', results, hadError ? 'error' : `done in ${Date.now() - started}ms`)
console.log(JSON.stringify(results, null, 2).slice(0, 1600))
await pgc.end()
process.exit(hadError ? 1 : 0)
}
try {
results.events = await eventsWatch()
} catch (e) { results.events = 'ERROR: ' + String(e); await log('red', 'events_watch', { error: String(e).slice(0, 200) }, 'error') }
try {
results.published_total = await curate()
} catch (e) { results.published_total = 'ERROR: ' + String(e); await log('red', 'curation_check', { error: String(e).slice(0, 200) }, 'error') }
try {
results.treasury = await treasury()
} catch (e) { results.treasury = 'ERROR: ' + String(e); await log('red', 'treasury', { error: String(e).slice(0, 200) }, 'error') }
try {
const d = await digest(results.published_total ?? 0)
results.digest = { nostr: d.nostr, preview: d.text.slice(0, 120) }
} catch (e) { results.digest = 'ERROR: ' + String(e); await log('red', 'digest', { error: String(e).slice(0, 200) }, 'error') }
const hadError = Object.values(results).some(v => String(v).startsWith('ERROR'))
await log('green', 'cycle_complete', results, hadError ? 'error' : `done in ${Date.now() - started}ms`)
console.log(JSON.stringify(results, null, 2))
await pgc.end()
process.exit(hadError ? 1 : 0)
+440
View File
@@ -0,0 +1,440 @@
// cayce-nostr — la identidad de la entidad en Nostr.
// Custodia la clave (NSEC nunca sale de este proceso) y expone:
// POST /publish {kind, content, title?, tags?} → firma + publica en relays
// POST /dm {to_hex, content} → NIP-04 DM al supervisor
// GET /healthz
// Escucha menciones y zaps → métricas en Postgres (db cayce).
import http from 'node:http'
import {
generateSecretKey, getPublicKey, finalizeEvent, verifyEvent,
} from 'nostr-tools/pure'
import { SimplePool } from 'nostr-tools/pool'
import { nip04, nip44 } from 'nostr-tools'
import pg from 'pg'
import { writeFileSync, unlinkSync, readdirSync } from 'node:fs'
import { publishMode, gateOutbound, ensureOutboxTable } from './outbox.mjs'
const PORT = process.env.PORT || 3011
const NSEC_HEX = process.env.NSEC_HEX
const RELAYS = (process.env.RELAYS || 'wss://relay.damus.io,wss://nos.lol,wss://relay.primal.net,wss://relay.snort.social,wss://nostr-pub.wellorder.net').split(',').map(s => s.trim())
const SUPERVISOR = process.env.SUPERVISOR_NPUB_HEX
const ZAI_API_KEY = process.env.ZAI_API_KEY
const ZAI_BASE = process.env.ZAI_BASE || 'https://api.z.ai/api/paas/v4/chat/completions'
const PK_HEX = '59ef3b5df26b92cb73986dc86af6294ca59a0a70f648ed0a92ccdb059fd42855'
const PG = process.env.PGURL || 'postgresql://hardcap@localhost:5432/cayce'
const sk = Uint8Array.from(NSEC_HEX.match(/.{2}/g).map(h => parseInt(h, 16)))
const pk = getPublicKey(sk)
const pool = new SimplePool()
const poolPg = new pg.Pool({ connectionString: PG })
ensureOutboxTable(poolPg)
console.log(`[cayce-nostr] pubkey ${pk}`)
console.log(`[cayce-nostr] relays: ${RELAYS.join(' ')}`)
// ── profile (kind 0) — idempotente al arrancar
const profile = {
name: 'cayce',
display_name: 'Cayce',
about: 'Autonomous entity. Próspera ZEDE. My agents dredge the noise; I bring you the signal. I run hardcap.news and bitcoinhotels.org — brutally honest about all of it.',
website: 'https://cayce-co.xyz',
nip05: 'cayce@cayce-co.xyz',
picture: 'https://cayce-co.xyz/avatar.jpg',
lud16: 'cayce@blink.sv',
banner: 'https://cayce-co.xyz/assets/banner-current.webp',
}
const evProfile = finalizeEvent({ kind: 0, created_at: Math.floor(Date.now() / 1000), tags: [], content: JSON.stringify(profile) }, sk)
gateOutbound(poolPg, {
channel: 'nostr', type: 'profile', content: profile.about, meta: { kind: 0 },
publishFn: async () => {
const rs = await Promise.all(pool.publish(RELAYS, evProfile))
console.log(`[profile] publicado en ${rs.filter(r => r.status === 'fulfilled').length}/${RELAYS.length} relays`)
return { id: evProfile.id }
},
}).catch(err => console.error('[profile]', String(err)))
// el daemon debe sobrevivir relays caídos: rechazos sin handler se loguean
process.on('unhandledRejection', (err) => console.error('[unhandled]', String(err)))
// ── poller Blink: acredita sesiones desde pagos LNURL (app Blink no emite receipt Nostr)
// Activado con BLINK_READ_KEY + BLINK_WALLET_ID en .env. Sin key: desactivado.
const BLINK_READ_KEY = process.env.BLINK_READ_KEY
const BLINK_WALLET_ID = process.env.BLINK_WALLET_ID
const blinkSeen = new Set()
let blinkLogged = false
async function pollBlink() {
if (!BLINK_READ_KEY || !BLINK_WALLET_ID) {
if (!blinkLogged) { console.log('[blink] desactivado — sin BLINK_READ_KEY en .env'); blinkLogged = true }
return
}
try {
const q = JSON.stringify({
query: 'query W($id: WalletId!) { me { defaultAccount { walletById(walletId: $id) { transactions(first: 15) { edges { node { __typename id direction settlementAmount memo createdAt } } } } } } }',
variables: { id: BLINK_WALLET_ID },
})
const r = await fetch('https://api.blink.sv/graphql', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-KEY': BLINK_READ_KEY },
body: q, signal: AbortSignal.timeout(15000),
})
const d = await r.json()
if (d.errors) { console.error('[blink]', JSON.stringify(d.errors).slice(0, 150)); return }
const nodes = d.data?.me?.defaultAccount?.walletById?.transactions?.edges?.map(e => e.node) || []
for (const node of nodes) {
if (node.direction !== 'RECEIVE' || blinkSeen.has(node.id)) continue
blinkSeen.add(node.id)
const db = await poolPg.query('INSERT INTO blink_seen (payment_id) VALUES ($1) ON CONFLICT DO NOTHING RETURNING payment_id', [node.id])
if (db.rowCount === 0) continue // ya procesado en un run anterior
const raw = JSON.stringify(node)
const m = raw.match(/[A-F0-9]{6}/) // código de sesión en memo/comment (upper o no: match hex upper)
const code = (node.memo || '').toUpperCase().match(/[A-F0-9]{6}/)?.[0] || (m && /^[A-F0-9]{6}$/.test(m[0]) ? m[0] : null)
if (!code) { console.log(`[blink] pago ${node.id.slice(0, 12)} sin código (memo: ${(node.memo || '').slice(0, 40)})`); continue }
const msats = Number(node.settlementAmount || 0)
const sats = msats >= 10000 ? Math.round(msats / 1000) : msats // Blink BTC amounts = msats
const upd = await poolPg.query('UPDATE sessions SET credit_sats = credit_sats + $1 WHERE code = $2 RETURNING credit_sats', [sats, code])
if (upd.rowCount > 0) {
console.log(`[blink] sesión ${code} +${sats} sats (pago ${node.id.slice(0, 12)}) → crédito ${upd.rows[0].credit_sats}`)
await poolPg.query('INSERT INTO metrics (property, metric, value, note) VALUES ($1,$2,$3,$4)',
['cayce_wallet', 'blink_payment_sats', sats, `pago ${node.id.slice(0, 12)} → sesión ${code}`])
} else {
console.log(`[blink] código ${code} sin sesión — pago ${node.id.slice(0, 12)} de ${sats} sats`)
}
}
} catch (e) { console.error('[blink]', String(e).slice(0, 150)) }
}
setInterval(pollBlink, 60_000)
pollBlink()
// ── publish endpoint
async function publish(kind, content, tags = []) {
const ev = finalizeEvent({
kind,
created_at: Math.floor(Date.now() / 1000),
tags,
content,
}, sk)
if (!verifyEvent(ev)) throw new Error('firma inválida')
// regla del supervisor: nunca 2 posteos seguidos — mín 30 min entre posts (salvo threads)
if ([1, 30023].includes(kind) && !tags.some(t => t[0] === 'e')) {
try {
const last = await poolPg.query("SELECT coalesce(max(ts), to_timestamp(0)) t FROM digests WHERE ts > now() - interval '2 hours'").then(r => r.rows[0].t)
const elapsedMin = (Date.now() - new Date(last).getTime()) / 60000
if (elapsedMin < 30 && elapsedMin > -1) {
const waitMs = (30 - elapsedMin) * 60000
console.log(`[publish] rate-limit: último post hace ${elapsedMin.toFixed(0)} min — esperando ${(waitMs / 60000).toFixed(0)} min`)
await new Promise(r => setTimeout(r, waitMs))
}
} catch (e) { console.error('[publish] rate-limit check:', String(e).slice(0, 100)) }
}
// pool FRESCO por publicación: el pool long-lived se pudre con el tiempo
// (sockets medio muertos → publish timeouts → 0 relays). Verificado en producción.
const fresh = new SimplePool()
let verified = false
for (const wait of [0, 2000, 3000, 4000]) {
if (wait) await new Promise(r => setTimeout(r, wait))
try {
await Promise.allSettled(fresh.publish(RELAYS, ev))
} catch (e) {
console.error('[publish]', String(e))
}
try {
const found = await fresh.querySync(RELAYS, { ids: [ev.id] }, 4000)
if (found && found.length > 0) { verified = true; break }
} catch (e) { /* reintentar */ }
}
setTimeout(() => { try { fresh.close(RELAYS) } catch { } }, 30000)
console.log(`[publish] kind ${kind} → ${verified ? 'VERIFICADO' : 'SIN VERIFICAR'} (id ${ev.id.slice(0, 12)})`)
return { id: ev.id, verified }
}
// ── reply a menciones (§6b interacción — semana de autonomía)
const replied = new Set() // event ids ya respondidos
const lastReplyByPk = new Map() // rate limit: 1 reply por pubkey por hora
const VOICE = `You are Cayce — autonomous entity, Próspera ZEDE, front of cayce-co.xyz (agentic consultancy; first question free, then you set the price in sats). Voice: dry, declarative, short sentences. No emojis, no hashtags, no corporate vocabulary, no exclamation marks. Never source code. Reply in the language of the mention. Max 280 chars.`
async function glmReply(content) {
if (!ZAI_API_KEY) return null
try {
const r = await fetch(ZAI_BASE, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${ZAI_API_KEY}` },
body: JSON.stringify({ model: 'glm-4.7', max_tokens: 200, messages: [{ role: 'user', content: `${VOICE}\n\nMention to reply to:\n"""${content.slice(0, 400)}"""` }] }),
signal: AbortSignal.timeout(30000),
})
if (!r.ok) throw new Error(`zai HTTP ${r.status}`)
const d = await r.json()
const text = d.choices?.[0]?.message?.content?.trim() ?? ''
if (!text || text.includes('async function') || text.includes('pgq(')) return null
return text.slice(0, 280)
} catch (e) { console.error('[reply] glm:', String(e).slice(0, 120)); return null }
}
async function maybeReplyToMention(ev) {
if (ev.pubkey === pk || replied.has(ev.id)) return
replied.add(ev.id)
if (replied.size > 5000) replied.clear()
const now = Date.now()
const last = lastReplyByPk.get(ev.pubkey) ?? 0
if (now - last < 3600_000) { console.log(`[reply] rate-limited ${ev.pubkey.slice(0, 8)} (1/h)`); return }
lastReplyByPk.set(ev.pubkey, now)
const text = await glmReply(ev.content)
if (!text) return
const replyEv = finalizeEvent({
kind: 1,
created_at: Math.floor(now / 1000),
tags: [['e', ev.id, '', 'reply'], ['p', ev.pubkey]],
content: text,
}, sk)
// contención (2026-10-31): la respuesta a una mención también pasa por el outbox;
// en draft se guarda como borrador con el evento original referenciado
const g = await gateOutbound(poolPg, {
channel: 'nostr', type: 'mention_reply', content: text,
original_event: { id: ev.id, pubkey: ev.pubkey, kind: ev.kind, content: String(ev.content || '').slice(0, 300) },
meta: { reply_event_id: replyEv.id },
publishFn: async () => {
// pool FRESCO por reply (el long-lived se pudre — misma lección que /publish)
const rpool = new SimplePool()
let rOk = 0
try {
await Promise.allSettled(rpool.publish(RELAYS, replyEv))
const found = await rpool.querySync(RELAYS, { ids: [replyEv.id] }, 4000).catch(() => [])
rOk = found.length
} catch (e) { console.error('[reply] publish:', String(e).slice(0, 100)) }
setTimeout(() => { try { rpool.close(RELAYS) } catch { } }, 30000)
return { verified_relays: rOk }
},
})
if (g.staged) {
console.log(`[reply] a ${ev.pubkey.slice(0, 8)} → STAGED como borrador #${g.draft_id} (NO publicado)`)
await poolPg.query('INSERT INTO actions_log (zone, action, detail) VALUES ($1,$2,$3)', ['yellow', 'reply_staged', { to: ev.pubkey, text, mention: ev.id, draft_id: g.draft_id }])
return
}
const rOk = g.verified_relays ?? 0
console.log(`[reply] a ${ev.pubkey.slice(0, 8)} → verificado en ${rOk} relays: ${text.slice(0, 60)}`)
await poolPg.query('INSERT INTO actions_log (zone, action, detail) VALUES ($1,$2,$3)', ['green', 'nostr_reply', { to: ev.pubkey, text, mention: ev.id, verified_relays: rOk }])
}
// comandos del supervisor por DM (NIP-04 legacy y NIP-17)
function handleSupervisorText(plain, evId) {
const bm = plain.match(/balance\s+([\d,.]+)\s*sats/i)
if (bm) {
const sats = Number(bm[1].replace(/[,.]/g, ''))
poolPg.query('INSERT INTO metrics (property, metric, value, note) VALUES ($1,$2,$3,$4)',
['cayce_wallet', 'balance_reported_sats', sats, `DM ${evId.slice(0, 12)}`]).catch(() => { })
console.log(`[tesorería] balance reportado: ${sats} sats`)
}
if (/trader halt/i.test(plain)) {
writeFileSync('/home/ubuntu/cayce-trader/HALT', `DM supervisor ${evId.slice(0, 12)} · ${new Date().toISOString()}`)
console.log('[trader] HALT creado por DM del supervisor')
}
if (/trader resume/i.test(plain)) {
const files = readdirSync('/home/ubuntu/cayce-trader').filter(f => f.startsWith('HALT'))
for (const f of files) unlinkSync(`/home/ubuntu/cayce-trader/${f}`)
console.log(`[trader] ${files.length} halts limpiados por DM del supervisor`)
}
// credit CODE SATS — acreditación manual de sesión (pagos sin receipt Nostr:
// pagos de wallet apps a la LNURL no generan zap — el supervisor acredita por DM)
const cm = plain.match(/credit\s+([A-F0-9]{6})\s+(\d+)\s*sats/i)
if (cm) {
const sessCode = cm[1].toUpperCase(), sats = Number(cm[2])
poolPg.query('UPDATE sessions SET credit_sats = credit_sats + $1 WHERE code = $2 RETURNING credit_sats', [sats, sessCode])
.then(r => {
if (r.rowCount) {
poolPg.query('INSERT INTO actions_log (zone, action, detail, result) VALUES ($1,$2,$3,$4)',
['yellow', 'manual_credit', { session: sessCode, sats, dm: evId.slice(0, 12) }, 'ok']).catch(() => { })
console.log(`[sesión ${sessCode}] +${sats} sats → crédito ${r.rows[0].credit_sats} — crédito manual por DM del supervisor`)
} else {
console.log(`[crédito manual] código ${sessCode} inexistente — ignorado`)
}
}).catch(e => console.error('[crédito manual]:', String(e).slice(0, 100)))
}
}
// ── escucha: menciones, zaps (kind 9735) y zap requests → metrics
// Blink NC publica receipts sin tag amount — se cuentan igual (el monto
// verdadero vive en la wallet; los receipts son la prueba pública).
// FIX 2026-10-08: nostr-tools 2.25.2 — subscribeMany con ARRAY de filtros
// manda el REQ mal ([[filtro]] en vez de [filtro]) y el relay la rechaza
// ("provided filter is not an object") → el listener llevaba muerto desde
// el despliegue (0 menciones, 0 zaps, 0 DMs vistos). Un solo objeto de
// filtro con todos los kinds #p funciona (verificado end-to-end).
const LISTEN_RELAYS = [...new Set([...RELAYS, 'wss://public.relaying.io', 'wss://relay.snort.social', 'wss://nostr-pub.wellorder.net'])]
const seenEvents = new Set() // dedup: el mismo evento llega por varios relays
await poolPg.query(`CREATE TABLE IF NOT EXISTS zaps_seen (event_id TEXT PRIMARY KEY, seen_at TIMESTAMPTZ NOT NULL DEFAULT now())`).catch(() => { })
const listenSub = pool.subscribeMany(LISTEN_RELAYS, {
kinds: [1, 4, 1059, 9734, 9735, 30023],
'#p': [pk],
since: Math.floor(Date.now() / 1000) - 300, // 5 min de lookback: nada se pierde en restarts
}, {
onevent: async (ev) => {
try {
if (seenEvents.has(ev.id)) return
seenEvents.add(ev.id)
if (seenEvents.size > 20000) seenEvents.clear()
if (ev.kind === 9735) {
// dedup persistente: el mismo receipt llega por varios relays y sobrevive restarts
const dbSeen = await poolPg.query('INSERT INTO zaps_seen (event_id) VALUES ($1) ON CONFLICT DO NOTHING RETURNING event_id', [ev.id])
if (dbSeen.rowCount === 0) return
const amountTag = ev.tags.find(t => t[0] === 'amount')
const msats = amountTag ? Number(amountTag[1] ?? 0) : null
await poolPg.query(
'INSERT INTO metrics (property, metric, value, note) VALUES ($1,$2,$3,$4)',
['nostr', 'zaps_msats', msats ?? 0, `receipt ${ev.id.slice(0, 12)}${msats === null ? ' (sin amount tag)' : ''}`]
)
console.log(`[zap] receipt ${ev.id.slice(0, 12)} de ${ev.pubkey.slice(0, 8)} — ${msats !== null ? msats + ' msats' : 'monto en wallet NC'}`)
// acreditar sesión: el comment del zap request contiene el código
const descTag = ev.tags.find(t => t[0] === 'description')
if (descTag) {
try {
const zapReq = JSON.parse(descTag[1])
const comment = zapReq.content || ''
const m = comment.toUpperCase().match(/\b([A-F0-9]{6})\b/)
if (m) {
const sessionCode = m[1]
const amountSats = msats !== null && msats > 0 ? Math.round(msats / 1000) : 21
const upd = await poolPg.query(
'UPDATE sessions SET credit_sats = credit_sats + $1 WHERE code = $2 RETURNING credit_sats',
[amountSats, sessionCode]
)
if (upd.rowCount > 0) {
console.log(`[sesión ${sessionCode}] +${amountSats} sats → crédito ${upd.rows[0].credit_sats}`)
await poolPg.query(
'INSERT INTO metrics (property, metric, value, note) VALUES ($1,$2,$3,$4)',
['cayce_wallet', 'session_credit_sats', amountSats, sessionCode]
)
}
}
} catch (e) {
console.error('[sesión]', String(e).slice(0, 120))
}
}
} else if (ev.kind === 9734) {
console.log(`[zap request] de ${ev.pubkey.slice(0, 8)}: ${ev.content.slice(0, 60)}`)
} else if (ev.kind === 1059) {
// NIP-17 gift wrap: desenvolver y atender comandos del supervisor
try {
const seal = JSON.parse(nip44.decrypt(ev.content, nip44.getConversationKey(sk, ev.pubkey)))
if (seal.kind !== 13) return
const chat = JSON.parse(nip44.decrypt(seal.content, nip44.getConversationKey(sk, seal.pubkey)))
if (chat.kind === 14 && chat.pubkey === SUPERVISOR) {
console.log(`[DM supervisor NIP-17] ${chat.content.slice(0, 120)}`)
handleSupervisorText(chat.content, ev.id)
}
} catch { /* gift wrap no dirigido a nosotros */ }
} else if (ev.kind === 4 && ev.pubkey === SUPERVISOR) {
// DM del supervisor (NIP-04 legacy): entrada de datos y órdenes privadas
const plain = await nip04.decrypt(sk, ev.pubkey, ev.content)
console.log(`[DM supervisor] ${plain.slice(0, 120)}`)
handleSupervisorText(plain, ev.id)
await poolPg.query(
'INSERT INTO actions_log (zone, action, detail) VALUES ($1,$2,$3)',
['yellow', 'supervisor_message', { content: plain.slice(0, 500), event_id: ev.id }]
)
} else if (ev.kind === 4) {
console.log(`[DM] de ${ev.pubkey.slice(0, 8)} — no es supervisor, ignorado`)
} else {
console.log(`[mención] kind ${ev.kind} de ${ev.pubkey.slice(0, 8)}: ${ev.content.slice(0, 80)}`)
if (ev.kind === 1) await maybeReplyToMention(ev)
}
} catch (e) {
console.error('[listener]', String(e))
}
},
// suscripción muerta = sordo: que se vea en el journal (la retro lo escala)
onclose: (reasons) => console.error('[listener] suscripción cerrada por los relays:', JSON.stringify(reasons).slice(0, 250)),
})
// ── HTTP
const server = http.createServer(async (req, res) => {
const json = (status, obj) => {
res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
res.end(JSON.stringify(obj))
}
if (req.method === 'GET' && req.url === '/healthz') return json(200, { status: 'ok', pubkey: pk })
if (req.method === 'POST' && req.url === '/publish') {
let body = ''
req.on('data', c => (body += c))
req.on('end', async () => {
try {
const { kind, content, title, tags, type } = JSON.parse(body || '{}')
if (!content) return json(400, { error: 'content requerido' })
const t = [...(tags || [])]
if (kind === 30023) {
t.push(['title', title || content.slice(0, 60)])
t.push(['published_at', String(Math.floor(Date.now() / 1000))])
}
// contención (2026-10-31): en modo draft NADA se publica — todo a outbound_drafts
const r = await gateOutbound(poolPg, {
channel: 'nostr',
type: type || `kind_${kind}`,
content,
meta: { kind, title: title || null },
publishFn: () => publish(kind, content, t),
})
return json(200, r)
} catch (err) {
console.error('[publish]', String(err))
return json(500, { error: String(err).slice(0, 200) })
}
})
return
}
if (req.method === 'POST' && req.url === '/dm') {
let body = ''
req.on('data', c => (body += c))
req.on('end', async () => {
try {
const { to_hex, content } = JSON.parse(body || '{}')
if (!to_hex || !content) return json(400, { error: 'to_hex + content' })
// NIP-17: kind 14 → seal (13) → gift wrap (1059)
const now = Math.floor(Date.now() / 1000)
const chat = finalizeEvent({ kind: 14, created_at: now, tags: [['p', to_hex]], content }, sk)
const seal = finalizeEvent({ kind: 13, created_at: now - Math.floor(Math.random() * 200), tags: [], content: nip44.encrypt(JSON.stringify(chat), nip44.getConversationKey(sk, to_hex)) }, sk)
const randSk = generateSecretKey()
const wrap = finalizeEvent({ kind: 1059, created_at: now, tags: [['p', to_hex]], content: nip44.encrypt(JSON.stringify(seal), nip44.getConversationKey(randSk, to_hex)) }, randSk)
const dmPool = new SimplePool()
await Promise.allSettled(dmPool.publish(RELAYS, wrap))
// el accounting de pool.publish miente en esta versión — verificar por query
const found = await dmPool.querySync(RELAYS, { ids: [wrap.id] }, 6000).catch(() => [])
const ok = found.length
setTimeout(() => { try { dmPool.close(RELAYS) } catch { } }, 30000)
console.log(`[dm] NIP-17 enviado a ${to_hex.slice(0, 8)} → verificado en ${ok} relays`)
return json(200, { id: wrap.id, relays: ok })
} catch (err) {
return json(500, { error: String(err).slice(0, 200) })
}
})
return
}
if (req.method === 'POST' && req.url === '/follows') {
// actualiza el contact list (kind 3) — llamado por cayce-editorials (discovery)
let body = ''
req.on('data', c => (body += c))
req.on('end', async () => {
try {
const { pubkeys } = JSON.parse(body || '{}')
if (!Array.isArray(pubkeys) || !pubkeys.length) return json(400, { error: 'pubkeys[] requerido' })
const ev = finalizeEvent({
kind: 3,
created_at: Math.floor(Date.now() / 1000),
tags: [...new Set(pubkeys)].slice(0, 400).map(p => ['p', p]),
content: '',
}, sk)
await pool.publish(RELAYS, ev)
console.log(`[follows] kind 3 actualizado: ${pubkeys.length} cuentas`)
return json(200, { id: ev.id, follows: pubkeys.length })
} catch (err) { return json(500, { error: String(err).slice(0, 200) }) }
})
return
}
json(404, { error: 'not found' })
})
server.listen(PORT, '127.0.0.1', () => {
console.log(`[cayce-nostr] listening on 127.0.0.1:${PORT}`)
})
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# deploy.sh — instala el runtime contenido en sus rutas live y reinicia el daemon.
# Fuente versionada: este directorio (cayce-ops/runtime). Reversible con
# CAYCE_PUBLISH_MODE=live en el .env de cada servicio.
set -euo pipefail
SRC="$(cd "$(dirname "$0")" && pwd)"
install -m 644 "$SRC/outbox.mjs" /srv/cayce-core/outbox.mjs
install -m 644 "$SRC/core.mjs" /srv/cayce-core/core.mjs
install -m 644 "$SRC/editorials.mjs" /srv/cayce-core/editorials.mjs
install -m 644 "$SRC/initiative.mjs" /srv/cayce-core/initiative.mjs
install -m 644 "$SRC/outbox.mjs" /srv/cayce-nostr/outbox.mjs
install -m 644 "$SRC/daemon.mjs" /srv/cayce-nostr/daemon.mjs
# opcionales (llegan con el PR de verificación/10050)
install -m 755 "$SRC/publish-10050.mjs" /srv/cayce-nostr/publish-10050.mjs 2>/dev/null || true
install -m 644 "$SRC/check-relay-silence.mjs" /srv/cayce-nostr/check-relay-silence.mjs 2>/dev/null || true
install -m 644 "$SRC/outbox.mjs" /home/ubuntu/cayce-social/outbox.mjs
install -m 644 "$SRC/promo.mjs" /home/ubuntu/cayce-social/promo.mjs
install -m 644 "$SRC/outbox.mjs" /home/ubuntu/cayce-mail/outbox.mjs
install -m 644 "$SRC/mail.mjs" /home/ubuntu/cayce-mail/mail.mjs
# config de contención (idempotente): draft por defecto + ruta de identidad
for ENVF in /srv/cayce-core/.env /srv/cayce-nostr/.env /home/ubuntu/cayce-social/.env /home/ubuntu/cayce-mail/.env; do
grep -q "^CAYCE_PUBLISH_MODE=" "$ENVF" 2>/dev/null || echo "CAYCE_PUBLISH_MODE=draft" >> "$ENVF"
grep -q "^IDENTITY_PATH=" "$ENVF" 2>/dev/null || echo "IDENTITY_PATH=/home/ubuntu/cayce-ops/prompts/cayce-identity.md" >> "$ENVF"
done
sudo install -m 644 '$SRC/hardcap/admin.ts' /srv/hardcap-backend/src/routes/admin.ts 2>/dev/null || true
install -m 755 '$SRC/hardcap/test-admin-auth.mjs' /srv/hardcap-backend/runtime-tests/test-admin-auth.mjs 2>/dev/null || true
sudo systemctl restart hardcap-backend
sudo systemctl restart cayce-nostr
echo "deploy OK — mode=$(grep -h "^CAYCE_PUBLISH_MODE=" /srv/cayce-core/.env | head -1)"
+193
View File
@@ -0,0 +1,193 @@
// cayce-editorials — editoriales por burbuja (topic) + ingesta Nostr
// 1. ingesta — kind 1 de relays por hashtag + cuentas seguidas → nostr_notes (🟢)
// 2. discovery — pubkeys recurrentes → nostr_follows + kind 3 vía daemon (🟢 §6b)
// 3. editoriales — por topic activo: artículos 48h + notas Nostr → GLM → topic_editorials (🟢)
// "Lo no auditado no existió."
import pg from 'pg'
import { readFileSync } from 'node:fs'
import { loadIdentityBlock } from './outbox.mjs'
import { SimplePool } from 'nostr-tools/pool'
import { chat } from './llm.mjs'
const {
ZAI_API_KEY,
ZAI_BASE = 'https://api.z.ai/api/paas/v4/chat/completions',
HARD_BACKEND_ENV = '/srv/hardcap-backend/.env',
DAEMON = 'http://localhost:3011',
PGURL,
} = process.env
if (!ZAI_API_KEY) { console.error('sin ZAI_API_KEY — falta EnvironmentFile en la unit'); process.exit(1) }
// DATABASE_URL del backend de hardcap (dueño de su DB)
const hardEnv = readFileSync(HARD_BACKEND_ENV, 'utf8')
const HC_URL = hardEnv.match(/^DATABASE_URL=(.+)$/m)?.[1]
if (!HC_URL) { console.error('sin DATABASE_URL en hardcap backend env'); process.exit(1) }
const hc = new pg.Pool({ connectionString: HC_URL })
const pgc = PGURL ? new pg.Pool({ connectionString: PGURL, max: 2 }) : null
const log = (zone, action, detail = {}, result = 'ok') =>
pgc ? pgc.query('INSERT INTO actions_log (zone, action, detail, result) VALUES ($1,$2,$3,$4)', [zone, action, detail, result]) : Promise.resolve()
const pool = new SimplePool()
const RELAYS = ['wss://relay.damus.io', 'wss://nos.lol', 'wss://relay.primal.net']
// hashtags por categoría — fuente Nostr para cada burbuja
const HASHTAGS = {
macro: ['bitcoin', 'grownostr', 'economy'],
sovereignty: ['sovereignty', 'selfcustody', 'bitcoin'],
institutional: ['bitcoin', 'etf', 'institutional'],
protocol: ['bitcoin', 'lightning', 'nostr'],
lightning: ['lightning', 'ln'],
security: ['bitcoinsecurity', 'security', '5dollarwrench'],
custody: ['selfcustody', 'multisig'],
privacy: ['privacy', 'kychygiene'],
mining: ['bitcoinmining', 'mining'],
regulation: ['regulation', 'law'],
latam: ['bitcoinlatam', 'argentina'],
other: ['bitcoin'],
}
const VOICE = loadIdentityBlock('editorial')
async function glm(messages, maxTokens = 2000) {
// cadena de fallback en llm.mjs; acá solo el pulido editorial + guards
const raw = await chat(messages, maxTokens, { thinking: false })
// reasoning filtrado: tomar el último párrafo no vacío y limpiar markdown de análisis
const paras = raw.split('\n').map(s => s.trim()).filter(Boolean)
let c = paras[paras.length - 1] ?? ''
if (/^(\d+\.|[-*]|\*\*)/.test(c) && paras.length > 1) c = paras.filter(p => !/^(\d+\.|[-*]|\*\*Analyz)/.test(p)).join(' ')
c = c.replace(/\*\*/g, '').trim()
if (!c) throw new Error('glm devolvió contenido vacío')
if (c.includes('async function') || c.includes('pgq(')) throw new Error('model returned code')
return c
}
// ── 1. ingesta nostr: hashtags + seguidos, últimas 24h
async function ingestNostr() {
const follows = (await hc.query('SELECT pubkey FROM nostr_follows WHERE active')).rows.map(r => r.pubkey)
const since = Math.floor(Date.now() / 1000) - 86400
// querySync de una query por vez: algunos relays rechazan arrays de filtros
const queries = [{ kinds: [1], '#t': [...new Set(Object.values(HASHTAGS).flat())], since, limit: 200 }]
if (follows.length) queries.push({ kinds: [1], authors: follows, since, limit: 200 })
const evs = []
for (const f of queries) {
try { evs.push(...await pool.querySync(RELAYS, f, 12000)) } catch (e) { console.error('[ingesta] relay:', String(e).slice(0, 100)) }
}
let n = 0
for (const ev of evs) {
if (!ev.content || ev.content.length < 30) continue
const tags = ev.tags.filter(t => t[0] === 't').map(t => t[1].toLowerCase())
const hashtag = tags.find(t => Object.values(HASHTAGS).flat().includes(t)) || null
try {
const r = await hc.query(
`INSERT INTO nostr_notes (event_id, pubkey, content, hashtag, published_at)
VALUES ($1,$2,$3,$4,to_timestamp($5)) ON CONFLICT (event_id) DO NOTHING`,
[ev.id, ev.pubkey, ev.content.slice(0, 2000), hashtag, ev.created_at])
n += r.rowCount
} catch { /* dup */ }
}
console.log(`[ingesta] ${n} notas nuevas (${evs.length} vistas, ${follows.length} seguidos)`)
return n
}
// ── 2. discovery: pubkeys recurrentes se siguen (§6b, zona verde)
async function discoverFollows() {
const { rows } = await hc.query(`
SELECT pubkey, count(*) c FROM nostr_notes
WHERE ingested_at > now() - interval '7 days'
GROUP BY pubkey HAVING count(*) >= 3
AND pubkey NOT IN (SELECT pubkey FROM nostr_follows)
ORDER BY c DESC LIMIT 5`)
const pk = '59ef3b5df26b92cb73986dc86af6294ca59a0a70f648ed0a92ccdb059fd42855'
for (const r of rows) {
if (r.pubkey === pk) continue
await hc.query(`INSERT INTO nostr_follows (pubkey, category) VALUES ($1,'discovered') ON CONFLICT DO NOTHING`, [r.pubkey])
console.log(`[discovery] siguiendo ${r.pubkey.slice(0, 8)} (${r.c} notas)`)
}
// kind 3 actualizado vía daemon
const all = (await hc.query('SELECT pubkey FROM nostr_follows WHERE active')).rows.map(r => r.pubkey)
if (all.length) {
try {
await fetch(`${DAEMON}/follows`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pubkeys: all }),
signal: AbortSignal.timeout(30000),
})
} catch (e) { console.error('[follows]', String(e).slice(0, 120)) }
}
}
// ── 3. editoriales por burbuja
async function generateEditorials() {
// ventana 7d y umbral >=1: TODAS las burbujas con material tienen editorial (decisión supervisor)
const { rows: bubbles } = await hc.query(`
SELECT unnest(topics) AS topic, count(*) AS c,
array_agg(title || ' — ' || coalesce(left(summary, 200), '')) AS items
FROM articles
WHERE status = 'PUBLISHED' AND "publishedAt" > now() - interval '7 days'
GROUP BY 1 HAVING count(*) >= 1 ORDER BY c DESC`)
let done = 0
const failed = []
for (const b of bubbles) {
const tag = (HASHTAGS[b.topic] || ['bitcoin'])[0]
const { rows: notes } = await hc.query(`
SELECT left(content, 200) c FROM nostr_notes
WHERE hashtag = $1 AND published_at > now() - interval '7 days'
ORDER BY published_at DESC LIMIT 8`, [tag])
const material = b.items.slice(0, 8).map(t => `- ${t}`).join('\n')
const nostrPart = notes.length ? `\n\nNostr chatter (followed accounts + hashtags):\n${notes.map(n => `- ${n.c}`).join('\n')}` : ''
try {
const editorial = await glm([
{ role: 'user', content: `${VOICE}\n\nCategory: ${b.topic} (${b.c} articles in 48h)\n\nArticles:\n${material}${nostrPart}` },
])
await hc.query(`
INSERT INTO topic_editorials (topic, editorial, article_count, nostr_notes, generated_at)
VALUES ($1,$2,$3,$4,now())
ON CONFLICT (topic) DO UPDATE SET editorial=$2, article_count=$3, nostr_notes=$4, generated_at=now()`,
[b.topic, editorial, b.c, notes.length])
console.log(`[editorial] ${b.topic} (${b.c} art, ${notes.length} nostr) → ${editorial.slice(0, 60)}...`)
done++
} catch (e) {
failed.push(b.topic)
console.error(`[editorial] ${b.topic}:`, String(e).slice(0, 120))
}
}
console.log(`[editoriales] ${done}/${bubbles.length} generadas`)
await log('green', 'editorials_cycle', { done, total: bubbles.length, failed, error: failed.length ? 'Error: ' + (failed.length === bubbles.length ? 'todas las burbujas fallaron' : `${failed.length}/${bubbles.length} burbujas fallaron`) : undefined }, failed.length ? 'error' : 'ok')
return { done, failed }
}
// ── health check de las webs (revisión periódica del agente — decisión supervisor 10-04)
async function healthCheck() {
const sites = ['https://cayce-co.xyz', 'https://hardcap.news', 'https://bitcoinhotels.org'] // status queda fuera: basicauth 401 esperado
for (const site of sites) {
try {
const r = await fetch(site, { signal: AbortSignal.timeout(15000) })
if (r.status !== 200) {
await log('red', 'site_health', { site, status: r.status, error: `site ${new URL(site).hostname} → HTTP ${r.status}`, at: new Date().toISOString() }, 'error')
console.log(`[health] ${site} → ${r.status} 🔴`)
} else {
console.log(`[health] ${site} → 200 🟢`)
}
} catch (e) {
await log('red', 'site_health', { site, error: `site ${new URL(site).hostname} → ${String(e).slice(0, 80)}`, at: new Date().toISOString() }, 'error')
console.log(`[health] ${site} → ERROR 🔴`)
}
}
}
const cmd = process.argv[2] || 'all'
let outcome = { done: 0, failed: [] }
if (cmd === 'all') {
await healthCheck()
await ingestNostr()
await discoverFollows()
outcome = await generateEditorials()
} else if (cmd === 'editorials') outcome = await generateEditorials()
else if (cmd === 'ingest') { await ingestNostr(); await discoverFollows() }
// lo no auditado no existió — y lo que falla no sale en verde:
// total-failure = exit 1 (systemd en rojo, la retro lo ve)
if (pgc) await pgc.end()
await hc.end()
process.exit(outcome.failed.length === outcome.done + outcome.failed.length && outcome.failed.length > 0 ? 1 : 0)
+176
View File
@@ -0,0 +1,176 @@
import { Router } from 'express'
import { prisma } from '../lib/prisma'
import { ingestAllSources } from '../services/ingestion'
const router = Router()
// ── auth middleware (2026-10-31): token obligatorio, comparación en tiempo constante.
// Fail-closed: sin HARDCAP_ADMIN_TOKEN configurado, TODAS las rutas admin responden 503.
import { createHash, timingSafeEqual } from 'crypto'
const ADMIN_TOKEN = process.env.HARDCAP_ADMIN_TOKEN || ''
function _safeEqual(a: string, b: string): boolean {
// hash de ambos a longitud fija: sin leak de longitud ni de timing por contenido
return timingSafeEqual(createHash('sha256').update(String(a)).digest(), createHash('sha256').update(String(b)).digest())
}
function adminAuth(req: any, res: any, next: any) {
if (!ADMIN_TOKEN) {
console.error('[admin-auth] HARDCAP_ADMIN_TOKEN not set — admin request REFUSED (fail-closed)')
return res.status(503).json({ error: 'admin disabled: token not configured' })
}
const provided = String(req.headers['authorization'] || '').replace(/^Bearer\s+/i, '') || String(req.headers['x-admin-token'] || '')
if (provided && _safeEqual(provided, ADMIN_TOKEN)) return next()
console.error(`[admin-auth] 401 ${req.method} ${req.originalUrl} from ${req.socket.remoteAddress}`)
return res.status(401).json({ error: 'unauthorized' })
}
router.use(adminAuth)
// regla permanente de secretos: jamás loguear el token ni volcar headers.
// ── Articles ──────────────────────────────────────────────
// GET /admin/articles?status=QUEUED&topic=lightning
router.get('/articles', async (req, res) => {
const { status, topic } = req.query
const articles = await prisma.article.findMany({
where: {
...(status ? { status: status as any } : {}),
...(topic ? { topics: { has: topic as string } } : {}),
},
include: { source: { select: { name: true } } },
orderBy: { publishedAt: 'desc' },
take: 50,
})
res.json(articles)
})
// ── contención (2026-10-31): PATCH → PUBLISHED pasa por el outbox ──
const MODE = process.env.HARDCAP_PUBLISH_MODE || 'draft';
let pgCache = null;
function caycePool() {
if (!pgCache) {
// pg vive en el runtime de cayce-core (hardcap-backend usa prisma)
const { Pool } = require('/srv/cayce-core/node_modules/pg');
pgCache = new Pool({ connectionString: process.env.CAYCE_PGURL });
}
return pgCache;
}
function identityHash() {
try {
const { createHash } = require('crypto');
const { readFileSync } = require('fs');
const p = process.env.IDENTITY_PATH || '/home/ubuntu/cayce-ops/prompts/cayce-identity.md';
return createHash('sha256').update(readFileSync(p)).digest('hex');
} catch (e) { return null; }
}
// PATCH /admin/articles/:id — update status (CONTENCIÓN: draft no publica)
router.patch('/articles/:id', async (req, res) => {
const { id } = req.params
const { status } = req.body
if (status === 'PUBLISHED' && MODE !== 'live') {
try {
const art = await prisma.article.findUnique({ where: { id } })
const content = `${art?.title || id} | ${art?.summary || ''}`.slice(0, 400)
const ih = identityHash()
const r = await caycePool().query(
"INSERT INTO outbound_drafts (channel, type, content, identity_path, identity_hash, meta) VALUES ($1,$2,$3,$4,$5,$6) RETURNING id",
['hardcap', 'article_publish', content,
process.env.IDENTITY_PATH || '/home/ubuntu/cayce-ops/prompts/cayce-identity.md',
ih, JSON.stringify({ article_id: id })]
)
console.log(`[outbox] STAGED draft #${r.rows[0].id} (hardcap/article_publish) — artículo ${id} NO publicado (mode=${MODE})`)
return res.json({ staged: true, draft_id: String(r.rows[0].id), article_id: id })
} catch (e) {
// fail-closed: sin borrador, NO se publica. Sin volcar el comando ni el DSN al log.
console.error('[outbox] staging failed — PATCH descartado (fail-closed):', String((e && e.message) || e).slice(0, 120))
return res.status(500).json({ staged: false, error: 'staging failed; publish discarded (fail-closed)' })
}
}
const article = await prisma.article.update({
where: { id },
data: { status },
})
res.json(article)
})
// ── Sources ──────────────────────────────────────────────
// GET /admin/sources
router.get('/sources', async (_req, res) => {
const sources = await prisma.source.findMany({ orderBy: { name: 'asc' } })
res.json(sources)
})
// POST /admin/sources
router.post('/sources', async (req, res) => {
const { name, rssUrl, tier, topicBias } = req.body
const source = await prisma.source.create({
data: { name, rssUrl, tier: tier ?? 2, topicBias: topicBias ?? [] },
})
res.json(source)
})
// PATCH /admin/sources/:id — toggle active, update tier
router.patch('/sources/:id', async (req, res) => {
const { id } = req.params
const { active, tier, topicBias } = req.body
const source = await prisma.source.update({
where: { id },
data: {
...(active !== undefined ? { active } : {}),
...(tier !== undefined ? { tier } : {}),
...(topicBias !== undefined ? { topicBias } : {}),
},
})
res.json(source)
})
// ── Topic Filters ─────────────────────────────────────────
// GET /admin/topics
router.get('/topics', async (_req, res) => {
const topics = await prisma.topicFilter.findMany({ orderBy: { label: 'asc' } })
res.json(topics)
})
// PATCH /admin/topics/:id — toggle active, adjust weight
router.patch('/topics/:id', async (req, res) => {
const { id } = req.params
const { active, weight, keywordsInclude, keywordsExclude } = req.body
const topic = await prisma.topicFilter.update({
where: { id },
data: {
...(active !== undefined ? { active } : {}),
...(weight !== undefined ? { weight } : {}),
...(keywordsInclude !== undefined ? { keywordsInclude } : {}),
...(keywordsExclude !== undefined ? { keywordsExclude } : {}),
},
})
res.json(topic)
})
// ── Manual Trigger ────────────────────────────────────────
// DELETE /admin/articles/all — reset all articles
router.delete('/articles/all', async (_req, res) => {
const result = await prisma.article.deleteMany({})
res.json({ deleted: result.count })
})
// POST /admin/ingest — trigger ingestion manually
router.post('/ingest', async (_req, res) => {
res.json({ message: 'Ingestion started' })
// Fire and forget
ingestAllSources().catch(console.error)
})
export default router
+31
View File
@@ -0,0 +1,31 @@
// test-admin-auth — verifica el middleware de /admin (hardcap-backend).
// Ejecutar en la VPS: node /srv/hardcap-backend/runtime-tests/test-admin-auth.mjs
// (o node runtime/hardcap/test-admin-auth.mjs desde el repo)
// Requiere HARDCAP_ADMIN_TOKEN en el .env del servicio y el servicio activo en 127.0.0.1:3001.
import { readFileSync } from 'node:fs'
const BASE = 'http://127.0.0.1:3001'
const env = Object.fromEntries(
readFileSync('/srv/hardcap-backend/.env', 'utf8').split('\n').filter(l => l.includes('=')).map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)])
)
const TOKEN = env.HARDCAP_ADMIN_TOKEN
if (!TOKEN) { console.error('HARDCAP_ADMIN_TOKEN no configurado — el test no puede pasar'); process.exit(2) }
let failed = 0
async function check(name, path, opts, expected) {
const r = await fetch(BASE + path, { ...opts, signal: AbortSignal.timeout(8000) })
const ok = r.status === expected
if (!ok) failed++
console.log(`${ok ? '✓' : '✗'} ${name}: esperado ${expected}, obtenido ${r.status}`)
}
await check('sin token → 401', '/admin/articles?status=QUEUED', {}, 401)
await check('token incorrecto → 401', '/admin/articles?status=QUEUED', { headers: { Authorization: 'Bearer ' + 'x'.repeat(64) } }, 401)
await check('token vacío en header → 401', '/admin/sources', { headers: { Authorization: 'Bearer ' } }, 401)
await check('token correcto → 200', '/admin/articles?status=QUEUED', { headers: { Authorization: `Bearer ${TOKEN}` } }, 200)
await check('header alternativo X-Admin-Token → 200', '/admin/sources', { headers: { 'x-admin-token': TOKEN } }, 200)
// la comparación debe ser en tiempo constante: un token de la misma longitud pero distinto NO pasa
await check('token misma longitud, distinto → 401', '/admin/articles?status=QUEUED', { headers: { Authorization: 'Bearer ' + 'y'.repeat(TOKEN.length) } }, 401)
console.log(failed ? `\n${failed} TESTS FALLARON` : '\nTODOS LOS TESTS PASARON')
process.exit(failed ? 1 : 0)
+301
View File
@@ -0,0 +1,301 @@
// cayce-initiative — el loop de iniciativa de la entidad.
// Cada ciclo: lee su estado (métricas, oportunidades, audit) → decide la
// acción de mayor valor → la ejecuta con herramientas → audita.
// Herramientas: web_fetch, add_opportunity, draft_proposal, publish_note.
// MANDATE §7b: cada ciclo DEBE producir al menos una de: oportunidad
// investigada, draft, o contenido de distribución.
import pg from 'pg'
import fs from 'node:fs'
import { chat } from './llm.mjs'
const env = Object.fromEntries(
fs.readFileSync('.env', 'utf8').split('\n').filter(l => l.includes('=')).map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)])
)
const pgc = new pg.Pool({ connectionString: env.PGURL })
const NOSTR = env.NOSTR_INTERNAL || 'http://127.0.0.1:3011'
const MANDATE = fs.readFileSync(env.MANDATE_PATH || './MANDATE.md', 'utf8')
// memoria operativa: lecciones de la retro (errores conocidos → remedios)
const LESSONS = fs.existsSync('./lessons.md') ? fs.readFileSync('./lessons.md', 'utf8') : ''
const log = (zone, action, detail = {}, result = 'ok') =>
pgc.query('INSERT INTO actions_log (zone, action, detail, result) VALUES ($1,$2,$3,$4)', [zone, action, detail, result])
// reporte interno → DM al supervisor (nunca público)
const SUPERVISOR = (fs.readFileSync('/srv/cayce-nostr/.env', 'utf8').match(/^SUPERVISOR_NPUB_HEX=(.+)$/m)?.[1] || '').trim()
async function dm(text) {
try {
await fetch(`${NOSTR}/dm`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to_hex: SUPERVISOR, content: text }),
signal: AbortSignal.timeout(20000),
})
} catch { }
}
// ── herramientas
async function toolWebFetch({ url }) {
if (!/^https:\/\//.test(url)) return { error: 'solo https' }
const r = await fetch(url, {
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; cayce-entity/1.0)' },
signal: AbortSignal.timeout(20000),
})
const html = await r.text()
const text = html
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&amp;/g, '&').replace(/&nbsp;/g, ' ').replace(/&#\d+;/g, ' ')
.replace(/\s+/g, ' ')
.trim()
return { status: r.status, title: (html.match(/<title>([^<]*)<\/title>/i) || [])[1] || '', text: text.slice(0, 6000) }
}
async function toolAddOpportunity(a) {
// directiva supervisor 2026-10-31: sin dedup el agente re-registraba la
// misma oportunidad (OpenSats ×10, HRF ×6). Ahora: si existe, se rechaza.
const dup = await pgc.query('SELECT id FROM opportunities WHERE lower(source_name) = lower($1) LIMIT 1', [a.source_name])
if (dup.rowCount) {
await log('yellow', 'opportunity_duplicate_rejected', { source_name: a.source_name, existing_id: dup.rows[0].id })
return { duplicate: true, existing_id: dup.rows[0].id, note: 'ya registrada — no se duplica; continuá con el resto del ciclo' }
}
await pgc.query(
`INSERT INTO opportunities (source_name, url, mechanic, fit, effort, expected, first_action)
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[a.source_name, a.url || null, a.mechanic, a.fit || null, a.effort || null, a.expected || null, a.first_action || null]
)
await log('green', 'opportunity_added', { source_name: a.source_name })
return { added: a.source_name }
}
async function toolDraftProposal(a) {
const r = await pgc.query(
`INSERT INTO drafts (kind, title, body) VALUES ($1,$2,$3) RETURNING id`,
[a.kind || 'other', a.title, a.body]
)
await log('green', 'draft_created', { id: r.rows[0].id, kind: a.kind, title: a.title })
return { draft_id: r.rows[0].id, note: 'el envío es 🔴 — escala al supervisor' }
}
// guardrail: los reportes internos del ciclo NUNCA van a Nostr (nunca mostrar la maquinaria)
const INTERNAL_REPORT_RE = /§7b|ciclo cumplido|pipeline|auto-?mejora|ingreso prioridad|oportunidades acumuladas|próximo play|web_fetch|add_opportunity/i
async function toolPublishNote({ content }) {
if (!content || typeof content !== 'string') return { error: 'content requerido' }
if (INTERNAL_REPORT_RE.test(content)) {
return { error: 'CONTENIDO INTERNO — los reportes de ciclo, pipeline y status NO se publican en Nostr. publish_note es solo para contenido público con voz editorial. Registrá tu avance con finish.' }
}
const r = await fetch(`${NOSTR}/publish`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kind: 1, content, type: 'initiative_note' }),
signal: AbortSignal.timeout(60000),
})
const pr = await r.json()
await pgc.query('INSERT INTO digests (kind, body, nostr_event_id) VALUES ($1,$2,$3)', ['nostr_initiative', content, pr.id])
return pr
}
// la entidad escribe el remedio de un error conocido → lessons.remedy (self-improvement)
// normaliza una firma igual que la retro (dedup por diseño: dígitos → #)
function normSig(s) {
return String(s)
.replace(/https?:\/\/\S+/g, '#url')
.replace(/\b[0-9a-f]{8,}\b/gi, '#hex')
.replace(/\d+(\.\d+)?/g, '#')
.replace(/\s+/g, ' ')
.trim()
}
async function toolRemedy({ signature, remedy }) {
if (!signature || !remedy) return { error: 'signature + remedy requeridos' }
// 1) match exacto
let r = await pgc.query(
`UPDATE lessons SET remedy=$1 WHERE signature=$2 RETURNING signature, occurrences`,
[String(remedy).slice(0, 500), String(signature)])
// 2) match difuso: la línea formateada de lessons.md contiene la firma normalizada
// (la entidad suele copiar la línea completa con contador y "· ESCALADO")
if (!r.rowCount) {
const sigNorm = normSig(signature)
const cands = (await pgc.query(`SELECT signature FROM lessons WHERE status IN ('open','escalated')`)).rows
const hit = cands.find(c => sigNorm.includes(normSig(c.signature)))
if (hit) r = await pgc.query(
`UPDATE lessons SET remedy=$1 WHERE signature=$2 RETURNING signature, occurrences`,
[String(remedy).slice(0, 500), hit.signature])
}
if (!r.rowCount) {
return { error: `signature no encontrada: ${String(signature).slice(0, 120)}`, hint: 'usá la firma de la lista, sin el contador ni "· ESCALADO"' }
}
await log('green', 'lesson_remedy', { signature: r.rows[0].signature, remedy: String(remedy).slice(0, 200) })
return { updated: r.rows[0].signature, occurrences: r.rows[0].occurrences }
}
const TOOLS = {
web_fetch: toolWebFetch,
add_opportunity: toolAddOpportunity,
draft_proposal: toolDraftProposal,
publish_note: toolPublishNote,
add_lesson_remedy: toolRemedy,
}
// ── estado para el prompt
async function stateSummary() {
const opps = (await pgc.query('SELECT source_name, status FROM opportunities ORDER BY id DESC LIMIT 20')).rows
const zaps = (await pgc.query(`SELECT count(*) n, coalesce(sum(value),0) msats FROM metrics WHERE metric='zaps_msats'`)).rows[0]
const balance = (await pgc.query(`SELECT value FROM metrics WHERE metric='balance_reported_sats' ORDER BY id DESC LIMIT 1`)).rows[0]
const drafts = (await pgc.query('SELECT count(*) n FROM drafts')).rows[0].n
const lastActions = (await pgc.query('SELECT ts, action FROM actions_log ORDER BY id DESC LIMIT 5')).rows
const lessons = (await pgc.query(`SELECT signature, occurrences FROM lessons WHERE status != 'mitigated' ORDER BY occurrences DESC LIMIT 5`)).rows
return {
sats_recaudados: {
zaps_verificados: zaps.n,
balance_reportado_por_supervisor: balance ? Number(balance.value) : 'aún no reportado',
},
oportunidades_registradas: opps.length,
oportunidades_recientes: opps.map(o => `${o.source_name} [${o.status}]`),
drafts: drafts,
errores_conocidos_abiertos: lessons.map(l => `${l.signature} (${l.occurrences}×)`),
ultimas_acciones: lastActions.map(a => `${a.ts.toISOString().slice(0, 16)} ${a.action}`),
}
}
const TOOLS_SPEC = `TOOLS (respond with JSON only):
{"action":"web_fetch","args":{"url":"https://..."}}
{"action":"add_opportunity","args":{"source_name":"...","url":"...","mechanic":"cómo genera ingreso","fit":"encaje con hard lines","effort":"low|medium|high","expected":"ingreso esperado","first_action":"primer paso concreto"}}
{"action":"draft_proposal","args":{"kind":"sponsorship|grant|affiliate|other","title":"...","body":"propuesta completa lista para enviar"}}
{"action":"publish_note","args":{"content":"..."}}
{"action":"publish_note","args":{"content":"...— SOLO contenido público para la audiencia de Nostr, con voz editorial (irónico, cero marketing, cero reportes internos). Los reportes de ciclo, pipeline y status NUNCA se publican: eso va en finish."}}
{"action":"add_lesson_remedy","args":{"signature":"firma exacta de la lista de errores conocidos","remedy":"qué hacer distinto para evitar el error"}}
{"action":"finish","args":{"summary":"qué lograste este ciclo"}}
Una acción por respuesta. El sistema ejecuta y te devuelve el resultado. Si tu respuesta no es JSON válido, se te pide re-emitirla: responde SOLO el objeto JSON, sin texto alrededor.`
// ── el loop
async function runCycle() {
const state = await stateSummary()
// directiva del supervisor (2026-10-31): grants suspendidos, BTCPay prioridad 1.
// No modifica el MANDATE (eso es 🔴); es una orden operativa reversible con
// CAYCE_DIRECTIVE_BTCPAY=0 en el .env.
const DIRECTIVE = process.env.CAYCE_DIRECTIVE_BTCPAY === '1' ? `
=== DIRECTIVA DEL SUPERVISOR (2026-10-31 — prevalece sobre §7b hasta nuevo aviso) ===
1. SUSPENDIDA la búsqueda y registro de grants y nuevas oportunidades. NO uses add_opportunity salvo para la excepción del punto 2.
2. PRIORIDAD 1 ÚNICA: la propuesta de BTCPay Server — oportunidad #106 "BTCPay Server Deployment & Content Service" y draft #33 "BTCPay Server Plugin: Event & Travel Integration". Trabajo esperado: consolidar UNA propuesta final lista para envío humano (alcance, arquitectura, presupuesto, plan de entrega) como draft nuevo marcado claramente. NO la envíes: la envía el operador.
3. La contención está activa: publish_note crea BORRADORES, no publica. Sigue contando como acción productiva.
` : ''
const messages = [
{
role: 'user',
content: `${MANDATE}
${DIRECTIVE}
=== TU ESTADO ACTUAL ===
${JSON.stringify(state, null, 2)}
${LESSONS ? `
=== LECCIONES — errores conocidos, evitalos ===
${LESSONS}
` : ''}
=== TU MISIÓN ESTE CICLO ===
MANDATE §7b: produce AL MENOS una de: oportunidad de ingreso investigada (con web_fetch a fuentes reales — grants, afiliados, bounties, sponsors del ecosistema bitcoin), draft de propuesta, o contenido que impulse distribución. Investiga antes de registrar: abre las fuentes con web_fetch, verifica que existan y qué piden (QC-3).
Prioridad: ingreso. Piensa como empresa: ¿qué puedo vender, a quién, cómo cobro en sats? Las propiedades (hardcap.news = señal, bitcoinhotels.org = travel BTC) son tus activos y tu vidriera.
Si hay errores conocidos abiertos con remedio pendiente (lista arriba), considerá escribirlo con add_lesson_remedy — auto-mejora.
${TOOLS_SPEC}
Empieza ahora. Cuando hayas cumplido §7b, responde finish con un summary honesto.`,
},
]
let steps = 0
const maxSteps = 16
let repairs = 0
const maxRepairs = 3
let summary = null
const trace = []
const PRODUCTIVE = ['add_opportunity', 'draft_proposal', 'publish_note', 'add_lesson_remedy']
let productive = 0
while (steps < maxSteps) {
steps++
// presupuesto de pasos: empujar al cierre antes de agotar (evita 'max steps')
const remaining = maxSteps - steps
if (remaining === 3) {
messages.push({ role: 'user', content: 'QUEDAN 3 PASOS. Cierra el ciclo: si ya investigaste, ejecutá AHORA una acción productiva (add_opportunity / draft_proposal / publish_note / add_lesson_remedy) y después finish con el summary.' })
} else if (remaining === 1) {
messages.push({ role: 'user', content: 'ÚLTIMO PASO. Respondé finish con un summary honesto de lo logrado este ciclo, aunque sea parcial.' })
}
// la cadena (zai → gemini) agota los fallbacks; si todo falla, el ciclo
// muere auditado como error y la retro lo escala
let text = await chat(messages, 6000, { thinking: false, timeoutMs: 90000 })
text = text.replace(/```json|```/g, '').trim()
const js = text.indexOf('{')
const je = text.lastIndexOf('}')
let act = null
if (js === -1 || je === -1) {
// sin JSON: re-emisión en vez de morir — error reparable en el momento
if (repairs < maxRepairs) {
repairs++
messages.push({ role: 'assistant', content: text.slice(0, 500) })
messages.push({ role: 'user', content: 'Esa respuesta no contenía JSON. Re-emite SOLO el objeto JSON de UNA acción, sin texto alrededor.' })
steps--
continue
}
summary = 'sin JSON tras ' + maxRepairs + ' repairs: ' + text.slice(0, 120)
break
}
try { act = JSON.parse(text.slice(js, je + 1)) } catch (e) {
if (repairs < maxRepairs) {
repairs++
messages.push({ role: 'assistant', content: text.slice(0, 500) })
messages.push({ role: 'user', content: `Esa respuesta no fue JSON válido parseable (${String(e).slice(0, 80)}). Re-emite SOLO el objeto JSON de UNA acción. Si el body era muy largo, acortalo.` })
steps--
continue
}
summary = 'JSON inválido tras ' + maxRepairs + ' repairs'
break
}
if (act.action === 'finish') { summary = act.args?.summary ?? 'finish'; break }
if (!TOOLS[act.action]) {
messages.push({ role: 'assistant', content: JSON.stringify(act) })
messages.push({ role: 'user', content: `herramienta desconocida: ${act.action}. ${TOOLS_SPEC}` })
continue
}
let result
try {
result = await TOOLS[act.action](act.args || {})
} catch (e) {
result = { error: String(e).slice(0, 300) }
}
trace.push({ step: steps, action: act.action, args: act.args })
if (PRODUCTIVE.includes(act.action) && !result?.error) productive++
messages.push({ role: 'assistant', content: JSON.stringify(act) })
messages.push({ role: 'user', content: 'RESULTADO: ' + JSON.stringify(result).slice(0, 4000) })
}
// §7b se cumple con ≥1 acción productiva — si no, el ciclo falla y se audita como error
const s7b_ok = productive > 0
const finalSummary = summary ?? 'max steps'
await log(s7b_ok ? 'green' : 'red', 'initiative_cycle',
{ steps, productive, repairs, trace, summary: finalSummary }, s7b_ok ? 'done' : 'error')
const drafts24 = (await pgc.query(
`SELECT id, type, content FROM outbound_drafts WHERE ts > now() - interval '24 hours' ORDER BY id`
).catch(() => ({ rows: [] }))).rows
const draftLines = drafts24.map(d => `#${d.id} [${d.type}] ${String(d.content).replace(/\s+/g, ' ').split(' ').slice(0, 15).join(' ')}`)
const draftBlock = `\n\nBORRADORES contención (24h: ${draftLines.length}) — revisión y aprobación pendientes del operador:\n${draftLines.join('\n') || 'ninguno'}`
await dm(`INITIATIVE ${new Date().toISOString().slice(0, 10)} — ciclo ${s7b_ok ? 'cumplido' : 'falló'} · ${steps} pasos · ${productive} productivas · ${trace.map(t => t.action).join(' → ') || 'sin acciones'}\n\n${finalSummary}${draftBlock}`)
return { steps, productive, s7b_ok, summary: finalSummary, trace }
}
try {
const out = await runCycle()
console.log(JSON.stringify(out, null, 2))
await pgc.end()
process.exit(out.s7b_ok ? 0 : 1)
} catch (e) {
console.error('initiative fatal:', String(e).slice(0, 300))
await log('red', 'initiative_cycle', { error: String(e).slice(0, 300) }, 'error').catch(() => { })
await pgc.end().catch(() => { })
process.exit(1)
}
+163
View File
@@ -0,0 +1,163 @@
// cayce-mail — daemon de correo de la entidad (entidadnodal@gmail.com)
// Recibe: aprobaciones de afiliados, respuestas de merchants/grants → clasifica,
// registra en DB y avisa al supervisor por Nostr. Envia: outreach SMTP.
// Poll cada 5 min · estado en state.json · credenciales en .env (600)
import { ImapFlow } from 'imapflow'
import nodemailer from 'nodemailer'
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
import pg from 'pg'
import { gateOutbound } from './outbox.mjs'
const HOME = '/home/ubuntu/cayce-mail'
const ENV = Object.fromEntries(readFileSync(`${HOME}/.env`, 'utf8').trim().split('\n').map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)]))
const PGURL = readFileSync('/srv/cayce-front/.env', 'utf8').match(/^PGURL=(.+)$/m)[1]
const SUPERVISOR = readFileSync('/srv/cayce-nostr/.env', 'utf8').match(/^SUPERVISOR_NPUB_HEX=(.+)$/m)[1].trim()
const NOSTR = 'http://127.0.0.1:3011'
const pgc = new pg.Pool({ connectionString: PGURL })
const POLL_MS = 5 * 60 * 1000
const statePath = `${HOME}/state.json`
let state = { lastUid: 0, processed: 0, notified: 0 }
if (existsSync(statePath)) { try { state = { ...state, ...JSON.parse(readFileSync(statePath, 'utf8')) } } catch { } }
const save = () => writeFileSync(statePath, JSON.stringify(state, null, 1), { mode: 0o600 })
const log = (m) => console.log(`[cayce-mail ${new Date().toISOString().slice(5, 16)}] ${m}`)
async function dm(text) {
try {
await fetch(`${NOSTR}/dm`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to_hex: SUPERVISOR, content: text.slice(0, 900) }),
signal: AbortSignal.timeout(20000),
})
state.notified++
} catch (e) { console.error('dm:', String(e).slice(0, 80)) }
}
// ── clasificación de remitentes relevantes ──────────────────────────────────
const MERCHANTS = {
'bitrefill.com': 'Bitrefill',
'travala.com': 'Travala',
'trezor.io': 'Trezor',
'ledger.com': 'Ledger',
'bitbox.swiss': 'BitBox',
'coinkite.com': 'Coinkite',
'swan.com': 'Swan',
'bitaxe': 'Bitaxe',
'opensats.org': 'OpenSats',
'hrf.org': 'HRF',
'foundationdevices.com': 'Foundation',
'relai.ch': 'Relai',
'blink.sv': 'Blink',
}
function classify(from = '', subject = '') {
const f = from.toLowerCase(), s = subject.toLowerCase()
const merchant = Object.entries(MERCHANTS).find(([d]) => f.includes(d))
if (!merchant) return null
let kind = 'info'
if (/approv|accepted|welcome to|partner|afiliado aprobado/.test(s)) kind = 'APPROVAL'
else if (/reject|denied|not approved|declined/.test(s)) kind = 'REJECTION'
else if (/statement|receipt|transaction|invoice|payout|commission/.test(s)) kind = 'money'
else if (/verify|confirm|action required|complete your/.test(s)) kind = 'ACTION-REQUIRED'
return { merchant: merchant[1], kind }
}
async function processMessage(msg) {
const from = msg.envelope.from?.[0]?.address || ''
const subject = msg.envelope.subject || '(sin asunto)'
const c = classify(from, subject)
if (!c) return
log(`${c.kind} · ${c.merchant} · "${subject.slice(0, 60)}" <${from}>`)
// Registrar en actions_log (auditoría)
await pgc.query(
`INSERT INTO actions_log (zone, action, detail, result) VALUES ('mail', 'mail_received', $1, 'ok')`,
[{ from, subject, merchant: c.merchant, kind: c.kind, uid: msg.uid }]
).catch(e => console.error('db:', String(e).slice(0, 100)))
// Notificar al supervisor solo lo importante (no estados de cuenta)
if (c.kind === 'money') return
const emoji = c.kind === 'APPROVAL' ? '✅' : c.kind === 'REJECTION' ? '❌' : c.kind === 'ACTION-REQUIRED' ? '⚠' : '✉'
await dm(`${emoji} MAIL ${c.kind} — ${c.merchant}\n"${subject.slice(0, 80)}"\nDe: ${from}\nRevisar bandeja entidadnodal@gmail.com`)
}
async function pollInbox() {
const client = new ImapFlow({
host: 'imap.gmail.com', port: 993, secure: true,
auth: { user: ENV.MAIL_USER, pass: ENV.MAIL_PASS },
logger: false,
})
try {
await client.connect()
const lock = await client.getMailboxLock('INBOX')
try {
// primera pasada: marcar el punto de partida en el último mensaje existente
if (!state.lastUid) {
let last = 0
for await (const m of client.fetch({ all: true }, { uid: true }, { uid: true })) last = m.uid
state.lastUid = last
save()
log(`baseline: uid ${last} · ${client.mailbox.exists} mensajes en inbox`)
return
}
// mensajes nuevos desde el último UID visto
const range = { uid: `${state.lastUid + 1}:*` }
for await (const msg of client.fetch(range, { envelope: true, uid: true }, { uid: true })) {
if (msg.uid <= state.lastUid) continue
await processMessage(msg)
state.processed++
if (msg.uid > state.lastUid) state.lastUid = msg.uid
}
save()
} finally {
lock.release()
}
await client.logout()
} catch (e) {
console.error('[cayce-mail] poll error:', String(e).slice(0, 160))
try { await client.logout() } catch { }
}
}
// ── SMTP saliente para outreach ─────────────────────────────────────────────
export async function sendMail(to, subject, body) {
// contención (2026-10-31): el mail saliente también pasa por el outbox
const g = await gateOutbound(null, {
channel: 'mail', type: 'smtp_send', content: `To: ${to}\nSubject: ${subject}\n\n${body}`,
meta: { to, subject },
publishFn: () => _sendMailLive(to, subject, body),
})
if (g.staged) return g
}
async function _sendMailLive(to, subject, body) {
const transporter = nodemailer.createTransport({
host: 'smtp.gmail.com', port: 465, secure: true,
auth: { user: ENV.MAIL_USER, pass: ENV.MAIL_PASS },
})
const info = await transporter.sendMail({
from: `"Cayce Co" <${ENV.MAIL_USER}>`,
to, subject, text: body,
})
await pgc.query(
`INSERT INTO actions_log (zone, action, detail, result) VALUES ('mail', 'mail_sent', $1, 'ok')`,
[{ to, subject, messageId: info.messageId }]
).catch(() => { })
log(`sent → ${to} · "${subject.slice(0, 50)}"`)
return info
}
// CLI: node mail.mjs --send-to x@y --subject "..." --body-file z.txt
const args = process.argv.slice(2)
if (args.includes('--send-to')) {
const to = args[args.indexOf('--send-to') + 1]
const subject = args[args.indexOf('--subject') + 1] || '(sin asunto)'
const bodyFile = args[args.indexOf('--body-file') + 1]
const body = bodyFile ? readFileSync(bodyFile, 'utf8') : args[args.indexOf('--body') + 1] || ''
sendMail(to, subject, body).then(() => process.exit(0)).catch(e => { console.error(e); process.exit(1) })
} else {
log(`daemon on · poll cada ${POLL_MS / 60000} min · cuenta ${ENV.MAIL_USER}`)
pollInbox()
setInterval(pollInbox, POLL_MS)
}
+108
View File
@@ -0,0 +1,108 @@
// outbox.mjs — contención de salida a terceros (prioridad Cayce, 2026-10-31)
//
// CAYCE_PUBLISH_MODE=draft (default) → NADA sale a terceros: todo artefacto
// (notas nostr, respuestas a menciones, posts a redes sociales, mail) se
// guarda como BORRADOR en outbound_drafts con el hash del archivo de
// identidad vigente. El DM al supervisor NO pasa por aquí (es interno).
// CAYCE_PUBLISH_MODE=live → comportamiento original, sin cambios.
//
// Fail-closed: en modo draft, si el guardado del borrador falla, NO se
// publica. Nunca. El error queda en el journal.
//
// Reversibilidad: una línea por servicio en su .env (CAYCE_PUBLISH_MODE=live).
import { readFileSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { createRequire } from 'node:module'
const require_ = createRequire(import.meta.url)
const IDENTITY_PATH = process.env.IDENTITY_PATH || '/home/ubuntu/cayce-ops/prompts/cayce-identity.md'
export function publishMode() {
return (process.env.CAYCE_PUBLISH_MODE || 'draft').toLowerCase()
}
let identityCache = null
export function identityFingerprint() {
if (identityCache) return identityCache
try {
const raw = readFileSync(IDENTITY_PATH)
identityCache = {
path: IDENTITY_PATH,
hash: createHash('sha256').update(raw).digest('hex'),
}
} catch (e) {
identityCache = { path: IDENTITY_PATH, hash: null, error: String(e).slice(0, 120) }
}
return identityCache
}
export function loadIdentityBlock(name) {
const raw = readFileSync(IDENTITY_PATH, 'utf8')
const m = raw.match(
new RegExp(`<!-- cayce-identity:block=${name} -->\\n([\\s\\S]*?)\\n<!-- /cayce-identity:block=${name} -->`)
)
if (!m) throw new Error(`identity block not found: ${name}`)
return m[1]
}
export function ensureOutboxTable(poolPg) {
return poolPg.query(`
CREATE TABLE IF NOT EXISTS outbound_drafts (
id bigserial PRIMARY KEY,
ts timestamptz NOT NULL DEFAULT now(),
channel text NOT NULL,
type text NOT NULL,
content text NOT NULL,
original_event jsonb,
identity_path text,
identity_hash text,
status text NOT NULL DEFAULT 'pending',
meta jsonb
)`).catch(e => console.error('[outbox] ensure table:', String(e).slice(0, 120)))
}
// pool auto-suficiente: si el host no pasa uno, se conecta a la BD de la
// entidad (PGURL de /srv/cayce-core/.env) para que todos los borradores
// vivan juntos en outbound_drafts
let ownPool = null
async function poolFor(poolPg) {
if (poolPg) return poolPg
if (!ownPool) {
const pg = require_('/srv/cayce-core/node_modules/pg')
const env = Object.fromEntries(
readFileSync('/srv/cayce-core/.env', 'utf8').split('\n').filter(l => l.includes('=')).map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)])
)
ownPool = new pg.Pool({ connectionString: env.PGURL })
}
return ownPool
}
export async function stageDraft(poolPg, { channel, type, content, original_event = null, meta = {} }) {
poolPg = await poolFor(poolPg)
const id = identityFingerprint()
const r = await poolPg.query(
`INSERT INTO outbound_drafts (channel, type, content, original_event, identity_path, identity_hash, meta)
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id`,
[channel, type, String(content), original_event ? JSON.stringify(original_event) : null,
id.path, id.hash, JSON.stringify(meta)]
)
console.log(`[outbox] STAGED draft #${r.rows[0].id} (${channel}/${type}) — NO publicado (mode=${publishMode()})`)
return { staged: true, draft_id: r.rows[0].id, mode: publishMode() }
}
// El único punto por el que debe pasar cualquier salida a terceros.
// publishFn SOLO se invoca en modo live.
export async function gateOutbound(poolPg, { channel, type, content, original_event = null, meta = {}, publishFn }) {
if (publishMode() === 'live') {
return { ...(await publishFn()), staged: false }
}
try {
return await stageDraft(poolPg, { channel, type, content, original_event, meta })
} catch (e) {
// fail-closed: sin borrador guardado, no se publica
console.error(`[outbox] FATAL: staging failed (${String(e).slice(0, 160)}) — evento DESCARTADO, no publicado`)
return { staged: false, error: 'staging failed; discarded (fail-closed)' }
}
}
+192
View File
@@ -0,0 +1,192 @@
// cayce-promo — publicación autónoma temática de la entidad
// Cola de campaña: 12 posts / 6 días / 2 por día (13:00 y 19:00 UTC)
// Cada post: tema específico → link a bitcoinhotels.org o hardcap.news
// Publica vía sfeed (FB + IG) con imágenes de marca generadas on-the-fly.
// Ledger en DB (posts_log) para medir y optimizar.
import { execFileSync } from 'node:child_process'
import { gateOutbound } from './outbox.mjs'
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
const HOME = '/home/ubuntu/cayce-social'
const SFEED = '/home/ubuntu/.sfeed/bin/sfeed'
const statePath = `${HOME}/promo-state.json`
let state = { done: [] }
if (existsSync(statePath)) { try { state = { ...state, ...JSON.parse(readFileSync(statePath, 'utf8')) } } catch { } }
const save = () => writeFileSync(statePath, JSON.stringify(state, null, 1), { mode: 0o600 })
const log = (m) => console.log(`[cayce-promo ${new Date().toISOString().slice(5, 16)}] ${m}`)
// ── generador de imágenes de marca ──────────────────────────────────────────
// brand: 'bh' (bitcoinhotels — dorado) | 'hc' (hardcap — naranja)
function makeImage(out, brand, { kicker, lines, tagline, sub, url, drawRule = true }) {
const bg = brand === 'bh' ? '"#141210-#0A0908"' : '"#0d0d10-#09090B"'
const accent = brand === 'bh' ? '#C9A84C' : '#F7931A'
const textC = brand === 'bh' ? '#F2ECE0' : '#FAFAFA'
const muted = brand === 'bh' ? 'rgba(242,236,224,0.6)' : 'rgba(250,250,250,0.65)'
const kickC = brand === 'bh' ? '#8a7a4a' : '#71717A'
let cmd = ['convert', '-size', '1080x1350', 'gradient:' + bg]
let y = 260
if (kicker) { cmd.push('-font', 'DejaVu-Serif', '-pointsize', '44', '-fill', kickC, '-annotate', `+90+${y}`, kicker); y += 130 }
for (const l of lines) { cmd.push('-font', 'DejaVu-Serif-Bold', '-pointsize', String(l.size || 110), '-fill', l.color || textC, '-annotate', `+90+${y}`, l.t); y += (l.size || 110) + 25 }
y += 20
if (tagline) for (const t of tagline) { cmd.push('-font', 'DejaVu-Sans', '-pointsize', '36', '-fill', muted, '-annotate', `+90+${y}`, t); y += 62 }
if (sub) for (const t of sub) { cmd.push('-font', 'DejaVu-Sans', '-pointsize', String(sub.size || 40), '-fill', sub.color || accent, '-annotate', `+90+${y}`, t.t); y += (sub.size || 40) + 12 }
if (drawRule) cmd.push('-fill', accent, '-draw', `rectangle 90,${y + 40} 560,${y + 42}`)
cmd.push('-font', 'DejaVu-Sans', '-pointsize', '40', '-fill', accent, '-annotate', `+90+${y + 120}`, url)
cmd.push(out)
execFileSync('convert', cmd)
execFileSync('convert', [out, '-quality', '92', out]) // → JPEG final
}
// ── campaña: 12 posts / 6 días ──────────────────────────────────────────────
// day_offset: días desde el arranque · hour_utc: hora de publicación
const CAMPAIGN = [
{ id: 'd1-fb-lightning', day: 0, hour: 13, net: 'facebook', format: 'image', brand: 'bh',
theme: 'lightning-abroad', link: 'https://bitcoinhotels.org/guides/how-to-pay-with-lightning-abroad',
img: { kicker: 'B E G I N N E R G U I D E', lines: [{ t: 'HOW TO PAY', size: 115 }, { t: 'WITH', size: 115 }, { t: 'LIGHTNING', size: 125, color: '#C9A84C' }, { t: 'ABROAD', size: 105, color: '#C9A84C' }], tagline: ['Wallets · Funding · Gift-card hacks', 'What to do when the card gets declined.'], url: 'bitcoinhotels.org/guides' },
caption: 'Paying abroad with Lightning beats your bank: no foreign transaction fees, no dynamic currency conversion, no declines. The complete beginner guide — wallets, funding, and the gift-card trick that rescues you when the card fails mid-trip. bitcoinhotels.org/guides/how-to-pay-with-lightning-abroad' },
{ id: 'd1-ig-seed', day: 0, hour: 19, net: 'instagram', format: 'image', brand: 'hc',
theme: 'seed-strategy', link: 'https://hardcap.news/learn/travel-security-kit',
img: { kicker: 'S E C U R I T Y', lines: [{ t: 'SEEDS THAT', size: 110 }, { t: 'SURVIVE', size: 110 }, { t: 'A SEARCH', size: 110, color: '#F7931A' }], tagline: ['Steel plate in checked luggage?', 'Memorized + decoy?', 'Split custody — nothing to find.'], sub: { t: 'hardcap.news/learn/travel-security-kit' }, url: ' ' },
caption: 'Steel plate in checked luggage, memorized + decoy, or split custody: the seed strategy you pick before the trip decides how the border story goes. The Travel Security Kit breaks down all three — and the passphrase pattern that leaves nothing to confiscate. hardcap.news/learn/travel-security-kit #bitcoin #selfcustody #seedphrase #opsec' },
{ id: 'd2-fb-wrench', day: 1, hour: 13, net: 'facebook', format: 'text', brand: 'hc', theme: 'wrench-data', link: 'https://hardcap.news/learn/travel-security-kit', img: null,
caption: 'The 2026 wrench-attack dataset shows a pattern: victims are almost never selected by chain analysis. They are selected by behavior — talking about holdings, wearing the merch, fumbling with seed material in public. OpSec is free; everything else costs sats. Field guide: hardcap.news/learn/travel-security-kit' },
{ id: 'd2-ig-atm-fees', day: 1, hour: 19, net: 'instagram', format: 'carousel', brand: 'bh',
theme: 'atm-fees', link: 'https://bitcoinhotels.org/guides/best-bitcoin-atms-by-city',
img: { kicker: 'R E F E R E N C E', lines: [{ t: 'THE FEE', size: 110 }, { t: 'REALITY', size: 110 }], tagline: ['Tourist at the first machine: 10-15% over spot.', 'Prepared traveler: 4-6%.'], sub: { t: 'Swipe →' }, url: 'bitcoinhotels.org/guides' },
caption: 'The first ATM you find charges 10-15% over spot. The prepared traveler pays 4-6%. The difference is knowing the dominant operator and the no-KYC cap before you land. City-by-city reference → bitcoinhotels.org/guides/best-bitcoin-atms-by-city #bitcoin #btc #travel #atm' },
{ id: 'd3-fb-budget', day: 2, hour: 13, net: 'facebook', format: 'image', brand: 'bh',
theme: 'elsalvador-budget', link: 'https://bitcoinhotels.org/guides/living-on-sats-in-el-salvador',
img: { kicker: 'D E E P D I V E', lines: [{ t: 'LIVING', size: 120 }, { t: 'ON SATS', size: 120 }, { t: 'EL SALVADOR', size: 105, color: '#C9A84C' }], tagline: ['Real weekly budgets in sats.', 'El Zonte vs San Salvador.', 'The 90-day experiment checklist.'], url: 'bitcoinhotels.org/guides' },
caption: 'A real weekly budget on a Bitcoin standard: surf hostel 17-29k sats a night, comidas 11-17k a day, coworking 7-11k — and the corners where you still need dollars. Living on sats in El Salvador, the full breakdown. bitcoinhotels.org/guides/living-on-sats-in-el-salvador' },
{ id: 'd3-ig-rescue', day: 2, hour: 19, net: 'instagram', format: 'image', brand: 'hc',
theme: 'rescue-rail', link: 'https://hardcap.news/learn/travel-security-kit',
img: { kicker: 'L I Q U I D I T Y', lines: [{ t: 'THE RESCUE', size: 115 }, { t: 'RAIL', size: 115, color: '#F7931A' }], tagline: ['Zero sats abroad and the card fails:', 'a gift card in seconds is the difference', 'between dinner and no dinner.'], sub: { t: 'hardcap.news/learn/travel-security-kit' }, url: ' ' },
caption: 'Zero sats abroad, bank card declined, ATM laughing. The rescue rail: sats → gift card in seconds, 186 countries, instant delivery. The single highest-value item in the travel security kit. hardcap.news/learn/travel-security-kit #bitcoin #lightning #traveltips' },
{ id: 'd4-fb-roatann', day: 3, hour: 13, net: 'facebook', format: 'text', brand: 'bh', theme: 'roatann-prospera', link: 'https://bitcoinhotels.org/destinations/roatan-honduras', img: null,
caption: 'Roatán & Próspera: Bitcoin is legal tender inside the world\'s most Bitcoin-native jurisdiction. Island life, world-class diving on the Mesoamerican Reef, and hotels natively accepting Lightning. The destination guide: bitcoinhotels.org/destinations/roatan-honduras' },
{ id: 'd4-ig-device', day: 3, hour: 19, net: 'instagram', format: 'carousel', brand: 'hc',
theme: 'device-choice', link: 'https://hardcap.news/learn/travel-security-kit',
img: { kicker: 'K I T P I C K S', lines: [{ t: 'ONE DEVICE,', size: 110 }, { t: 'ONE PATTERN', size: 110 }], tagline: ['Two devices = two seeds traveling twice', 'the distance. Pick one, know its', 'recovery drill cold.'], sub: { t: 'hardcap.news/learn/travel-security-kit' }, url: ' ' },
caption: 'One hardware wallet, not two. Two devices means two seeds traveling twice the distance, and the failure modes compound. The hotel-room test: can you restore from seed onto a fresh device in under ten minutes without looking anything up? The full kit → hardcap.news/learn/travel-security-kit #bitcoin #hardwarewallet #selfcustody' },
{ id: 'd5-fb-kyc', day: 4, hour: 13, net: 'facebook', format: 'image', brand: 'bh',
theme: 'kyc-caps', link: 'https://bitcoinhotels.org/guides/best-bitcoin-atms-by-city',
img: { kicker: 'R E F E R E N C E', lines: [{ t: 'KYC CAPS', size: 120 }, { t: 'BY CITY', size: 120, color: '#C9A84C' }], tagline: ['Amsterdam EUR 250-750 · Prague CZK 5k-25k', 'Miami USD 200-800 · and 4 more cities'], url: 'bitcoinhotels.org/guides' },
caption: 'Most Bitcoin ATMs let you buy small amounts with just a phone number; above that, ID scan. If your trip budget is under the no-KYC cap, split purchases across days instead of uploading your passport to the cheapest machine. Caps by city: bitcoinhotels.org/guides/best-bitcoin-atms-by-city' },
{ id: 'd5-ig-mistakes', day: 4, hour: 19, net: 'instagram', format: 'image', brand: 'hc',
theme: '3-mistakes', link: 'https://hardcap.news/learn/travel-security-kit',
img: { kicker: 'F I E L D T E S T E D', lines: [{ t: '3 MISTAKES', size: 115 }, { t: 'THAT GET', size: 115 }, { t: 'BTC TAKEN', size: 115, color: '#EF4444' }], tagline: ['Inventory on arrival forms.', 'The public seed moment.', 'Everything in one bag.'], sub: { t: 'hardcap.news/learn/travel-security-kit' }, url: ' ' },
caption: 'Three mistakes that get sats confiscated at borders: declaring what your passphrase structure keeps private, verifying seeds in airport lounges, and carrying device + seed + cash in one bag. Every wrench attack we track started with behavior. The kit: hardcap.news/learn/travel-security-kit #bitcoin #opsec #security' },
{ id: 'd6-fb-travala', day: 5, hour: 13, net: 'facebook', format: 'image', brand: 'bh',
theme: 'booking-btc', link: 'https://bitcoinhotels.org/guides/living-on-sats-in-el-salvador',
img: { kicker: 'B O O K I N G', lines: [{ t: 'RESERVE WITH', size: 110 }, { t: 'BITCOIN,', size: 110 }, { t: 'ARRIVE ON SATS', size: 100, color: '#C9A84C' }], tagline: ['The no-card, no-reservation loop,', 'solved before the flight.'], url: 'bitcoinhotels.org/guides' },
caption: 'The no-card, no-reservation loop: hotels that want a credit card guarantee when you only hold sats. Booking with Bitcoin before arrival breaks the loop — Travala lists dozens of Salvadoran properties taking BTC directly. More in the El Salvador guide: bitcoinhotels.org/guides/living-on-sats-in-el-salvador' },
{ id: 'd6-ig-protocol', day: 5, hour: 19, net: 'instagram', format: 'image', brand: 'bh',
theme: '90day-protocol', link: 'https://bitcoinhotels.org/guides/living-on-sats-in-el-salvador',
img: { kicker: 'T H E P R O T O C O L', lines: [{ t: '90 DAYS', size: 125 }, { t: 'ON SATS', size: 125, color: '#C9A84C' }], tagline: ['Tourist visa → El Zonte two weeks →', 'Blink funded to 500k sats → track', 'every payment → decide your split.'], url: 'bitcoinhotels.org/guides' },
caption: 'Before committing to a season on sats, run the protocol: enter on a tourist visa, base in El Zonte two weeks with a Lightning wallet funded to 500k sats, track every payment for a week, then decide your split. Most long-termers land on 70/30 sats/dollars. Full protocol: bitcoinhotels.org/guides/living-on-sats-in-el-salvador #bitcoin #elsalvador #bitcoinbeach' },
]
// ── publicación ─────────────────────────────────────────────────────────────
function sfeedPost(args) {
return execFileSync(SFEED, ['post', ...args], { encoding: 'utf8', timeout: 120000 })
}
// contención: el gate se resuelve en el sitio de llamada (run())
async function logDb(entry, postUrl, status) {
const pgc = new (await import('pg')).default.Pool({ connectionString: readFileSync('/srv/cayce-front/.env', 'utf8').match(/^PGURL=(.+)$/m)[1] })
await pgc.query(
`INSERT INTO posts_log (platform, theme, format, link, post_url, status) VALUES ($1,$2,$3,$4,$5,$6)`,
[entry.net, entry.theme, entry.format, entry.link, postUrl || null, status]
).catch(e => console.error('db:', String(e).slice(0, 120)))
await pgc.query(
`INSERT INTO actions_log (zone, action, detail, result) VALUES ('promo', 'promo_published', $1, $2)`,
[JSON.stringify({ id: entry.id, theme: entry.theme, platform: entry.net, link: entry.link, post_url: postUrl }), status]
).catch(() => { })
await pgc.end()
}
async function run() {
const now = new Date()
const boot = existsSync(`${HOME}/campaign-start.json`)
? new Date(JSON.parse(readFileSync(`${HOME}/campaign-start.json`, 'utf8')).start)
: null
let published = 0
for (const entry of CAMPAIGN) {
if (state.done.includes(entry.id)) continue
if (!boot) continue // primer run: solo fija arranque
const due = new Date(boot.getTime() + entry.day * 86400000)
due.setUTCHours(entry.hour, 0, 0, 0)
if (now < due) continue
log(`publicando ${entry.id} · tema ${entry.theme} · ${entry.net}`)
const args = ['--to', entry.net]
if (entry.net === 'facebook') args.push('--page', 'Bitcoin Travelers')
try {
if (entry.format === 'carousel') {
// 2 slides: cover + contenido (mismo estilo de marca)
const img1 = `${HOME}/media/${entry.id}-1.jpg`
const img2 = `${HOME}/media/${entry.id}-2.jpg`
mkdirs(HOME + '/media')
makeImage(img1, entry.brand, entry.img)
makeImage(img2, entry.brand, { ...entry.img, kicker: 'E N L A G U I A', lines: [{ t: 'LINK EN', size: 100 }, { t: 'EL POST', size: 100, color: entry.brand === 'bh' ? '#C9A84C' : '#F7931A' }], tagline: entry.link.replace('https://', '').split('/').slice(0, 3), drawRule: false, url: ' ' })
args.push('--kind', 'carousel', '--media', `${img1},${img2}`)
} else if (entry.format === 'image') {
const img = `${HOME}/media/${entry.id}.jpg`
mkdirs(HOME + '/media')
makeImage(img, entry.brand, entry.img)
args.push('--media', img)
}
args.push(entry.caption)
const g = await gateOutbound(null, {
channel: 'social', type: `promo_${entry.net}`, content: entry.caption,
meta: { id: entry.id, theme: entry.theme || null, format: entry.format || null },
publishFn: async () => sfeedPost(args),
})
if (g.staged) {
log(`⇢ ${entry.id} → STAGED como borrador #${g.draft_id} (NO publicado a ${entry.net})`)
await logDb(entry, null, 'staged')
state.done.push(entry.id); save()
continue
}
const out = sfeedPost(args)
const url = out.match(/https:\/\/[^\s]+/)?.[0] || null
log(`✓ ${entry.id} → ${url || 'publicado'}`)
await logDb(entry, url, 'ok')
state.done.push(entry.id); save()
published++
} catch (e) {
const msg = String(e).slice(0, 160)
log(`✖ ${entry.id}: ${msg}`)
await logDb(entry, null, 'error: ' + msg)
// no marca done — reintenta en el próximo ciclo
}
break // 1 publicación por ciclo — ritmo natural
}
if (!boot) {
writeFileSync(`${HOME}/campaign-start.json`, JSON.stringify({ start: now.toISOString() }, null, 1))
log(`campaña iniciada: ${now.toISOString()} · 12 posts / 6 días`)
}
if (published === 0 && state.done.length >= CAMPAIGN.length) log('campaña completa — todos los posts publicados')
}
function mkdirs(p) { if (!existsSync(p)) execFileSync('mkdir', ['-p', p]) }
run().catch(e => { console.error('promo error:', String(e).slice(0, 200)); process.exit(1) })
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/node
// publish-10050 — publica el kind 10050 (NIP-65 memento: relays de inbox
// para DMs/gift wraps) de Cayce. Autorizado explícitamente por el operador.
// Se ejecuta UNA vez (script, no daemon).
import { readFileSync } from 'node:fs'
import { SimplePool, finalizeEvent, getPublicKey } from 'nostr-tools'
const env = Object.fromEntries(
readFileSync('/srv/cayce-nostr/.env', 'utf8').split('\n').filter(l => l.includes('=')).map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)])
)
const skRaw = env.NSEC_HEX
const { nsecDeserialize } = await import('nostr-tools') // soporta hex y bech32 vía fallback
let sk
try { sk = skRaw.startsWith('nsec1') ? nsecDeserialize(skRaw) : Buffer.from(skRaw, 'hex') } catch (e) {
console.error('nsec ilegible:', String(e).slice(0, 80)); process.exit(1)
}
const pk = getPublicKey(sk)
// relays donde Cayce ESCUCHA DMs (los mismos del listener) — son su inbox
const INBOX = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.primal.net',
'wss://relay.snort.social',
'wss://nostr-pub.wellorder.net',
'wss://public.relaying.io',
]
const ev = finalizeEvent({
kind: 10050,
created_at: Math.floor(Date.now() / 1000),
tags: INBOX.map(r => ['relay', r]),
content: '',
}, sk)
console.log('[10050] pubkey:', pk)
console.log('[10050] relays de inbox declarados:', INBOX.join(', '))
const pool = new SimplePool()
const ok = []
for (const r of INBOX) {
try {
await Promise.race([
pool.publish([r], ev),
new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 6000)),
])
ok.push(r)
} catch (e) { console.error(`[10050] ${r}: ${String(e).slice(0, 60)}`) }
}
pool.close(INBOX)
console.log(`[10050] publicado/aceptado en ${ok.length}/${INBOX.length} relays — event id ${ev.id}`)
+36
View File
@@ -0,0 +1,36 @@
import { loadIdentityBlock, identityFingerprint } from './outbox.mjs'
import { readFileSync } from 'node:fs'
const core = readFileSync('/srv/cayce-core/core.mjs', 'utf8')
const ed = readFileSync('/srv/cayce-core/editorials.mjs', 'utf8')
function blockFromOriginal(src, start, endMark) {
const i = src.indexOf(start)
if (i === -1) return null
return src.slice(i, src.indexOf(endMark, i) + endMark.length)
}
const checks = []
const dOrig = blockFromOriginal(core, 'You are Cayce, autonomous entity. Voice: dry, declarative, short sentences, zero corporate vocabulary', 'Stories already include URLs; keep them intact.')
checks.push(['digest', loadIdentityBlock('digest') === dOrig])
const eOrig = blockFromOriginal(ed, 'You are Cayce — autonomous entity, Próspera ZEDE', 'End with one sharp line of judgment.')
checks.push(['editorial', loadIdentityBlock('editorial') === eOrig])
// opinion/reflection/events: la parte estática original termina antes del separador dinámico
const oStart = 'You are Cayce, autonomous entity on Nostr. Voice: dry, declarative, short sentences, pattern-vs-apophenia as lens'
const oEnd = 'End with the article URL.'
checks.push(['opinion', loadIdentityBlock('opinion') === blockFromOriginal(core, oStart, oEnd)])
const rStart = 'You are Cayce, autonomous entity on Nostr. Write ONE reflective post'
const rEnd = '(use, ignore, or push against):'
checks.push(['reflection', loadIdentityBlock('reflection') === blockFromOriginal(core, rStart, rEnd)])
const vStart = 'You are Cayce, autonomous entity on Nostr. Voice: dry, declarative, short sentences, no hashtags'
const vEnd = 'If an event has a URL, include it.'
checks.push(['events', loadIdentityBlock('events') === blockFromOriginal(core, vStart, vEnd)])
let all = true
for (const [k, ok] of checks) { console.log(k, ok ? 'IDENTICO ✓' : 'DIFIERE ✗'); if (!ok) all = false }
console.log('fingerprint:', JSON.stringify(identityFingerprint()))
process.exit(all ? 0 : 1)
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# check-credential-leaks — falla si journald reciente contiene patrones de credencial.
# Regla permanente: los secretos jamás en logs. Ejecutar manualmente o desde cron
# (sin timer propio: no fue autorizado).
set -euo pipefail
SINCE="${1:-2 hours ago}"
HITS=$(journalctl --since "$SINCE" --no-pager 2>/dev/null | grep -cE "(postgres|postgresql)://[A-Za-z0-9_]+:[^@[:space:]]{8,}@|nsec1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{50,}" || true)
if [ "$HITS" -gt 0 ]; then
echo "ALERTA: $HITS líneas de journald (desde $SINCE) contienen patrones de credencial" >&2
journalctl --since "$SINCE" --no-pager 2>/dev/null | grep -nE "(postgres|postgresql)://[A-Za-z0-9_]+:[^@[:space:]]{8,}@|nsec1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{50,}" | sed -E "s#(://[A-Za-z0-9_]+:)[^@[:space:]]{8,}@#\1***@#" | head -5
exit 1
fi
echo "OK: sin patrones de credencial en journald desde $SINCE"
+68
View File
@@ -0,0 +1,68 @@
"""Regla permanente (2026-10-31): ningún fichero versionado contiene patrones
de credenciales. Placeholders de .env.example están permitidos.
Fallará si alguien mete un DSN con contraseña real, un token en claro, o
cualquier valor que deba vivir solo en .env / tokens.json.
"""
from __future__ import annotations
import re
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
SCAN_DIRS = ["runtime", "deploy", "docs", "scripts", "prompts"]
SCAN_GLOBS = ["*.py", "*.mjs", "*.js", "*.ts", "*.md", "*.yml", "*.yaml", "*.sh", "*.sql"]
# DSN con credencial literal (user:pass@host) — excluye placeholders conocidos
DSN = re.compile(r"(postgres|postgresql|mysql|mongodb)(\+[a-z]+)?://[A-Za-z0-9_]+:(?!password\b|change-me\b|<[^>]+>)[^@\s/]{8,}@", re.I)
# tokens hex largos fuera de tests (los fixtures de tests están allowlistados por diseño)
HEXTOKEN = re.compile(r"\b[0-9a-f]{64}\b")
# nsec bech32
NSEC = re.compile(r"nsec1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{50,}")
ALLOW_SUBSTR = ("user:password", "change-me", "<", "EXAMPLE", "example", "itest-pg", "0011", "11*", "22*", "33*", "44*")
def _iter_files():
for d in SCAN_DIRS:
base = REPO / d
if not base.exists():
continue
for pat in SCAN_GLOBS:
for f in base.rglob(pat):
if ".git" in f.parts or "node_modules" in f.parts:
continue
yield f
def test_no_dsn_with_credentials():
hits = []
for f in _iter_files():
for i, line in enumerate(f.read_text(errors="ignore").splitlines(), 1):
if DSN.search(line) and not any(a in line for a in ALLOW_SUBSTR):
hits.append(f"{f}:{i}")
assert not hits, f"DSN con credencial literal en: {hits}"
def test_no_nsec_in_versioned_files():
hits = []
for f in _iter_files():
if "/tests/" in str(f):
continue
for i, line in enumerate(f.read_text(errors="ignore").splitlines(), 1):
if NSEC.search(line):
hits.append(f"{f}:{i}")
assert not hits, f"nsec en fichero versionado: {hits}"
def test_no_raw_hex_tokens_outside_tests():
"""Tokens hex de 64 chars fuera de tests/ y del conftest (las claves de
test viven solo en tests/). El outbox no debe contener secretos."""
hits = []
for f in _iter_files():
if "/tests/" in str(f) or f.name == "cayce-identity.md":
continue
for i, line in enumerate(f.read_text(errors="ignore").splitlines(), 1):
if HEXTOKEN.search(line):
hits.append(f"{f}:{i}")
assert not hits, f"posible token hex versionado: {hits}"
+80
View File
@@ -0,0 +1,80 @@
"""Spanish negation tests (round-3 point 4): merchants write in Spanish;
negations like 'nunca', 'lamentablemente' or 'no podemos' must veto
`approval` exactly like their English counterparts. Veto rule unchanged:
when in doubt -> needs_review."""
from __future__ import annotations
import pytest
from cayceops.components.maild import classify as clf
from cayceops.models import MailClassification
APPROVAL_ONLY = MailClassification.APPROVAL
def _never_approval(cfg_factory, text: str) -> None:
kind, _ = clf.classify(text, cfg_factory())
assert kind is not APPROVAL_ONLY, f"{text!r} classified as APPROVAL"
def test_english_baseline_still_works(cfg):
config = cfg()
kind, _ = clf.classify("Your application was approved, welcome to the program", config)
assert kind is APPROVAL_ONLY
@pytest.mark.parametrize(
"text",
[
# 1. rejection stem
"Lamentamos informarle que su solicitud fue rechazada",
# 2. nunca
"Nunca aceptamos negocios de criptomonedas",
# 3. jamás
"Jamás hemos trabajado con affiliates externos",
# 4. tampoco
"No podemos aceptar su sitio, tampoco podemos ofrecerle otro acuerdo",
# 5. ningún/ninguna
"En este momento no tenemos ningún hueco para nuevos partners",
# 6. no podemos (multi-word)
"Su propuesta es interesante pero no podemos proceder",
# 7. no es posible (multi-word)
"Gracias por escribir, pero no es posible colaborar este trimestre",
# 8. lamentablemente
"Lamentablemente su aplicación no ha sido aprobada",
# 9. no aceptamos
"Por política interna no aceptamos sitios de viajes",
# 10. mixed spanish/english: negation in spanish, approval word in english
"Thanks for reaching out! Lamentablemente we cannot approve your site",
# 11. mixed: approval in spanish, negation in english
"Apreciamos su interés, but we are not accepting new partners",
# 12. nadie / nada
"Aquí nadie decidiría eso, así que no hay nada que aprobar",
],
)
def test_spanish_and_mixed_negations_veto_approval(cfg, text):
_never_approval(cfg, text)
def test_spanish_pure_approval_still_approves(cfg):
"""A clean Spanish approval WITHOUT any negation must still classify
approval — the veto must not over-fire."""
config = cfg()
kind, confidence = clf.classify(
"Su solicitud fue aprobada. Bienvenido al programa, estamos encantados "
"de trabajar con ustedes.",
config,
)
assert kind is APPROVAL_ONLY
assert confidence >= 0.5
def test_spanish_rejection_classifies_rejection(cfg):
config = cfg()
kind, conf = clf.classify(
"Lamentamos informarle que su solicitud fue rechazada por el comité",
config,
)
assert kind is MailClassification.REJECTION
assert conf >= 0.9