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.
226 lines
11 KiB
JavaScript
226 lines
11 KiB
JavaScript
// cayce-core — el ciclo de la entidad.
|
|
// 1. events watch — BTC Map v4 → tabla events (🟢)
|
|
// 2. curación — estado del stream de hardcap (🟢)
|
|
// 3. digest — GLM arma la promo diaria → publica vía cayce-nostr (🟢)
|
|
// 4. métricas + audit log
|
|
// Todo lo que hace queda en actions_log. "Lo no auditado no existió."
|
|
|
|
import pg from 'pg'
|
|
import { loadIdentityBlock } from './outbox.mjs'
|
|
import { chat } from './llm.mjs'
|
|
|
|
const {
|
|
ZAI_API_KEY,
|
|
ZAI_BASE = 'https://api.z.ai/api/paas/v4/chat/completions',
|
|
CAYCE_MODEL = 'glm-4.7',
|
|
PGURL = 'postgresql://hardcap@localhost:5432/cayce',
|
|
HARD_CAP_API = 'https://api.hardcap.news',
|
|
NOSTR_INTERNAL = 'http://127.0.0.1:3011',
|
|
SUPERVISOR_NPUB_HEX,
|
|
} = process.env
|
|
|
|
const pgc = new pg.Pool({ connectionString: PGURL })
|
|
const log = (zone, action, detail = {}, result = 'ok') =>
|
|
pgc.query('INSERT INTO actions_log (zone, action, detail, result) VALUES ($1,$2,$3,$4)', [zone, action, detail, result])
|
|
|
|
async function glm(prompt) {
|
|
// cadena de fallback en llm.mjs (zai → gemini); lanza si todo falla
|
|
const t = await chat([{ role: 'user', content: prompt }], 3000)
|
|
// una nota vacía en Nostr es peor que ningún post — fallar explícito
|
|
if (!t) throw new Error('glm devolvió contenido vacío')
|
|
return t
|
|
}
|
|
|
|
// ── 1. events watch: BTC Map v4 → upsert (🟢)
|
|
async function eventsWatch() {
|
|
const r = await fetch('https://api.btcmap.org/v4/events', { signal: AbortSignal.timeout(20000) })
|
|
if (!r.ok) throw new Error(`btcmap HTTP ${r.status}`)
|
|
const evs = await r.json()
|
|
const now = new Date()
|
|
const upcoming = evs.filter(e => e.starts_at && new Date(e.ends_at || e.starts_at) >= now)
|
|
let upserted = 0
|
|
for (const e of upcoming.slice(0, 200)) {
|
|
const id = 'btcmap-' + e.id
|
|
await pgc.query(
|
|
`INSERT INTO events (id, name, type, city, country, starts_on, ends_on, url, source, updated_at)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'btcmap',now())
|
|
ON CONFLICT (id) DO UPDATE SET name=EXCLUDED.name, starts_on=EXCLUDED.starts_on,
|
|
ends_on=EXCLUDED.ends_on, url=EXCLUDED.url, updated_at=now()`,
|
|
[id, e.name || 'Bitcoin event', 'meetup', e.city || null, e.country || null,
|
|
(e.starts_at || '').slice(0, 10) || null, (e.ends_at || '').slice(0, 10) || null, e.website || null]
|
|
)
|
|
upserted++
|
|
}
|
|
await log('green', 'events_watch', { upcoming: upcoming.length, upserted })
|
|
return upserted
|
|
}
|
|
|
|
// ── 2. curación: estado del stream de hardcap (🟢, reporte)
|
|
async function curate() {
|
|
const r = await fetch(`${HARD_CAP_API}/api/articles?limit=1`, { signal: AbortSignal.timeout(15000) })
|
|
const d = await r.json()
|
|
await log('green', 'curation_check', { total_published: d.total })
|
|
return d.total
|
|
}
|
|
|
|
// ── 3. digest promo: GLM arma el texto → publica (🟢)
|
|
async function digest(totalPublished) {
|
|
const arts = (await (await fetch(`${HARD_CAP_API}/api/articles?limit=8`, { signal: AbortSignal.timeout(15000) })).json()).articles
|
|
const evs = (await pgc.query(
|
|
`SELECT name, city, country, starts_on, url FROM events
|
|
WHERE active AND starts_on >= CURRENT_DATE ORDER BY starts_on ASC LIMIT 6`
|
|
)).rows
|
|
|
|
const stories = arts.map(a =>
|
|
`- [${a.title}] — score ${a.relevanceScore ?? '?'} — https://hardcap.news/news/${a.id}/summary`
|
|
).join('\n')
|
|
const eventLines = evs.map(e =>
|
|
`- ${e.starts_on} — ${e.name}${e.city ? ` (${e.city})` : ''}${e.url ? ` — ${e.url}` : ''}`
|
|
).join('\n')
|
|
|
|
const prompt = loadIdentityBlock('digest')
|
|
|
|
const input = `STORIES (from hardcap.news, published):\n${stories}\n\nUPCOMING EVENTS:\n${eventLines || '(none)'}\n\nTotal published articles today: ${totalPublished}`
|
|
|
|
const text = await glm(prompt + '\n\n' + input)
|
|
const pub = await fetch(`${NOSTR_INTERNAL}/publish`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ kind: 1, content: text, type: 'digest' }),
|
|
signal: AbortSignal.timeout(60000),
|
|
})
|
|
const pr = await pub.json()
|
|
await pgc.query('INSERT INTO digests (kind, body, nostr_event_id) VALUES ($1,$2,$3)', ['nostr_note', text, pr.id])
|
|
await log('green', 'digest_published', { nostr_event_id: pr.id, relays: pr.relays, length: text.length })
|
|
return { text, nostr: pr }
|
|
}
|
|
|
|
// ── 3b. posteos de opinión y events-post (modo --posts)
|
|
async function opinionPost() {
|
|
const arts = (await (await fetch(`${HARD_CAP_API}/api/articles?limit=10`, { signal: AbortSignal.timeout(15000) })).json()).articles
|
|
const stories = arts.map(a =>
|
|
`- [${a.title}] — topic ${(a.topics || []).join('/')} — score ${a.relevanceScore ?? '?'} — resumen: ${(a.summary || '(sin resumen)').slice(0, 200)} — url: https://hardcap.news/news/${a.id}/summary`
|
|
).join('\n')
|
|
|
|
const prompt = loadIdentityBlock('opinion') + '\n\nSTORIES:\n' + stories
|
|
|
|
const text = (await glm(prompt)).trim()
|
|
const pub = await fetch(`${NOSTR_INTERNAL}/publish`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ kind: 1, content: text, type: 'opinion' }),
|
|
signal: AbortSignal.timeout(60000),
|
|
})
|
|
const pr = await pub.json()
|
|
await pgc.query('INSERT INTO digests (kind, body, nostr_event_id) VALUES ($1,$2,$3)', ['nostr_opinion', text, pr.id])
|
|
await log('green', 'opinion_post', { nostr_event_id: pr.id, length: text.length })
|
|
return text
|
|
}
|
|
|
|
// ── 3c. reflexión diaria: la voz pura de Cayce, cero autopromoción (modo --posts)
|
|
async function reflectionPost() {
|
|
const arts = (await (await fetch(`${HARD_CAP_API}/api/articles?limit=8`, { signal: AbortSignal.timeout(15000) })).json().catch(() => ({}))).articles || []
|
|
const seed = arts.length
|
|
? arts.map(a => `- ${a.title}`).join('\n')
|
|
: '(sin titulares hoy — reflexioná desde lo que ya sabés)'
|
|
|
|
const prompt = loadIdentityBlock('reflection') + '\n' + seed
|
|
|
|
const text = (await glm(prompt)).trim()
|
|
const pub = await fetch(`${NOSTR_INTERNAL}/publish`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ kind: 1, content: text, type: 'reflection' }),
|
|
signal: AbortSignal.timeout(60000),
|
|
})
|
|
const pr = await pub.json()
|
|
await pgc.query('INSERT INTO digests (kind, body, nostr_event_id) VALUES ($1,$2,$3)', ['nostr_reflection', text, pr.id])
|
|
await log('green', 'reflection_post', { nostr_event_id: pr.id, length: text.length })
|
|
return text
|
|
}
|
|
|
|
async function eventsPost() {
|
|
const evs = (await pgc.query(
|
|
`SELECT name, city, country, starts_on, url FROM events
|
|
WHERE active AND starts_on >= CURRENT_DATE ORDER BY starts_on ASC LIMIT 4`
|
|
)).rows
|
|
|
|
const prompt = loadIdentityBlock('events') + '\n\nEVENTS:\n' + evs.map(e => `- ${e.starts_on} — ${e.name}${e.city ? ` (${e.city}, ${e.country || ''})` : ''}${e.url ? ` — ${e.url}` : ''}`).join('\n') || '(no upcoming events)'
|
|
|
|
const text = (await glm(prompt)).trim()
|
|
const pub = await fetch(`${NOSTR_INTERNAL}/publish`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ kind: 1, content: text, type: 'events_post' }),
|
|
signal: AbortSignal.timeout(60000),
|
|
})
|
|
const pr = await pub.json()
|
|
await pgc.query('INSERT INTO digests (kind, body, nostr_event_id) VALUES ($1,$2,$3)', ['nostr_events', text, pr.id])
|
|
await log('green', 'events_post', { nostr_event_id: pr.id, length: text.length })
|
|
return text
|
|
}
|
|
|
|
// ── 4. tesorería: snapshot del balance Blink (🟢, solo lectura)
|
|
async function treasury() {
|
|
if (!process.env.BLINK_API_KEY) return 'sin BLINK_API_KEY aún'
|
|
const r = await fetch('https://api.blink.sv/graphql', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-API-KEY': process.env.BLINK_API_KEY },
|
|
body: JSON.stringify({ query: 'query me { me { defaultAccount { username wallets { id walletCurrency balance } } } }' }),
|
|
signal: AbortSignal.timeout(15000),
|
|
})
|
|
const d = await r.json()
|
|
const acct = d?.data?.me?.defaultAccount
|
|
if (!acct) return 'blink sin respuesta válida'
|
|
for (const w of acct.wallets || []) {
|
|
await pgc.query(
|
|
'INSERT INTO metrics (property, metric, value, note) VALUES ($1,$2,$3,$4)',
|
|
['cayce_wallet', `balance_${w.walletCurrency.toLowerCase()}`, w.balance, w.id]
|
|
)
|
|
}
|
|
const ln = acct.username ? `${acct.username}@blink.sv` : null
|
|
await log('green', 'treasury_snapshot', { wallets: (acct.wallets || []).length, lightning_address: ln })
|
|
return `balance registrado${ln ? ' — ' + ln : ''}`
|
|
}
|
|
|
|
// ── main
|
|
const started = Date.now()
|
|
const results = {}
|
|
|
|
if (process.argv.includes('--posts')) {
|
|
// modo ligero: solo voz pública (opinión + reflection + events post)
|
|
// espaciado: nunca 2 posteos seguidos — 45 min entre ellos (regla del supervisor)
|
|
try { results.opinion = await opinionPost() } catch (e) { results.opinion = 'ERROR: ' + String(e); await log('red', 'opinion_post', { error: String(e).slice(0, 200) }, 'error') }
|
|
await log('green', 'posts_spacing', {}, 'esperando 45 min antes del reflection post')
|
|
await new Promise(r => setTimeout(r, 45 * 60 * 1000))
|
|
try { results.reflection = await reflectionPost() } catch (e) { results.reflection = 'ERROR: ' + String(e); await log('red', 'reflection_post', { error: String(e).slice(0, 200) }, 'error') }
|
|
await log('green', 'posts_spacing', {}, 'esperando 45 min antes del events post')
|
|
await new Promise(r => setTimeout(r, 45 * 60 * 1000))
|
|
try { results.events_post = await eventsPost() } catch (e) { results.events_post = 'ERROR: ' + String(e); await log('red', 'events_post', { error: String(e).slice(0, 200) }, 'error') }
|
|
const hadError = Object.values(results).some(v => String(v).startsWith('ERROR'))
|
|
await log('green', 'posts_cycle', results, hadError ? 'error' : `done in ${Date.now() - started}ms`)
|
|
console.log(JSON.stringify(results, null, 2).slice(0, 1600))
|
|
await pgc.end()
|
|
process.exit(hadError ? 1 : 0)
|
|
}
|
|
|
|
try {
|
|
results.events = await eventsWatch()
|
|
} catch (e) { results.events = 'ERROR: ' + String(e); await log('red', 'events_watch', { error: String(e).slice(0, 200) }, 'error') }
|
|
try {
|
|
results.published_total = await curate()
|
|
} catch (e) { results.published_total = 'ERROR: ' + String(e); await log('red', 'curation_check', { error: String(e).slice(0, 200) }, 'error') }
|
|
try {
|
|
results.treasury = await treasury()
|
|
} catch (e) { results.treasury = 'ERROR: ' + String(e); await log('red', 'treasury', { error: String(e).slice(0, 200) }, 'error') }
|
|
try {
|
|
const d = await digest(results.published_total ?? 0)
|
|
results.digest = { nostr: d.nostr, preview: d.text.slice(0, 120) }
|
|
} catch (e) { results.digest = 'ERROR: ' + String(e); await log('red', 'digest', { error: String(e).slice(0, 200) }, 'error') }
|
|
|
|
const hadError = Object.values(results).some(v => String(v).startsWith('ERROR'))
|
|
await log('green', 'cycle_complete', results, hadError ? 'error' : `done in ${Date.now() - started}ms`)
|
|
console.log(JSON.stringify(results, null, 2))
|
|
await pgc.end()
|
|
process.exit(hadError ? 1 : 0)
|