// 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(//gi, ' ') .replace(//gi, ' ') .replace(/<[^>]+>/g, ' ') .replace(/&/g, '&').replace(/ /g, ' ').replace(/&#\d+;/g, ' ') .replace(/\s+/g, ' ') .trim() return { status: r.status, title: (html.match(/([^<]*)<\/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) }