Compare commits

..
3 Commits
Author SHA1 Message Date
cayce 8b095b8c1c ci: retrigger docker mode
ci / lint-test-leaks (pull_request) Failing after 0s
2026-11-02 02:34:12 +00:00
cayce 8b63f3f5fa ci: retrigger post-docker
ci / lint-test-leaks (pull_request) Failing after 35s
2026-11-02 01:09:31 +00:00
cayce f0d2ff905a ci: prueba trivial — debe pasar checks y auto-mergear
ci / lint-test-leaks (pull_request) Failing after 4s
2026-11-02 01:04:52 +00:00
10 changed files with 56 additions and 286 deletions
+44 -30
View File
@@ -6,44 +6,58 @@ on:
pull_request:
jobs:
lint-test:
lint-test-leaks:
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: 1
fetch-depth: 0 # gitleaks needs full history
- name: Setup
run: |
python3 -m pip install --user ruff pytest --quiet 2>/dev/null || true
- uses: astral-sh/setup-uv@v5
with:
python-version: "3.12"
- name: Lint (ruff, advisory)
run: |
if [ -d cayceops ]; then
ruff check cayceops/ --exit-zero || true
else
echo "no python package — OK"
fi
- name: Install (locked)
run: uv sync --frozen
- name: Tests
run: |
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: Lint (ruff)
run: uv run ruff check .
- name: Guard tests
- name: Migrations upgrade/downgrade round-trip (Postgres 16)
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
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
- name: Syntax check (runtime)
- 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)
run: |
for f in runtime/*.mjs; do
[ -f "$f" ] && node --check "$f" || true
done
echo "syntax check OK"
sudo apt-get update && sudo apt-get install -y postgresql-client
uv run pytest -m integration -v
- name: Secret scan (gitleaks, full history)
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
-11
View File
@@ -1,11 +0,0 @@
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
-22
View File
@@ -1,22 +0,0 @@
# 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.
-35
View File
@@ -1,35 +0,0 @@
# Vía de publicación de bitcoinhotels (diseño mínimo)
Objetivo: las guías del sitio entran como PRs, y si pasan publish-guard + build,
se mergean y despliegan solas (directiva 2026-11-01, punto 4). El código del
sitio NO entra aquí: sigue siendo PR con merge humano.
## Piezas
1. **Repo `cayce/bitcoinhotels-content`** (privado): SOLO `content/guides/*.md`
+ `manifest.json` (orden, títulos). Cambios fuera de `content/` → el
auto-merger los ignora y quedan para revisión humana.
2. **Auto-merger** (`cayce-ops/cayceops/plugins/bitcoinhotels_prague.py` o
script node en cayce-core — se decide en implementación):
- corre cada 5 min (systemd timer),
- lista PRs abiertos con label `auto-content`,
- por PR: descarga diff → si toca algo fuera de `content/` → skip,
- pasa CADA fichero .md nuevo/modificado por el mismo `guard.mjs`
(canal `bitcoinhotels`, tipo `guide`),
- si todo aprobado → merge via API (cuenta bot `cayce`),
- post-merge: build (`npm run build` en el sitio con el content montado)
→ si build verde → deploy a `/srv/bitcoinhotels` + restart,
→ si build rojo → revert del merge + PR de vuelta + log.
3. **Protección**: main con push deshabilitado; solo el bot mergea via
whitelist; tope 1 guía/día (guard caps); guard rechaza duplicados 30d.
4. **Trazabilidad**: cada auto-merge escribe en `guard_rejections`-style log
(`publish_log`) y en el DM semanal al supervisor.
## Lo que NO hace la V1
- No publica sin PR (no hay escritura directa al sitio).
- No toca código/diseño del sitio (eso es PR humano).
- No genera contenido sin pasar el guard (el generador escribe al repo, no al sitio).
## Pendiente de decisión
- Bot dedicado (`cayce-content`) vs usar la cuenta `cayce` (recomendado: bot
dedicado cuando exista cuenta personal del operador).
-36
View File
@@ -1,36 +0,0 @@
# 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.
+5 -20
View File
@@ -6,19 +6,8 @@
// Todo lo que hace queda en actions_log. "Lo no auditado no existió."
import pg from 'pg'
import { loadIdentityBlock } from './outbox.mjs'
import { loadIdentityBlock } from '/srv/cayce-core/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,
@@ -93,8 +82,7 @@ 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 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 text = await glm(prompt + '\n\n' + input)
const pub = await fetch(`${NOSTR_INTERNAL}/publish`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -116,8 +104,7 @@ async function opinionPost() {
const prompt = loadIdentityBlock('opinion') + '\n\nSTORIES:\n' + stories
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 text = (await glm(prompt)).trim()
const pub = await fetch(`${NOSTR_INTERNAL}/publish`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -139,8 +126,7 @@ async function reflectionPost() {
const prompt = loadIdentityBlock('reflection') + '\n' + seed
const refSource = await selectSource()
const text = (await glm(prompt)).trim() + attribute(refSource)
const text = (await glm(prompt)).trim()
const pub = await fetch(`${NOSTR_INTERNAL}/publish`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -161,8 +147,7 @@ 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 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 text = (await glm(prompt)).trim()
const pub = await fetch(`${NOSTR_INTERNAL}/publish`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
+7 -33
View File
@@ -5,7 +5,7 @@ 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 METADATA_TYPES = ['profile', 'contact_list', 'inbox_relays'] // no caps, no formato texto
const DEFAULT_CAPS = { nostr: 3, hardcap: 4, bitcoinhotels: 1, facebook: 1, instagram: 1 }
function caps() {
@@ -21,11 +21,7 @@ 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
function r1_marcas(content, out) {
const c = stripUrls(content).toLowerCase()
for (const b of blocklist()) {
const re = new RegExp(`(^|[^a-z0-9])${b.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9]|$)`, 'i')
@@ -91,7 +87,7 @@ function r8_cierre(content, out) {
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)
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(' ')) || FACT_RE.test(last)
if (!hasDato && words.length < 12) out.push('cierre-aforismo-sin-dato')
}
async function r4_casiDuplicado(pool, content, out) {
@@ -129,21 +125,6 @@ async function r5_caps(pool, channel, type, out) {
} 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(`
@@ -156,20 +137,13 @@ export function ensureGuardTables(pool) {
}
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)
const body = String(content ?? '')
r1_marcas(body, reasons)
if (!METADATA_TYPES.includes(type)) {
r2_formato(body, reasons)
r3_hechos(full, reasons)
r7_frescura(channel, full, reasons)
r3_hechos(body, reasons)
r7_frescura(channel, body, reasons)
r8_cierre(body, reasons)
r9_atribucion(full, reasons)
await r4_casiDuplicado(pool, body, reasons)
await r5_caps(pool, channel, type, reasons)
}
-53
View File
@@ -16,42 +16,34 @@ const clean = async () => {
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' })])
@@ -60,7 +52,6 @@ test('R4 casi-duplicado 30 dias', async () => {
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__' })
@@ -68,7 +59,6 @@ test('R5 tope diario por canal', async () => {
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 () => {
@@ -77,27 +67,22 @@ test('metadata (kind 3) exenta de reglas de texto', async () => {
})
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))
})
@@ -108,44 +93,6 @@ test('R8 regression: cola (source: ...) no cuenta como cierre', async () => {
})
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))
})
-1
View File
@@ -8,7 +8,6 @@
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)])
-45
View File
@@ -1,45 +0,0 @@
// 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}`
}