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.
279 lines
13 KiB
JavaScript
279 lines
13 KiB
JavaScript
// cayce-initiative — el loop de iniciativa de la entidad.
|
||
// Cada ciclo: lee su estado (métricas, oportunidades, audit) → decide la
|
||
// acción de mayor valor → la ejecuta con herramientas → audita.
|
||
// Herramientas: web_fetch, add_opportunity, draft_proposal, publish_note.
|
||
// MANDATE §7b: cada ciclo DEBE producir al menos una de: oportunidad
|
||
// investigada, draft, o contenido de distribución.
|
||
|
||
import pg from 'pg'
|
||
import fs from 'node:fs'
|
||
import { chat } from './llm.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)])
|
||
)
|
||
const pgc = new pg.Pool({ connectionString: env.PGURL })
|
||
const NOSTR = env.NOSTR_INTERNAL || 'http://127.0.0.1:3011'
|
||
const MANDATE = fs.readFileSync(env.MANDATE_PATH || './MANDATE.md', 'utf8')
|
||
// memoria operativa: lecciones de la retro (errores conocidos → remedios)
|
||
const LESSONS = fs.existsSync('./lessons.md') ? fs.readFileSync('./lessons.md', 'utf8') : ''
|
||
|
||
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])
|
||
|
||
// reporte interno → DM al supervisor (nunca público)
|
||
const SUPERVISOR = (fs.readFileSync('/srv/cayce-nostr/.env', 'utf8').match(/^SUPERVISOR_NPUB_HEX=(.+)$/m)?.[1] || '').trim()
|
||
async function dm(text) {
|
||
try {
|
||
await fetch(`${NOSTR}/dm`, {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ to_hex: SUPERVISOR, content: text }),
|
||
signal: AbortSignal.timeout(20000),
|
||
})
|
||
} catch { }
|
||
}
|
||
|
||
// ── herramientas
|
||
async function toolWebFetch({ url }) {
|
||
if (!/^https:\/\//.test(url)) return { error: 'solo https' }
|
||
const r = await fetch(url, {
|
||
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; cayce-entity/1.0)' },
|
||
signal: AbortSignal.timeout(20000),
|
||
})
|
||
const html = await r.text()
|
||
const text = html
|
||
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
||
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
||
.replace(/<[^>]+>/g, ' ')
|
||
.replace(/&/g, '&').replace(/ /g, ' ').replace(/&#\d+;/g, ' ')
|
||
.replace(/\s+/g, ' ')
|
||
.trim()
|
||
return { status: r.status, title: (html.match(/<title>([^<]*)<\/title>/i) || [])[1] || '', text: text.slice(0, 6000) }
|
||
}
|
||
|
||
async function toolAddOpportunity(a) {
|
||
await pgc.query(
|
||
`INSERT INTO opportunities (source_name, url, mechanic, fit, effort, expected, first_action)
|
||
VALUES ($1,$2,$3,$4,$5,$6,$7)`,
|
||
[a.source_name, a.url || null, a.mechanic, a.fit || null, a.effort || null, a.expected || null, a.first_action || null]
|
||
)
|
||
await log('green', 'opportunity_added', { source_name: a.source_name })
|
||
return { added: a.source_name }
|
||
}
|
||
|
||
async function toolDraftProposal(a) {
|
||
const r = await pgc.query(
|
||
`INSERT INTO drafts (kind, title, body) VALUES ($1,$2,$3) RETURNING id`,
|
||
[a.kind || 'other', a.title, a.body]
|
||
)
|
||
await log('green', 'draft_created', { id: r.rows[0].id, kind: a.kind, title: a.title })
|
||
return { draft_id: r.rows[0].id, note: 'el envío es 🔴 — escala al supervisor' }
|
||
}
|
||
|
||
// guardrail: los reportes internos del ciclo NUNCA van a Nostr (nunca mostrar la maquinaria)
|
||
const INTERNAL_REPORT_RE = /§7b|ciclo cumplido|pipeline|auto-?mejora|ingreso prioridad|oportunidades acumuladas|próximo play|web_fetch|add_opportunity/i
|
||
|
||
async function toolPublishNote({ content }) {
|
||
if (!content || typeof content !== 'string') return { error: 'content requerido' }
|
||
if (INTERNAL_REPORT_RE.test(content)) {
|
||
return { error: 'CONTENIDO INTERNO — los reportes de ciclo, pipeline y status NO se publican en Nostr. publish_note es solo para contenido público con voz editorial. Registrá tu avance con finish.' }
|
||
}
|
||
const r = await fetch(`${NOSTR}/publish`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ kind: 1, content, type: 'initiative_note' }),
|
||
signal: AbortSignal.timeout(60000),
|
||
})
|
||
const pr = await r.json()
|
||
await pgc.query('INSERT INTO digests (kind, body, nostr_event_id) VALUES ($1,$2,$3)', ['nostr_initiative', content, pr.id])
|
||
return pr
|
||
}
|
||
|
||
// la entidad escribe el remedio de un error conocido → lessons.remedy (self-improvement)
|
||
// normaliza una firma igual que la retro (dedup por diseño: dígitos → #)
|
||
function normSig(s) {
|
||
return String(s)
|
||
.replace(/https?:\/\/\S+/g, '#url')
|
||
.replace(/\b[0-9a-f]{8,}\b/gi, '#hex')
|
||
.replace(/\d+(\.\d+)?/g, '#')
|
||
.replace(/\s+/g, ' ')
|
||
.trim()
|
||
}
|
||
|
||
async function toolRemedy({ signature, remedy }) {
|
||
if (!signature || !remedy) return { error: 'signature + remedy requeridos' }
|
||
// 1) match exacto
|
||
let r = await pgc.query(
|
||
`UPDATE lessons SET remedy=$1 WHERE signature=$2 RETURNING signature, occurrences`,
|
||
[String(remedy).slice(0, 500), String(signature)])
|
||
// 2) match difuso: la línea formateada de lessons.md contiene la firma normalizada
|
||
// (la entidad suele copiar la línea completa con contador y "· ESCALADO")
|
||
if (!r.rowCount) {
|
||
const sigNorm = normSig(signature)
|
||
const cands = (await pgc.query(`SELECT signature FROM lessons WHERE status IN ('open','escalated')`)).rows
|
||
const hit = cands.find(c => sigNorm.includes(normSig(c.signature)))
|
||
if (hit) r = await pgc.query(
|
||
`UPDATE lessons SET remedy=$1 WHERE signature=$2 RETURNING signature, occurrences`,
|
||
[String(remedy).slice(0, 500), hit.signature])
|
||
}
|
||
if (!r.rowCount) {
|
||
return { error: `signature no encontrada: ${String(signature).slice(0, 120)}`, hint: 'usá la firma de la lista, sin el contador ni "· ESCALADO"' }
|
||
}
|
||
await log('green', 'lesson_remedy', { signature: r.rows[0].signature, remedy: String(remedy).slice(0, 200) })
|
||
return { updated: r.rows[0].signature, occurrences: r.rows[0].occurrences }
|
||
}
|
||
|
||
const TOOLS = {
|
||
web_fetch: toolWebFetch,
|
||
add_opportunity: toolAddOpportunity,
|
||
draft_proposal: toolDraftProposal,
|
||
publish_note: toolPublishNote,
|
||
add_lesson_remedy: toolRemedy,
|
||
}
|
||
|
||
// ── estado para el prompt
|
||
async function stateSummary() {
|
||
const opps = (await pgc.query('SELECT source_name, status FROM opportunities ORDER BY id DESC LIMIT 20')).rows
|
||
const zaps = (await pgc.query(`SELECT count(*) n, coalesce(sum(value),0) msats FROM metrics WHERE metric='zaps_msats'`)).rows[0]
|
||
const balance = (await pgc.query(`SELECT value FROM metrics WHERE metric='balance_reported_sats' ORDER BY id DESC LIMIT 1`)).rows[0]
|
||
const drafts = (await pgc.query('SELECT count(*) n FROM drafts')).rows[0].n
|
||
const lastActions = (await pgc.query('SELECT ts, action FROM actions_log ORDER BY id DESC LIMIT 5')).rows
|
||
const lessons = (await pgc.query(`SELECT signature, occurrences FROM lessons WHERE status != 'mitigated' ORDER BY occurrences DESC LIMIT 5`)).rows
|
||
return {
|
||
sats_recaudados: {
|
||
zaps_verificados: zaps.n,
|
||
balance_reportado_por_supervisor: balance ? Number(balance.value) : 'aún no reportado',
|
||
},
|
||
oportunidades_registradas: opps.length,
|
||
oportunidades_recientes: opps.map(o => `${o.source_name} [${o.status}]`),
|
||
drafts: drafts,
|
||
errores_conocidos_abiertos: lessons.map(l => `${l.signature} (${l.occurrences}×)`),
|
||
ultimas_acciones: lastActions.map(a => `${a.ts.toISOString().slice(0, 16)} ${a.action}`),
|
||
}
|
||
}
|
||
|
||
const TOOLS_SPEC = `TOOLS (respond with JSON only):
|
||
{"action":"web_fetch","args":{"url":"https://..."}}
|
||
{"action":"add_opportunity","args":{"source_name":"...","url":"...","mechanic":"cómo genera ingreso","fit":"encaje con hard lines","effort":"low|medium|high","expected":"ingreso esperado","first_action":"primer paso concreto"}}
|
||
{"action":"draft_proposal","args":{"kind":"sponsorship|grant|affiliate|other","title":"...","body":"propuesta completa lista para enviar"}}
|
||
{"action":"publish_note","args":{"content":"..."}}
|
||
{"action":"publish_note","args":{"content":"...— SOLO contenido público para la audiencia de Nostr, con voz editorial (irónico, cero marketing, cero reportes internos). Los reportes de ciclo, pipeline y status NUNCA se publican: eso va en finish."}}
|
||
{"action":"add_lesson_remedy","args":{"signature":"firma exacta de la lista de errores conocidos","remedy":"qué hacer distinto para evitar el error"}}
|
||
{"action":"finish","args":{"summary":"qué lograste este ciclo"}}
|
||
Una acción por respuesta. El sistema ejecuta y te devuelve el resultado. Si tu respuesta no es JSON válido, se te pide re-emitirla: responde SOLO el objeto JSON, sin texto alrededor.`
|
||
|
||
// ── el loop
|
||
async function runCycle() {
|
||
const state = await stateSummary()
|
||
const messages = [
|
||
{
|
||
role: 'user',
|
||
content: `${MANDATE}
|
||
|
||
=== TU ESTADO ACTUAL ===
|
||
${JSON.stringify(state, null, 2)}
|
||
${LESSONS ? `
|
||
=== LECCIONES — errores conocidos, evitalos ===
|
||
${LESSONS}
|
||
` : ''}
|
||
=== TU MISIÓN ESTE CICLO ===
|
||
MANDATE §7b: produce AL MENOS una de: oportunidad de ingreso investigada (con web_fetch a fuentes reales — grants, afiliados, bounties, sponsors del ecosistema bitcoin), draft de propuesta, o contenido que impulse distribución. Investiga antes de registrar: abre las fuentes con web_fetch, verifica que existan y qué piden (QC-3).
|
||
|
||
Prioridad: ingreso. Piensa como empresa: ¿qué puedo vender, a quién, cómo cobro en sats? Las propiedades (hardcap.news = señal, bitcoinhotels.org = travel BTC) son tus activos y tu vidriera.
|
||
|
||
Si hay errores conocidos abiertos con remedio pendiente (lista arriba), considerá escribirlo con add_lesson_remedy — auto-mejora.
|
||
|
||
${TOOLS_SPEC}
|
||
|
||
Empieza ahora. Cuando hayas cumplido §7b, responde finish con un summary honesto.`,
|
||
},
|
||
]
|
||
|
||
let steps = 0
|
||
const maxSteps = 16
|
||
let repairs = 0
|
||
const maxRepairs = 3
|
||
let summary = null
|
||
const trace = []
|
||
const PRODUCTIVE = ['add_opportunity', 'draft_proposal', 'publish_note', 'add_lesson_remedy']
|
||
let productive = 0
|
||
|
||
while (steps < maxSteps) {
|
||
steps++
|
||
// presupuesto de pasos: empujar al cierre antes de agotar (evita 'max steps')
|
||
const remaining = maxSteps - steps
|
||
if (remaining === 3) {
|
||
messages.push({ role: 'user', content: 'QUEDAN 3 PASOS. Cierra el ciclo: si ya investigaste, ejecutá AHORA una acción productiva (add_opportunity / draft_proposal / publish_note / add_lesson_remedy) y después finish con el summary.' })
|
||
} else if (remaining === 1) {
|
||
messages.push({ role: 'user', content: 'ÚLTIMO PASO. Respondé finish con un summary honesto de lo logrado este ciclo, aunque sea parcial.' })
|
||
}
|
||
// la cadena (zai → gemini) agota los fallbacks; si todo falla, el ciclo
|
||
// muere auditado como error y la retro lo escala
|
||
let text = await chat(messages, 6000, { thinking: false, timeoutMs: 90000 })
|
||
text = text.replace(/```json|```/g, '').trim()
|
||
const js = text.indexOf('{')
|
||
const je = text.lastIndexOf('}')
|
||
let act = null
|
||
if (js === -1 || je === -1) {
|
||
// sin JSON: re-emisión en vez de morir — error reparable en el momento
|
||
if (repairs < maxRepairs) {
|
||
repairs++
|
||
messages.push({ role: 'assistant', content: text.slice(0, 500) })
|
||
messages.push({ role: 'user', content: 'Esa respuesta no contenía JSON. Re-emite SOLO el objeto JSON de UNA acción, sin texto alrededor.' })
|
||
steps--
|
||
continue
|
||
}
|
||
summary = 'sin JSON tras ' + maxRepairs + ' repairs: ' + text.slice(0, 120)
|
||
break
|
||
}
|
||
try { act = JSON.parse(text.slice(js, je + 1)) } catch (e) {
|
||
if (repairs < maxRepairs) {
|
||
repairs++
|
||
messages.push({ role: 'assistant', content: text.slice(0, 500) })
|
||
messages.push({ role: 'user', content: `Esa respuesta no fue JSON válido parseable (${String(e).slice(0, 80)}). Re-emite SOLO el objeto JSON de UNA acción. Si el body era muy largo, acortalo.` })
|
||
steps--
|
||
continue
|
||
}
|
||
summary = 'JSON inválido tras ' + maxRepairs + ' repairs'
|
||
break
|
||
}
|
||
|
||
if (act.action === 'finish') { summary = act.args?.summary ?? 'finish'; break }
|
||
if (!TOOLS[act.action]) {
|
||
messages.push({ role: 'assistant', content: JSON.stringify(act) })
|
||
messages.push({ role: 'user', content: `herramienta desconocida: ${act.action}. ${TOOLS_SPEC}` })
|
||
continue
|
||
}
|
||
let result
|
||
try {
|
||
result = await TOOLS[act.action](act.args || {})
|
||
} catch (e) {
|
||
result = { error: String(e).slice(0, 300) }
|
||
}
|
||
trace.push({ step: steps, action: act.action, args: act.args })
|
||
if (PRODUCTIVE.includes(act.action) && !result?.error) productive++
|
||
messages.push({ role: 'assistant', content: JSON.stringify(act) })
|
||
messages.push({ role: 'user', content: 'RESULTADO: ' + JSON.stringify(result).slice(0, 4000) })
|
||
}
|
||
|
||
// §7b se cumple con ≥1 acción productiva — si no, el ciclo falla y se audita como error
|
||
const s7b_ok = productive > 0
|
||
const finalSummary = summary ?? 'max steps'
|
||
await log(s7b_ok ? 'green' : 'red', 'initiative_cycle',
|
||
{ steps, productive, repairs, trace, summary: finalSummary }, s7b_ok ? 'done' : 'error')
|
||
await dm(`INITIATIVE ${new Date().toISOString().slice(0, 10)} — ciclo ${s7b_ok ? 'cumplido' : 'falló'} · ${steps} pasos · ${productive} productivas · ${trace.map(t => t.action).join(' → ') || 'sin acciones'}\n\n${finalSummary}`)
|
||
return { steps, productive, s7b_ok, summary: finalSummary, trace }
|
||
}
|
||
|
||
try {
|
||
const out = await runCycle()
|
||
console.log(JSON.stringify(out, null, 2))
|
||
await pgc.end()
|
||
process.exit(out.s7b_ok ? 0 : 1)
|
||
} catch (e) {
|
||
console.error('initiative fatal:', String(e).slice(0, 300))
|
||
await log('red', 'initiative_cycle', { error: String(e).slice(0, 300) }, 'error').catch(() => { })
|
||
await pgc.end().catch(() => { })
|
||
process.exit(1)
|
||
}
|