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.
194 lines
9.2 KiB
JavaScript
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)
|