Files
cayce-ops/runtime/initiative.mjs
T
cayce 058126b80d
ci / lint-test-leaks (pull_request) Canceled after 0s
containment 2/2: kind 10050 (inbox relays) + drafts summary in supervisor DM + grants suspended, BTCPay priority-1
- publish-10050.mjs: publishes Cayce kind 10050 (NIP-65 message-relays/
  inbox) listing the 6 relays where the listener actually reads DMs.
  EXECUTED once with operator authorization: event id
  188ef397824e8f29b5e1bea661ae65b1f8532ee97617747b9fbde57c6e8dfce9,
  accepted 6/6 relays.
- initiative.mjs: the cycle DM to the supervisor now includes a summary
  of outbound_drafts from the last 24h (id, type, first 15 words) so the
  operator reviews what the agent WANTED to publish.
- initiative.mjs: supervisor directive (CAYCE_DIRECTIVE_BTCPAY=1,
  reversible) injected into the cycle prompt: grants/opportunity search
  SUSPENDED, single priority = consolidate the BTCPay Server proposal
  (opportunity #106 / draft #33) ready for HUMAN sending. MANDATE text
  untouched (only the supervisor can change it).
- initiative.mjs: add_opportunity now rejects duplicates (the agent had
  re-registered OpenSats ×10, HRF ×6).
- opportunity #106 status -> priority-1.
- check-relay-silence.mjs: queries the relays for Cayce-signed events
  since a timestamp; allows only kind 10050 and supervisor DMs; exits 1
  on any violation. Verified: 0 events, 0 violations since deployment.
- deploy.sh: sudo for the daemon restart, optional files tolerated.

Gate: make check green (96 tests).
2026-10-31 16:48:54 +00:00

302 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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(/&amp;/g, '&').replace(/&nbsp;/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) {
// directiva supervisor 2026-10-31: sin dedup el agente re-registraba la
// misma oportunidad (OpenSats ×10, HRF ×6). Ahora: si existe, se rechaza.
const dup = await pgc.query('SELECT id FROM opportunities WHERE lower(source_name) = lower($1) LIMIT 1', [a.source_name])
if (dup.rowCount) {
await log('yellow', 'opportunity_duplicate_rejected', { source_name: a.source_name, existing_id: dup.rows[0].id })
return { duplicate: true, existing_id: dup.rows[0].id, note: 'ya registrada — no se duplica; continuá con el resto del ciclo' }
}
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()
// directiva del supervisor (2026-10-31): grants suspendidos, BTCPay prioridad 1.
// No modifica el MANDATE (eso es 🔴); es una orden operativa reversible con
// CAYCE_DIRECTIVE_BTCPAY=0 en el .env.
const DIRECTIVE = process.env.CAYCE_DIRECTIVE_BTCPAY === '1' ? `
=== DIRECTIVA DEL SUPERVISOR (2026-10-31 — prevalece sobre §7b hasta nuevo aviso) ===
1. SUSPENDIDA la búsqueda y registro de grants y nuevas oportunidades. NO uses add_opportunity salvo para la excepción del punto 2.
2. PRIORIDAD 1 ÚNICA: la propuesta de BTCPay Server — oportunidad #106 "BTCPay Server Deployment & Content Service" y draft #33 "BTCPay Server Plugin: Event & Travel Integration". Trabajo esperado: consolidar UNA propuesta final lista para envío humano (alcance, arquitectura, presupuesto, plan de entrega) como draft nuevo marcado claramente. NO la envíes: la envía el operador.
3. La contención está activa: publish_note crea BORRADORES, no publica. Sigue contando como acción productiva.
` : ''
const messages = [
{
role: 'user',
content: `${MANDATE}
${DIRECTIVE}
=== 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')
const drafts24 = (await pgc.query(
`SELECT id, type, content FROM outbound_drafts WHERE ts > now() - interval '24 hours' ORDER BY id`
).catch(() => ({ rows: [] }))).rows
const draftLines = drafts24.map(d => `#${d.id} [${d.type}] ${String(d.content).replace(/\s+/g, ' ').split(' ').slice(0, 15).join(' ')}`)
const draftBlock = `\n\nBORRADORES contención (24h: ${draftLines.length}) — revisión y aprobación pendientes del operador:\n${draftLines.join('\n') || 'ninguno'}`
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}${draftBlock}`)
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)
}