Files
cayce-ops/runtime/editorials.mjs
cayce b81f7f3625
ci / lint-test-leaks (pull_request) Canceled after 0s
containment: every third-party send becomes a draft (outbox), identity file versioned
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

194 lines
9.2 KiB
JavaScript

// 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)