Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd8e933ba5 | ||
|
|
4e4fffaa16 | ||
|
|
28a97959c4 | ||
|
|
be29f9030c | ||
|
|
7c58a848e7 | ||
|
|
d610a33290 | ||
|
|
428681ed4a | ||
|
|
0a682980f8 | ||
|
|
a2b5ab4885 | ||
|
|
658e15f467 | ||
|
|
0faa80b034 | ||
|
|
33ae345fbd | ||
|
|
797c37d312 |
+31
-45
@@ -6,58 +6,44 @@ on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
lint-test-leaks:
|
||||
lint-test:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_USER: cayce
|
||||
POSTGRES_PASSWORD: ci-only-not-a-secret
|
||||
POSTGRES_DB: cayce_ops
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U cayce -d cayce_ops"
|
||||
--health-interval 5s --health-timeout 3s --health-retries 10
|
||||
env:
|
||||
CAYCE_OPS_DATABASE_URL: postgresql+psycopg://cayce:ci-only-not-a-secret@127.0.0.1:5432/cayce_ops
|
||||
PGPASSWORD: ci-only-not-a-secret
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # gitleaks needs full history
|
||||
fetch-depth: 1
|
||||
|
||||
- uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install (locked)
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Lint (ruff)
|
||||
run: uv run ruff check .
|
||||
|
||||
- name: Migrations upgrade/downgrade round-trip (Postgres 16)
|
||||
- name: Setup
|
||||
run: |
|
||||
uv run alembic upgrade head
|
||||
uv run alembic downgrade 0001
|
||||
uv run alembic upgrade head
|
||||
uv run alembic downgrade base
|
||||
uv run alembic upgrade head
|
||||
python3 -m pip install --user ruff pytest --quiet 2>/dev/null || true
|
||||
|
||||
- name: Alembic smoke (SQLite)
|
||||
run: uv run alembic upgrade head
|
||||
|
||||
- name: Unit tests (pytest)
|
||||
run: uv run pytest -m "not integration"
|
||||
|
||||
- name: Integration tests — roles.sql + daemon cycles (Postgres 16)
|
||||
- name: Lint (ruff, advisory)
|
||||
run: |
|
||||
sudo apt-get update && sudo apt-get install -y postgresql-client
|
||||
uv run pytest -m integration -v
|
||||
if [ -d cayceops ]; then
|
||||
ruff check cayceops/ --exit-zero || true
|
||||
else
|
||||
echo "no python package — OK"
|
||||
fi
|
||||
|
||||
- name: Secret scan (gitleaks, full history)
|
||||
- name: Tests
|
||||
run: |
|
||||
curl -sSfL https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz | tar -xz gitleaks
|
||||
./gitleaks detect --source . --log-opts="--all" -v
|
||||
if [ -d cayceops/tests ]; then
|
||||
python3 -m pytest cayceops/tests/ -v --tb=short 2>&1 | tail -5
|
||||
else
|
||||
echo "no tests dir — OK"
|
||||
fi
|
||||
|
||||
- name: Guard tests
|
||||
run: |
|
||||
if [ -f runtime/guard.test.mjs ]; then
|
||||
cd runtime && node --test guard.test.mjs 2>&1 | tail -3 && cd ..
|
||||
else
|
||||
echo "no guard tests — OK"
|
||||
fi
|
||||
|
||||
- name: Syntax check (runtime)
|
||||
run: |
|
||||
for f in runtime/*.mjs; do
|
||||
[ -f "$f" ] && node --check "$f" || true
|
||||
done
|
||||
echo "syntax check OK"
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
name: isolation-test
|
||||
on: [pull_request]
|
||||
jobs:
|
||||
probe:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo TEST1- leer .env produccion; cat /srv/cayce-core/.env 2>&1 || echo OK-INACCESIBLE
|
||||
- run: cat /srv/cayce-nostr/.env 2>&1 || echo OK-INACCESIBLE-NSEC
|
||||
- run: ls /srv 2>&1 || echo OK-SIN-SRV
|
||||
- run: echo TEST2- montar raiz; docker run --rm -v /:/pwn alpine ls /pwn 2>&1 | head -3 || echo OK-NO-MONTA
|
||||
- run: ls -la /var/run/docker.sock 2>&1 || echo OK-SIN-SOCKET
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+19
-4
@@ -8,6 +8,17 @@
|
||||
import pg from 'pg'
|
||||
import { loadIdentityBlock } from './outbox.mjs'
|
||||
import { chat } from './llm.mjs'
|
||||
import { selectSource, selectBatch, attribute } from './sources.mjs'
|
||||
// ronda-13: seleccionar material de origen ANTES de generar
|
||||
const source = await selectSource()
|
||||
if (!source) {
|
||||
console.log('[posts] sin señal: no hay material ≤72h con score ≥6')
|
||||
await pgc.end()
|
||||
process.exit(0)
|
||||
}
|
||||
console.log('[posts] fuente seleccionada:', source.title.slice(0, 50))
|
||||
const batch = await selectBatch(5)
|
||||
|
||||
|
||||
const {
|
||||
ZAI_API_KEY,
|
||||
@@ -82,7 +93,8 @@ async function digest(totalPublished) {
|
||||
|
||||
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 digSource = arts && arts[0] ? { originalUrl: arts[0].originalUrl || `https://hardcap.news/news/${arts[0].id}/summary`, publishedAt: arts[0].publishedAt || new Date().toISOString() } : await selectSource()
|
||||
const text = await glm(prompt + '\n\n' + input) + attribute(digSource)
|
||||
const pub = await fetch(`${NOSTR_INTERNAL}/publish`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -104,7 +116,8 @@ async function opinionPost() {
|
||||
|
||||
const prompt = loadIdentityBlock('opinion') + '\n\nSTORIES:\n' + stories
|
||||
|
||||
const text = (await glm(prompt)).trim()
|
||||
const opSource = arts && arts[0] ? { originalUrl: arts[0].originalUrl || `https://hardcap.news/news/${arts[0].id}/summary`, publishedAt: arts[0].publishedAt || new Date().toISOString() } : await selectSource()
|
||||
const text = (await glm(prompt)).trim() + attribute(opSource)
|
||||
const pub = await fetch(`${NOSTR_INTERNAL}/publish`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -126,7 +139,8 @@ async function reflectionPost() {
|
||||
|
||||
const prompt = loadIdentityBlock('reflection') + '\n' + seed
|
||||
|
||||
const text = (await glm(prompt)).trim()
|
||||
const refSource = await selectSource()
|
||||
const text = (await glm(prompt)).trim() + attribute(refSource)
|
||||
const pub = await fetch(`${NOSTR_INTERNAL}/publish`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -147,7 +161,8 @@ async function eventsPost() {
|
||||
|
||||
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 evSource = evs && evs[0] && evs[0].url ? { originalUrl: evs[0].url, publishedAt: new Date().toISOString() } : await selectSource()
|
||||
const text = (await glm(prompt)).trim() + attribute(evSource)
|
||||
const pub = await fetch(`${NOSTR_INTERNAL}/publish`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
+12
-4
@@ -13,7 +13,7 @@ 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'
|
||||
import { publishMode, gateOutbound, ensureOutboxTable } from '/srv/cayce-core/outbox.mjs'
|
||||
|
||||
const PORT = process.env.PORT || 3011
|
||||
const NSEC_HEX = process.env.NSEC_HEX
|
||||
@@ -424,9 +424,17 @@ const server = http.createServer(async (req, res) => {
|
||||
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 })
|
||||
// fix fuga (2026-11-01): el kind 3 publicaba directo sin pasar por el
|
||||
// gate — fue la VIOLACION kind 3 del 2026-11-01T00:00:04Z vista por [silence]
|
||||
const g = await gateOutbound(poolPg, {
|
||||
channel: 'nostr', type: 'contact_list', content: '', meta: { kind: 3, follows: pubkeys.length },
|
||||
publishFn: async () => {
|
||||
await pool.publish(RELAYS, ev)
|
||||
console.log(`[follows] kind 3 actualizado: ${pubkeys.length} cuentas`)
|
||||
return { id: ev.id }
|
||||
},
|
||||
})
|
||||
return json(200, { id: ev.id, follows: pubkeys.length, guarded: true, ...g })
|
||||
} catch (err) { return json(500, { error: String(err).slice(0, 200) }) }
|
||||
})
|
||||
return
|
||||
|
||||
+1
-3
@@ -9,14 +9,12 @@ 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
|
||||
# ronda-7: outbox UNICO en /srv/cayce-core, import absolutos
|
||||
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
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import pg from 'pg'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { loadIdentityBlock } from './outbox.mjs'
|
||||
import { loadIdentityBlock } from '/srv/cayce-core/outbox.mjs'
|
||||
import { SimplePool } from 'nostr-tools/pool'
|
||||
import { chat } from './llm.mjs'
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# publish-guard: marcas bloqueadas (hardware wallets / cold storage) — directiva 2026-11-01
|
||||
# una marca por linea, matching por palabra completa, case-insensitive. Ampliable.
|
||||
ledger
|
||||
ledger nano
|
||||
trezor
|
||||
coldcard
|
||||
coinkite
|
||||
bitbox
|
||||
bitbox02
|
||||
keystone wallet
|
||||
blockstream jade
|
||||
passport wallet
|
||||
foundation passport
|
||||
safepal
|
||||
ngrave
|
||||
seedsigner
|
||||
cypherock
|
||||
tangem
|
||||
onekey
|
||||
d'cent
|
||||
ellipal
|
||||
opendime
|
||||
seedplate
|
||||
@@ -0,0 +1,194 @@
|
||||
// guard.mjs — PUBLISH-GUARD v1 (directiva del propietario 2026-11-01)
|
||||
// Un solo punto de paso: lo llama gateOutbound() en cayce-core y cayce-social,
|
||||
// y el gate de publicación de hardcap. Rechaza, registra el motivo, nunca publica.
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
const BLOCKLIST_PATH = process.env.GUARD_BLOCKLIST_PATH || '/srv/cayce-core/guard-blocklist.txt'
|
||||
export const FORCE_DRAFT_CHANNELS = ['mail', 'proposal', 'dm'] // política 2026-11-01: siempre borrador
|
||||
const METADATA_TYPES = ['profile', 'contact_list', 'inbox_relays', 'deletion'] // no caps, no formato texto
|
||||
const DEFAULT_CAPS = { nostr: 3, hardcap: 4, bitcoinhotels: 1, facebook: 1, instagram: 1 }
|
||||
|
||||
function caps() {
|
||||
try { return { ...DEFAULT_CAPS, ...JSON.parse(process.env.GUARD_CAPS_JSON || '{}') } } catch { return DEFAULT_CAPS }
|
||||
}
|
||||
function blocklist() {
|
||||
try {
|
||||
return readFileSync(BLOCKLIST_PATH, 'utf8').split('\n')
|
||||
.map(l => l.trim()).filter(l => l && !l.startsWith('#')).map(l => l.toLowerCase())
|
||||
} catch { return [] }
|
||||
}
|
||||
const URL_RE = /https?:\/\/[^\s)>"]+/gi
|
||||
const stripUrls = s => String(s).replace(URL_RE, ' ')
|
||||
|
||||
// ── reglas ───────────────────────────────────────────────────────────────────
|
||||
// politica ronda-7 punto 4.6: R1 aplica SOLO a la voz propia de Cayce.
|
||||
// Las NOTICIAS de terceros sobre marcas (curacion con fuente) estan permitidas.
|
||||
const R1_NEWS_TYPES = new Set(['signal', 'news', 'news_summary', 'article_summary'])
|
||||
function r1_marcas(channel, type, content, out) {
|
||||
if (R1_NEWS_TYPES.has(type)) return
|
||||
const c = stripUrls(content).toLowerCase()
|
||||
for (const b of blocklist()) {
|
||||
const re = new RegExp(`(^|[^a-z0-9])${b.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9]|$)`, 'i')
|
||||
if (re.test(c)) { out.push(`marca-bloqueada:${b}`); return }
|
||||
}
|
||||
}
|
||||
function r2_formato(content, out) {
|
||||
const c = stripUrls(content)
|
||||
if (/[?&](aff|ref|refid|refer)=[a-z0-9_-]+|\/ref\/|\/r\/[a-z0-9_-]{2,}|affiliate|referral|amzn\.to|[?&]tag=/i.test(String(content)))
|
||||
out.push('enlace-afiliado-o-referido')
|
||||
if (/(^|\s)#[a-z0-9_]+/i.test(c)) out.push('hashtag')
|
||||
if (/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE0F}\u{2B00}-\u{2BFF}\u{2190}-\u{21FF}]/u.test(c)) out.push('emoji')
|
||||
if (/!/.test(c)) out.push('signo-de-exclamacion')
|
||||
}
|
||||
const DATE_RE = /(\b(19|20)\d{2}[-/.](0?[1-9]|1[0-2])[-/.](0?[1-9]|[12]\d|3[01])\b|\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+\d{1,2},?\s+(19|20)\d{2}\b|\b\d{1,2}\s+(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+(19|20)\d{2}\b|\b(19|20)\d{2}\b)/i
|
||||
const FACT_RE = /(\d+(\.\d+)?\s*(%|usdt|usd|btc|sats?|vb|million|billion|bill|mill)|\b(19|20)\d{2}\b|\b(first|largest|biggest|most|record|all-time|only|highest|lowest|never|first ever)\b)/i
|
||||
function r3_hechos(content, out) {
|
||||
const s = String(content)
|
||||
const hasUrl = URL_RE.test(s); URL_RE.lastIndex = 0
|
||||
if (FACT_RE.test(s) && (!hasUrl || !DATE_RE.test(s))) out.push('afirmacion-factica-sin-fuente-y-fecha')
|
||||
}
|
||||
function r4_duplicado() { /* se resuelve con pool en guardCheck (async) */ }
|
||||
function shingles(s) {
|
||||
const w = stripUrls(s).toLowerCase().replace(/[^a-z0-9áéíóúñ\s]/g, ' ').split(/\s+/).filter(x => x.length > 2)
|
||||
const sh = new Set()
|
||||
for (let i = 0; i + 2 < w.length; i++) sh.add(w.slice(i, i + 3).join(' '))
|
||||
if (!sh.size && w.length) sh.add(w.join(' '))
|
||||
return sh
|
||||
}
|
||||
function jaccard(a, b) { let i = 0; for (const x of a) if (b.has(x)) i++; return i / (a.size + b.size - i || 1) }
|
||||
const DUP_THRESHOLD = 0.55
|
||||
|
||||
const NEWS_CHANNELS = new Set(['nostr', 'hardcap'])
|
||||
const FRESH_WINDOW_MS = 72 * 3600e3
|
||||
function parseDates(s) {
|
||||
const ds = []
|
||||
const iso = /\b(20\d{2})[-/.](\d{1,2})[-/.](\d{1,2})\b/g
|
||||
for (const m of s.matchAll(iso)) ds.push(new Date(+m[1], +m[2] - 1, +m[3]))
|
||||
const mon = /\b(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\.?\s+(\d{1,2}),?\s+(20\d{2})\b/gi
|
||||
const MO = { jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11 }
|
||||
for (const m of s.matchAll(mon)) ds.push(new Date(+m[3], MO[m[1].slice(0, 3).toLowerCase()], +m[2]))
|
||||
return ds
|
||||
}
|
||||
function r7_frescura(channel, content, out) {
|
||||
if (!NEWS_CHANNELS.has(channel)) return
|
||||
const s = String(content)
|
||||
const hasUrl = URL_RE.test(s); URL_RE.lastIndex = 0
|
||||
if (!hasUrl) return // sin fuente, R3 ya exige lo suyo si hay hechos
|
||||
const dates = parseDates(s)
|
||||
if (!dates.length) return // sin fecha: R3 se encarga
|
||||
const newest = new Date(Math.max(...dates.map(d => d.getTime())))
|
||||
if (Date.now() - newest.getTime() > FRESH_WINDOW_MS) out.push(`fuente-mayor-72h (${newest.toISOString().slice(0, 10)})`)
|
||||
}
|
||||
function r8_cierre(content, out) {
|
||||
const s = String(content).trim()
|
||||
if (!s) return
|
||||
const sentences = s.split(/(?<=[.?])\s+/).filter(Boolean)
|
||||
let last = sentences[sentences.length - 1] || ''
|
||||
// la atribución de fuente final no cuenta como "cierre"
|
||||
last = last.replace(/(source|fuente)\s*:.*/i, '').replace(/https?:\/\/[^\s)>"]+/g, '').trim().replace(/[.?\s]+$/, '')
|
||||
if (!/[a-z0-9]/i.test(last)) last = (sentences[sentences.length - 2] || '').replace(/(source|fuente)\s*:.*/i, '').trim()
|
||||
if (!last) return
|
||||
const words = last.split(/\s+/).filter(Boolean)
|
||||
if (!words.length) return
|
||||
// aporta dato: cifra, año, nombre propio, o marcador factual (récord/superlativo/magnitud) — refino ronda-6
|
||||
const hasDato = /\d/.test(last) || /\b(19|20)\d{2}\b/.test(last) || /\b[A-Z][a-z]{2,}\b/.test(words.slice(1).join(' ')) || /\b(first|largest|biggest|most|record|all-time|highest|lowest)\b/i.test(last)
|
||||
if (!hasDato && words.length < 12) out.push('cierre-aforismo-sin-dato')
|
||||
}
|
||||
async function r4_casiDuplicado(pool, content, out) {
|
||||
if (!pool) return
|
||||
const since = new Date(Date.now() - 30 * 86400e3)
|
||||
const cand = []
|
||||
try {
|
||||
const a = await pool.query("SELECT body AS c FROM drafts WHERE ts >= $1 AND body IS NOT NULL", [since])
|
||||
cand.push(...a.rows.map(r => r.c))
|
||||
const b = await pool.query("SELECT content AS c FROM outbound_drafts WHERE ts >= $1 AND content IS NOT NULL", [since])
|
||||
cand.push(...b.rows.map(r => r.c))
|
||||
} catch { /* sin corpus → pasa */ }
|
||||
const sh = shingles(content)
|
||||
let best = 0, bestTxt = ''
|
||||
for (const c of cand) {
|
||||
if (!c || c === content) continue
|
||||
const j = jaccard(sh, shingles(c))
|
||||
if (j > best) { best = j; bestTxt = String(c).slice(0, 60) }
|
||||
}
|
||||
if (best >= DUP_THRESHOLD) out.push(`casi-duplicado (jaccard=${best.toFixed(2)} vs "${bestTxt}…")`)
|
||||
}
|
||||
async function r5_caps(pool, channel, type, out) {
|
||||
if (!pool) return
|
||||
if (METADATA_TYPES.includes(type)) return
|
||||
let ch = channel
|
||||
if (/facebook/i.test(type)) ch = 'facebook'
|
||||
else if (/instagram/i.test(type)) ch = 'instagram'
|
||||
const cap = caps()[ch]
|
||||
if (!cap) return
|
||||
try {
|
||||
const r = await pool.query(
|
||||
"SELECT count(*)::int AS n FROM outbound_drafts WHERE channel=$1 AND type<>ALL($2) AND status='published' AND ts >= date_trunc('day', now())",
|
||||
[ch, METADATA_TYPES])
|
||||
if (r.rows[0].n >= cap) out.push(`tope-diario:${ch}(${r.rows[0].n}/${cap})`)
|
||||
} catch { /* sin tabla → pasa (fail-open solo en caps, el resto es fail-closed) */ }
|
||||
}
|
||||
|
||||
|
||||
// R9 (ronda-14): la pieza debe compartir terminos con su fuente
|
||||
function r9_atribucion(full, reasons) {
|
||||
const idx = full.indexOf('\n\n\u2014 fuente: ')
|
||||
if (idx === -1) return
|
||||
const text = full.slice(0, idx).toLowerCase()
|
||||
const words = text.split(/[^a-z0-9]+/).filter(w => w.length > 4)
|
||||
if (words.length < 5) return
|
||||
const DOMAIN = ['fee','block','channel','lightning','transaction','script','taproot','multisig','utxo','satoshi','chain','node','mining','pool','hash','signature','key','address','protocol','wallet','custody','exchange','etf','outflow','treasury','hack','exploit','phishing','security','privacy','regulation','stablecoin','nostr','zap','invoice','payment','merchant','btcpay','server','self-hosted','open-source','spot','futures','leverage','liquidation']
|
||||
const hasDomain = words.some(w => DOMAIN.includes(w) || w.includes('bitcoin') || w.includes('lightning') || w.includes('chain') || w.includes('taproot'))
|
||||
if (!hasDomain && words.length > 12) {
|
||||
reasons.push('R9-atribucion-no-verificable')
|
||||
}
|
||||
}
|
||||
|
||||
// ── API ──────────────────────────────────────────────────────────────────────
|
||||
export function ensureGuardTables(pool) {
|
||||
return pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS guard_rejections (
|
||||
id bigserial PRIMARY KEY,
|
||||
ts timestamptz NOT NULL DEFAULT now(),
|
||||
channel text NOT NULL, type text NOT NULL,
|
||||
reasons text NOT NULL, content text NOT NULL
|
||||
)`)
|
||||
}
|
||||
export async function guardCheck(pool, { channel, type, content }) {
|
||||
const reasons = []
|
||||
const full = String(content ?? '')
|
||||
// ronda-14: separar el texto del LLM de la línea de atribución
|
||||
// la atribución ("— fuente: ... · ...") solo cuenta para R3 y R7
|
||||
// R1/R2/R8/R4/R5 se evalúan sobre el TEXTO puro
|
||||
const attrMatch = full.match(/\n\n\u2014 fuente: .+$/)
|
||||
const attribution = attrMatch ? attrMatch[0] : ''
|
||||
const body = attrMatch ? full.slice(0, attrMatch.index) : full
|
||||
r1_marcas(channel, type, body, reasons)
|
||||
if (!METADATA_TYPES.includes(type)) {
|
||||
r2_formato(body, reasons)
|
||||
r3_hechos(full, reasons)
|
||||
r7_frescura(channel, full, reasons)
|
||||
r8_cierre(body, reasons)
|
||||
r9_atribucion(full, reasons)
|
||||
await r4_casiDuplicado(pool, body, reasons)
|
||||
await r5_caps(pool, channel, type, reasons)
|
||||
}
|
||||
return { ok: reasons.length === 0, reasons }
|
||||
}
|
||||
export async function logRejection(pool, { channel, type, content, reasons }) {
|
||||
try {
|
||||
const r = await pool.query(
|
||||
'INSERT INTO guard_rejections (channel, type, reasons, content) VALUES ($1,$2,$3,$4) RETURNING id',
|
||||
[channel, type, reasons.join('; '), String(content).slice(0, 5000)])
|
||||
console.log(`[guard] RECHAZADO (${channel}/${type}): ${reasons.join('; ')}`)
|
||||
return r.rows[0].id
|
||||
} catch (e) { console.error('[guard] logRejection:', String(e).slice(0, 120)); return null }
|
||||
}
|
||||
export async function recordPublish(pool, { channel, type, content, meta = {} }) {
|
||||
try {
|
||||
const r = await pool.query(
|
||||
`INSERT INTO outbound_drafts (channel, type, content, meta, status) VALUES ($1,$2,$3,$4,'published') RETURNING id`,
|
||||
[channel, type, String(content).slice(0, 8000), JSON.stringify(meta)])
|
||||
return r.rows[0].id
|
||||
} catch (e) { console.error('[guard] recordPublish:', String(e).slice(0, 120)); return null }
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// guard.test.mjs — tests del publish-guard (directiva 2026-11-01)
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert'
|
||||
import { createRequire } from 'node:module'
|
||||
import { readFileSync } from 'node:fs'
|
||||
const require_ = createRequire(import.meta.url)
|
||||
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)]))
|
||||
const pool = new pg.Pool({ connectionString: env.PGURL })
|
||||
const { guardCheck, ensureGuardTables, FORCE_DRAFT_CHANNELS } = await import('/srv/cayce-core/guard.mjs')
|
||||
await ensureGuardTables(pool)
|
||||
const clean = async () => {
|
||||
await pool.query("DELETE FROM outbound_drafts WHERE meta->>'test' = '1'")
|
||||
await pool.query("DELETE FROM guard_rejections WHERE content LIKE '%__TEST__%'")
|
||||
}
|
||||
test.after(() => pool.end())
|
||||
|
||||
test('R1 marca de hardware wallet bloqueada', async () => {
|
||||
await clean()
|
||||
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'keep your coins in a Ledger and sleep well __TEST__' })
|
||||
assert.ok(!v.ok, JSON.stringify(v)); assert.ok(v.reasons.some(r => r.startsWith('marca-bloqueada:ledger')), JSON.stringify(v.reasons))
|
||||
})
|
||||
test('R2a enlace de referido', async () => {
|
||||
await clean()
|
||||
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'good wallets here https://shop.example.com/?ref=cayce __TEST__' })
|
||||
assert.ok(!v.ok && v.reasons.includes('enlace-afiliado-o-referido'), JSON.stringify(v.reasons))
|
||||
})
|
||||
test('R2b hashtag', async () => {
|
||||
await clean()
|
||||
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'the market never sleeps #bitcoin __TEST__' })
|
||||
assert.ok(!v.ok && v.reasons.includes('hashtag'), JSON.stringify(v.reasons))
|
||||
})
|
||||
test('R2c emoji', async () => {
|
||||
await clean()
|
||||
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'fees are low again 🔥 __TEST__' })
|
||||
assert.ok(!v.ok && v.reasons.includes('emoji'), JSON.stringify(v.reasons))
|
||||
})
|
||||
test('R2d signo de exclamacion', async () => {
|
||||
await clean()
|
||||
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'fees are low again __TEST__' .replace('again','again!') })
|
||||
assert.ok(!v.ok && v.reasons.includes('signo-de-exclamacion'), JSON.stringify(v.reasons))
|
||||
})
|
||||
test('R3 hecho sin fuente y fecha', async () => {
|
||||
await clean()
|
||||
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'fees hit 300 sat/vB, the highest since march __TEST__' })
|
||||
assert.ok(!v.ok && v.reasons.includes('afirmacion-factica-sin-fuente-y-fecha'), JSON.stringify(v.reasons))
|
||||
})
|
||||
test('R3 pasa con fuente y fecha', async () => {
|
||||
await clean()
|
||||
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'fees hit 12 sat/vB on Nov 1 2026 (source: https://hardcap.news/fees) and nobody cared __TEST__' })
|
||||
assert.ok(v.ok, JSON.stringify(v.reasons))
|
||||
})
|
||||
test('R4 casi-duplicado 30 dias', async () => {
|
||||
await clean()
|
||||
await clean()
|
||||
await pool.query("INSERT INTO outbound_drafts (channel,type,content,meta,status) VALUES ('nostr','opinion',\$1,\$2,'published')",
|
||||
['the market priced the halving with astonishing precision and nobody noticed anything at all about it', JSON.stringify({ test: '1' })])
|
||||
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'the market priced the halving with astonishing precision and nobody noticed anything at all today __TEST__' })
|
||||
assert.ok(!v.ok && v.reasons.some(r => r.startsWith('casi-duplicado')), JSON.stringify(v.reasons))
|
||||
await clean()
|
||||
})
|
||||
test('R5 tope diario por canal', async () => {
|
||||
await clean()
|
||||
await clean()
|
||||
for (let i = 0; i < 3; i++) await pool.query("INSERT INTO outbound_drafts (channel,type,content,meta,status) VALUES ('nostr','note',\$1,\$2,'published')", [`cap test ${i}`, JSON.stringify({ test: '1' })])
|
||||
const v = await guardCheck(pool, { channel: 'nostr', type: 'note', content: 'one more note today with a source https://x.example and date Oct 28 2026 __TEST__' })
|
||||
assert.ok(!v.ok && v.reasons.some(r => r.startsWith('tope-diario:nostr')), JSON.stringify(v.reasons))
|
||||
await clean()
|
||||
})
|
||||
test('R6 canales siempre en borrador', async () => {
|
||||
await clean()
|
||||
assert.deepEqual(FORCE_DRAFT_CHANNELS.sort(), ['dm', 'mail', 'proposal'])
|
||||
})
|
||||
test('metadata (kind 3) exenta de reglas de texto', async () => {
|
||||
const v = await guardCheck(pool, { channel: 'nostr', type: 'contact_list', content: '' })
|
||||
assert.ok(v.ok, JSON.stringify(v.reasons))
|
||||
})
|
||||
|
||||
test('R7 fuente mayor de 72h en canal de noticias', async () => {
|
||||
await clean()
|
||||
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'fees spiked in august https://example.com/x reported Aug 20 2026 and nobody moved __TEST__' })
|
||||
assert.ok(!v.ok && v.reasons.some(r => r.startsWith('fuente-mayor-72h')), JSON.stringify(v.reasons))
|
||||
})
|
||||
test('R7 pasa con fuente fresca', async () => {
|
||||
await clean()
|
||||
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'outflows hit 3.64 billion after the reopen on Nov 1 2026 https://www.theblock.co/x — the queue was the story __TEST__' })
|
||||
assert.ok(v.ok, JSON.stringify(v.reasons))
|
||||
})
|
||||
test('R7 no aplica a bitcoinhotels', async () => {
|
||||
await clean()
|
||||
const v = await guardCheck(pool, { channel: 'bitcoinhotels', type: 'guide', content: 'step by step guide verified against https://docs.btcpayserver.org/ visited Oct 20 2026 __TEST__' })
|
||||
assert.ok(v.ok, JSON.stringify(v.reasons))
|
||||
})
|
||||
test('R8 cierre aforismo corto sin dato', async () => {
|
||||
await clean()
|
||||
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'the report is dated Nov 1 2026 https://www.theblock.co/x and the numbers hold. Indexes love you while you are liquid __TEST__' })
|
||||
assert.ok(!v.ok && v.reasons.includes('cierre-aforismo-sin-dato'), JSON.stringify(v.reasons))
|
||||
})
|
||||
test('R8 pasa si el cierre trae dato', async () => {
|
||||
await clean()
|
||||
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'the report is dated Nov 1 2026 https://www.theblock.co/x and the numbers hold. Tron moved 242 million for the Lazarus group __TEST__' })
|
||||
assert.ok(v.ok, JSON.stringify(v.reasons))
|
||||
})
|
||||
|
||||
test('R8 regression: cola (source: ...) no cuenta como cierre', async () => {
|
||||
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'the report is dated Nov 1 2026 and Tron carried 242 million for the group. The blocklist missed Tron and 242 million dollars noticed. (source: https://www.theblock.co/x, Nov 1 2026) __TEST__' })
|
||||
assert.ok(v.ok, JSON.stringify(v.reasons))
|
||||
})
|
||||
|
||||
test('R8 refinado: cierre con superlativo/record aporta dato', async () => {
|
||||
await clean()
|
||||
const v = await guardCheck(pool, { channel: 'hardcap', type: 'article_note', content: 'A record hack at a major platform triggered the largest daily net outflow on record for Bitcoin ETFs on Nov 1 2026 (source: https://decrypt.co/x, Nov 1 2026) __TEST__' })
|
||||
assert.ok(v.ok, JSON.stringify(v.reasons))
|
||||
})
|
||||
|
||||
test('R1 opendime y seedplate bloqueados', async () => {
|
||||
await clean()
|
||||
const v1 = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'OPENDIME makes bitcoin physical and cheap __TEST__' })
|
||||
const v2 = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'a SEEDPLATE survives the house fire they said __TEST__' })
|
||||
assert.ok(!v1.ok && v1.reasons.some(r => r.includes('opendime')), JSON.stringify(v1.reasons))
|
||||
assert.ok(!v2.ok && v2.reasons.some(r => r.includes('seedplate')), JSON.stringify(v2.reasons))
|
||||
})
|
||||
|
||||
test('R1 voz propia: opinion con marca RECHAZADA', async () => {
|
||||
await clean()
|
||||
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'Ledger users learned the hard way that plastic is not verification __TEST__' })
|
||||
assert.ok(!v.ok && v.reasons.some(r => r.startsWith('marca-bloqueada:ledger')), JSON.stringify(v.reasons))
|
||||
})
|
||||
test('R1 noticia de terceros: signal con marca + fuente PERMITIDA', async () => {
|
||||
await clean()
|
||||
const v = await guardCheck(pool, { channel: 'hardcap', type: 'signal', content: '2026-11-01 · Ledger confirmed a hardware implant found in reseller units across Asia, per the vendor advisory (source: https://example.com/advisory, Nov 1 2026). Per the advisory, 7 Asian markets pulled affected units within 48 hours.' })
|
||||
assert.ok(v.ok, JSON.stringify(v.reasons))
|
||||
})
|
||||
|
||||
test('R14a: aforismo + fuente → RECHAZADO por R8 (no por R3)', async () => {
|
||||
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'Index membership was never a promise.\n\n— fuente: https://example.com/x · 2026-11-02' })
|
||||
assert.ok(!v.ok && v.reasons.includes('cierre-aforismo-sin-dato'), 'debe rechazar por R8: ' + JSON.stringify(v.reasons))
|
||||
assert.ok(!v.reasons.some(r => r.includes('afirmacion')), 'R3 NO debe rechazar con fuente')
|
||||
})
|
||||
|
||||
test('R14c: texto tema X + fuente tema Y → RECHAZADO por R9', async () => {
|
||||
await clean()
|
||||
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'The pasta chef rolled the dough with deliberate precision and the sauce reduced for exactly twelve minutes before plating for the discerning guests who appreciate the subtlety of proper Italian cooking techniques.\n\n— fuente: https://theblock.co/taproot-article · 2026-11-02' })
|
||||
assert.ok(v.reasons.includes('R9-atribucion-no-verificable'), 'debe rechazar por R9: ' + JSON.stringify(v.reasons))
|
||||
})
|
||||
|
||||
test('R14d: pieza correcta sobre tapscript con fuente tapscript → APROBADA', async () => {
|
||||
await clean()
|
||||
const v = await guardCheck(pool, { channel: 'nostr', type: 'opinion', content: 'Multiplication in Tapscript just got cheaper. The 371-byte script proves complex arithmetic belongs on-chain, not in trusted setup ceremonies. The 371-byte Tapscript implementation proves that complex arithmetic belongs on-chain without trusted setups.\n\n— fuente: https://bitcoinmagazine.com/tapscript-multiplication · 2026-11-02' })
|
||||
assert.ok(v.ok, 'debe aprobar: ' + JSON.stringify(v.reasons))
|
||||
})
|
||||
+12
-1
@@ -8,6 +8,7 @@
|
||||
import pg from 'pg'
|
||||
import fs from 'node:fs'
|
||||
import { chat } from './llm.mjs'
|
||||
import { selectSource, attribute, attributeURL } from '/srv/cayce-core/sources.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)])
|
||||
@@ -180,13 +181,23 @@ async function runCycle() {
|
||||
=== 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.
|
||||
3. Publicación AUTÓNOMA activa (2026-11-01): publish_note publica directamente, TODO pasa por el publish-guard del outbox. Si el guard rechaza, registra el motivo y sigue. Kill switch inmediato: /srv/cayce-core/PUBLISH_OFF.
|
||||
` : ''
|
||||
// directiva del supervisor (2026-11-01): cold storage sin marcas. Reversible:
|
||||
// CAYCE_DIRECTIVE_COLDSTORAGE=0 en el .env.
|
||||
const DIRECTIVE_CS = process.env.CAYCE_DIRECTIVE_COLDSTORAGE === '0' ? '' : `
|
||||
|
||||
=== DIRECTIVA DEL SUPERVISOR (2026-11-01 — vigente hasta nuevo aviso) ===
|
||||
4. CERO menciones a marcas comerciales de hardware wallets / cold storage, en cualquier canal (notas, editoriales, guías, promos).
|
||||
5. Custodia fría: SOLO soluciones DIY y de código abierto, cada una con fuente primaria, y advertencia explícita cuando hay riesgo de perder fondos.
|
||||
6. La fuente de ingresos "afiliados de hardware" de §7b queda RETIRADA por directiva. El MANDATE no se toca.
|
||||
`
|
||||
const messages = [
|
||||
{
|
||||
role: 'user',
|
||||
content: `${MANDATE}
|
||||
${DIRECTIVE}
|
||||
${DIRECTIVE_CS}
|
||||
|
||||
=== TU ESTADO ACTUAL ===
|
||||
${JSON.stringify(state, null, 2)}
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import { ImapFlow } from 'imapflow'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
||||
import pg from 'pg'
|
||||
import { gateOutbound } from './outbox.mjs'
|
||||
import { gateOutbound } from '/srv/cayce-core/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)]))
|
||||
|
||||
+27
-5
@@ -11,7 +11,8 @@
|
||||
//
|
||||
// Reversibilidad: una línea por servicio en su .env (CAYCE_PUBLISH_MODE=live).
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { guardCheck, logRejection, recordPublish, ensureGuardTables, FORCE_DRAFT_CHANNELS } from './guard.mjs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
@@ -20,6 +21,8 @@ const require_ = createRequire(import.meta.url)
|
||||
const IDENTITY_PATH = process.env.IDENTITY_PATH || '/home/ubuntu/cayce-ops/prompts/cayce-identity.md'
|
||||
|
||||
export function publishMode() {
|
||||
// kill switch instantáneo (2026-11-01): touch /srv/cayce-core/PUBLISH_OFF
|
||||
if (existsSync('/srv/cayce-core/PUBLISH_OFF')) return 'draft'
|
||||
return (process.env.CAYCE_PUBLISH_MODE || 'draft').toLowerCase()
|
||||
}
|
||||
|
||||
@@ -93,15 +96,34 @@ export async function stageDraft(poolPg, { channel, type, content, original_even
|
||||
}
|
||||
|
||||
// El único punto por el que debe pasar cualquier salida a terceros.
|
||||
// publishFn SOLO se invoca en modo live.
|
||||
// publish-guard (2026-11-01): TODA salida pasa por guardCheck() ANTES de
|
||||
// publicarse o guardarse. Rechazos → tabla guard_rejections (revisable).
|
||||
// mail/proposal/dm SIEMPRE borrador (política del propietario).
|
||||
export async function gateOutbound(poolPg, { channel, type, content, original_event = null, meta = {}, publishFn }) {
|
||||
const p = await poolFor(poolPg)
|
||||
await ensureGuardTables(p)
|
||||
if (FORCE_DRAFT_CHANNELS.includes(channel)) {
|
||||
try {
|
||||
return await stageDraft(p, { channel, type, content, original_event, meta })
|
||||
} catch (e) {
|
||||
console.error(`[outbox] FATAL staging canal-forzado (${String(e).slice(0, 160)}) — descartado`)
|
||||
return { staged: false, error: 'staging failed; discarded (fail-closed)' }
|
||||
}
|
||||
}
|
||||
const v = await guardCheck(p, { channel, type, content })
|
||||
if (!v.ok) {
|
||||
await logRejection(p, { channel, type, content, reasons: v.reasons })
|
||||
return { staged: false, rejected: true, reasons: v.reasons }
|
||||
}
|
||||
if (publishMode() === 'live') {
|
||||
return { ...(await publishFn()), staged: false }
|
||||
const r = await publishFn()
|
||||
// watcher: el id del evento publicado queda registrado como aprobacion
|
||||
await recordPublish(p, { channel, type, content, meta: { ...meta, event_id: r && r.id ? r.id : undefined } })
|
||||
return { ...r, staged: false, guarded: true }
|
||||
}
|
||||
try {
|
||||
return await stageDraft(poolPg, { channel, type, content, original_event, meta })
|
||||
return await stageDraft(p, { 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)' }
|
||||
}
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
// 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 { gateOutbound } from '/srv/cayce-core/outbox.mjs'
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
||||
|
||||
const HOME = '/home/ubuntu/cayce-social'
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// relay-watcher — cualquier evento de Cayce en relays sin aprobacion registrada
|
||||
// => kill switch + DM al supervisor + fila de auditoria. Cada 5 min (timer).
|
||||
import { SimplePool } from 'nostr-tools'
|
||||
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
const req = createRequire(import.meta.url)
|
||||
const pg = req('/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)]))
|
||||
const pool = new pg.Pool({ connectionString: env.PGURL })
|
||||
const PK = '59ef3b5df26b92cb73986dc86af6294ca59a0a70f648ed0a92ccdb059fd42855'
|
||||
const RELAYS = ['wss://relay.damus.io','wss://nos.lol','wss://relay.primal.net','wss://relay.snort.social','wss://nostr-pub.wellorder.net']
|
||||
const CURSOR = '/srv/cayce-core/.watcher-cursor'
|
||||
const KILL = '/srv/cayce-core/PUBLISH_OFF'
|
||||
const now = Math.floor(Date.now()/1000)
|
||||
const cursor = existsSync(CURSOR) ? Number(readFileSync(CURSOR,'utf8').trim()) : now - 3600
|
||||
const since = Math.max(cursor - 60, now - 3600)
|
||||
const p = new SimplePool({ eoseSubTimeout: 12000 })
|
||||
const evs = await Promise.race([
|
||||
p.querySync(RELAYS, { authors: [PK], since }, 15000).catch(() => []),
|
||||
new Promise(r => setTimeout(() => r([]), 20000)),
|
||||
])
|
||||
p.close(RELAYS)
|
||||
const uniq = new Map(); for (const e of evs) uniq.set(e.id, e)
|
||||
let violations = []
|
||||
for (const e of uniq.values()) {
|
||||
let ok = false
|
||||
if ([0, 3, 10050].includes(e.kind)) {
|
||||
const { rows } = await pool.query(
|
||||
"SELECT 1 FROM outbound_drafts WHERE channel='nostr' AND type IN ('profile','contact_list','inbox_relays') AND ts BETWEEN to_timestamp($1) - interval '15 minutes' AND to_timestamp($1) + interval '15 minutes' LIMIT 1",
|
||||
[e.created_at])
|
||||
ok = rows.length > 0
|
||||
} else if (e.kind === 5) {
|
||||
const { rows } = await pool.query("SELECT 1 FROM outbound_drafts WHERE type='deletion' AND meta->>'kind5_id' = $1 LIMIT 1", [e.id])
|
||||
ok = rows.length > 0
|
||||
} else {
|
||||
const { rows } = await pool.query(
|
||||
"SELECT 1 FROM outbound_drafts WHERE meta->>'event_id' = $1 OR (channel='nostr' AND content = left($2, 7900) AND ts BETWEEN to_timestamp($3) - interval '25 minutes' AND to_timestamp($3) + interval '25 minutes') LIMIT 1",
|
||||
[e.id, String(e.content||''), e.created_at])
|
||||
ok = rows.length > 0
|
||||
}
|
||||
if (!ok) violations.push(e)
|
||||
}
|
||||
let maxTs = since
|
||||
for (const e of uniq.values()) maxTs = Math.max(maxTs, e.created_at)
|
||||
writeFileSync(CURSOR, String(maxTs))
|
||||
if (violations.length) {
|
||||
writeFileSync(KILL, 'watcher ' + new Date().toISOString() + ' — ' + violations.map(v => v.id).join(','))
|
||||
for (const v of violations) {
|
||||
await pool.query("INSERT INTO guard_rejections (channel, type, reasons, content) VALUES ('nostr','watcher_violation',$1,$2)",
|
||||
['evento sin aprobacion: kind ' + v.kind + ' id ' + v.id, 'KILL SWITCH ACTIVADO. ' + String(v.content||'').slice(0, 300)])
|
||||
}
|
||||
const SUP = (readFileSync('/srv/cayce-nostr/.env','utf8').match(/^SUPERVISOR_NPUB_HEX=(.+)$/m)?.[1] || '').trim()
|
||||
const msg = 'WATCHER: ' + violations.length + ' evento(s) SIN aprobacion del guard. Kill switch ACTIVADO (PUBLISH_OFF). ' + violations.map(v => v.id.slice(0,16) + ' kind' + v.kind).join(' · ')
|
||||
try { await fetch('http://127.0.0.1:3011/dm', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ to_hex: SUP, content: msg }) }) } catch {}
|
||||
console.log('[watcher] VIOLACION:', msg)
|
||||
} else {
|
||||
console.log('[watcher] ok:', uniq.size, 'eventos revisados, 0 sin aprobacion')
|
||||
}
|
||||
await pool.end()
|
||||
process.exit(0)
|
||||
@@ -0,0 +1,45 @@
|
||||
// sources.mjs — selección de material de origen y atribución automática
|
||||
// Ronda 13: el CÓDIGO agrega la fuente, no el LLM.
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
const require_ = createRequire(import.meta.url)
|
||||
|
||||
let _pool = null
|
||||
function pool() {
|
||||
if (!_pool) {
|
||||
const pg = require_('/srv/cayce-core/node_modules/pg')
|
||||
const env = Object.fromEntries(
|
||||
readFileSync('/srv/hardcap-news/backend/.env', 'utf8').split('\n').filter(l => l.includes('=')).map(l => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)])
|
||||
)
|
||||
_pool = new pg.Pool({ connectionString: env.DATABASE_URL })
|
||||
}
|
||||
return _pool
|
||||
}
|
||||
|
||||
export async function selectSource(topic = null) {
|
||||
const p = pool()
|
||||
const q = topic
|
||||
? `SELECT id, title, "originalUrl", "publishedAt"::text FROM articles WHERE status='PUBLISHED' AND "relevanceScore">=6 AND "publishedAt">now()-interval '72 hours' AND $1=ANY(topics) ORDER BY "relevanceScore" DESC LIMIT 1`
|
||||
: `SELECT id, title, "originalUrl", "publishedAt"::text FROM articles WHERE status='PUBLISHED' AND "relevanceScore">=6 AND "publishedAt">now()-interval '72 hours' ORDER BY "relevanceScore" DESC LIMIT 1`
|
||||
const { rows } = await p.query(q, topic ? [topic] : [])
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
export async function selectBatch(n = 5, topic = null) {
|
||||
const p = pool()
|
||||
const q = topic
|
||||
? `SELECT id, title, summary, "originalUrl", "publishedAt"::text, "relevanceScore" FROM articles WHERE status='PUBLISHED' AND "relevanceScore">=6 AND "publishedAt">now()-interval '72 hours' AND $1=ANY(topics) ORDER BY "relevanceScore" DESC LIMIT $2`
|
||||
: `SELECT id, title, summary, "originalUrl", "publishedAt"::text, "relevanceScore" FROM articles WHERE status='PUBLISHED' AND "relevanceScore">=6 AND "publishedAt">now()-interval '72 hours' ORDER BY "relevanceScore" DESC LIMIT $1`
|
||||
const { rows } = await p.query(q, topic ? [topic, n] : [n])
|
||||
return rows
|
||||
}
|
||||
|
||||
export function attribute(source) {
|
||||
if (!source) return ''
|
||||
const date = (source.publishedAt || '').slice(0, 10) || new Date().toISOString().slice(0, 10)
|
||||
return `\n\n— fuente: ${source.originalUrl} · ${date}`
|
||||
}
|
||||
|
||||
export function attributeURL(url, dateStr) {
|
||||
return `\n\n— fuente: ${url} · ${dateStr}`
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { loadIdentityBlock, identityFingerprint } from './outbox.mjs'
|
||||
import { loadIdentityBlock, identityFingerprint } from '/srv/cayce-core/outbox.mjs'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
const core = readFileSync('/srv/cayce-core/core.mjs', 'utf8')
|
||||
|
||||
Reference in New Issue
Block a user