containment: every third-party send becomes a draft (outbox), identity file versioned
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.
This commit is contained in:
2026-10-31 16:44:57 +00:00
parent 8e59e1d4da
commit b81f7f3625
10 changed files with 1725 additions and 0 deletions
+278
View File
@@ -0,0 +1,278 @@
// 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) {
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)
}