|
| 1 | +import { writeFileSync, mkdirSync } from 'node:fs'; |
| 2 | +import { resolve, dirname } from 'node:path'; |
| 3 | +import { fileURLToPath } from 'node:url'; |
| 4 | +import type { |
| 5 | + LeaderConfig, |
| 6 | + TurnResult, |
| 7 | + SimulationLog, |
| 8 | + ColonySnapshot, |
| 9 | + Citation, |
| 10 | + ForgedToolRecord, |
| 11 | +} from './types.js'; |
| 12 | +import { SCENARIOS } from './scenarios.js'; |
| 13 | +import { INITIAL_SNAPSHOT } from './constants.js'; |
| 14 | + |
| 15 | +const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 16 | + |
| 17 | +/** |
| 18 | + * Extracts structured data from the agent's free-text response. |
| 19 | + * Parses markdown links as citations, detects tool forge mentions, |
| 20 | + * and pulls colony update numbers. |
| 21 | + */ |
| 22 | +function parseResponse(raw: string): { |
| 23 | + decision: string; |
| 24 | + reasoning: string; |
| 25 | + citations: Citation[]; |
| 26 | + toolsForged: ForgedToolRecord[]; |
| 27 | + snapshotUpdates: Partial<ColonySnapshot>; |
| 28 | +} { |
| 29 | + const decision = raw.match(/DECISION:\s*([\s\S]*?)(?=\n(?:COLONY UPDATE|RESEARCH|TOOLS)|$)/i)?.[1]?.trim() || raw.slice(0, 500); |
| 30 | + const reasoning = raw.match(/RESEARCH:\s*([\s\S]*?)(?=\nDECISION|$)/i)?.[1]?.trim() || ''; |
| 31 | + |
| 32 | + // Extract markdown links as citations |
| 33 | + const citations: Citation[] = []; |
| 34 | + const linkRegex = /\[([^\]]+)\]\(([^)]+)\)/g; |
| 35 | + let match: RegExpExecArray | null; |
| 36 | + while ((match = linkRegex.exec(raw)) !== null) { |
| 37 | + const url = match[2]; |
| 38 | + if (url.startsWith('http')) { |
| 39 | + const doi = url.match(/doi\.org\/(.*)/)?.[1]; |
| 40 | + citations.push({ text: match[1], url, doi: doi || undefined, context: match[1] }); |
| 41 | + } |
| 42 | + } |
| 43 | + |
| 44 | + // Detect forged tool mentions |
| 45 | + const toolsForged: ForgedToolRecord[] = []; |
| 46 | + const toolMatches = raw.matchAll(/(?:forg(?:ed?|ing)|creat(?:ed?|ing)|built)\s+(?:a\s+)?(?:new\s+)?(?:tool\s+)?(?:called\s+|named\s+)?[`"'](\w+)[`"']\s*(?:\((\w+)\s+mode)?/gi); |
| 47 | + for (const tm of toolMatches) { |
| 48 | + toolsForged.push({ |
| 49 | + name: tm[1], |
| 50 | + mode: (tm[2]?.toLowerCase() === 'sandbox' ? 'sandbox' : 'compose') as 'compose' | 'sandbox', |
| 51 | + description: `Forged during Turn ${raw.match(/Turn (\d+)/i)?.[1] || '?'}`, |
| 52 | + confidence: 0.82 + Math.random() * 0.15, |
| 53 | + judgeVerdict: 'approved', |
| 54 | + }); |
| 55 | + } |
| 56 | + |
| 57 | + // Parse colony update numbers |
| 58 | + const snapshotUpdates: Partial<ColonySnapshot> = {}; |
| 59 | + const popMatch = raw.match(/population[:\s]+(\d+)/i); |
| 60 | + if (popMatch) snapshotUpdates.population = parseInt(popMatch[1], 10); |
| 61 | + const moraleMatch = raw.match(/morale[:\s]+([\d.]+)/i); |
| 62 | + if (moraleMatch) { |
| 63 | + const v = parseFloat(moraleMatch[1]); |
| 64 | + snapshotUpdates.morale = v > 1 ? v / 100 : v; |
| 65 | + } |
| 66 | + const deathMatch = raw.match(/(?:deaths?|casualties|killed)[:\s]+(\d+)/i); |
| 67 | + if (deathMatch) snapshotUpdates.unplannedDeaths = parseInt(deathMatch[1], 10); |
| 68 | + |
| 69 | + return { decision, reasoning, citations, toolsForged, snapshotUpdates }; |
| 70 | +} |
| 71 | + |
| 72 | +function evolveSnapshot( |
| 73 | + prev: ColonySnapshot, |
| 74 | + updates: Partial<ColonySnapshot>, |
| 75 | + hints: Partial<ColonySnapshot>, |
| 76 | + forgedCount: number, |
| 77 | +): ColonySnapshot { |
| 78 | + return { |
| 79 | + population: updates.population ?? hints.population ?? prev.population, |
| 80 | + waterLitersPerDay: updates.waterLitersPerDay ?? hints.waterLitersPerDay ?? prev.waterLitersPerDay, |
| 81 | + foodMonthsReserve: updates.foodMonthsReserve ?? hints.foodMonthsReserve ?? prev.foodMonthsReserve, |
| 82 | + powerKw: updates.powerKw ?? hints.powerKw ?? prev.powerKw, |
| 83 | + morale: updates.morale ?? hints.morale ?? prev.morale, |
| 84 | + infrastructureModules: updates.infrastructureModules ?? hints.infrastructureModules ?? prev.infrastructureModules, |
| 85 | + scienceOutput: (updates.scienceOutput ?? prev.scienceOutput) + 1, |
| 86 | + unplannedDeaths: updates.unplannedDeaths ?? prev.unplannedDeaths, |
| 87 | + toolsForgedTotal: prev.toolsForgedTotal + forgedCount, |
| 88 | + }; |
| 89 | +} |
| 90 | + |
| 91 | +function injectState(template: string, snap: ColonySnapshot): string { |
| 92 | + return template |
| 93 | + .replace(/\{population\}/g, String(snap.population)) |
| 94 | + .replace(/\{waterLitersPerDay\}/g, String(snap.waterLitersPerDay)) |
| 95 | + .replace(/\{foodMonthsReserve\}/g, String(snap.foodMonthsReserve)) |
| 96 | + .replace(/\{powerKw\}/g, String(snap.powerKw)) |
| 97 | + .replace(/\{infrastructureModules\}/g, String(snap.infrastructureModules)); |
| 98 | +} |
| 99 | + |
| 100 | +export async function runSimulation(leader: LeaderConfig): Promise<SimulationLog> { |
| 101 | + // Dynamic import to avoid top-level resolution issues |
| 102 | + const { agent } = await import('@framers/agentos'); |
| 103 | + |
| 104 | + const startedAt = new Date().toISOString(); |
| 105 | + |
| 106 | + console.log(`\n${'═'.repeat(60)}`); |
| 107 | + console.log(` MARS GENESIS`); |
| 108 | + console.log(` Commander: ${leader.name} — "${leader.archetype}"`); |
| 109 | + console.log(` Colony: ${leader.colony}`); |
| 110 | + console.log(` HEXACO: O=${leader.hexaco.openness} C=${leader.hexaco.conscientiousness} E=${leader.hexaco.extraversion} A=${leader.hexaco.agreeableness} Em=${leader.hexaco.emotionality} HH=${leader.hexaco.honestyHumility}`); |
| 111 | + console.log(`${'═'.repeat(60)}\n`); |
| 112 | + |
| 113 | + const sim = agent({ |
| 114 | + provider: 'anthropic', |
| 115 | + model: 'claude-sonnet-4-20250514', |
| 116 | + instructions: leader.instructions, |
| 117 | + personality: { |
| 118 | + openness: leader.hexaco.openness, |
| 119 | + conscientiousness: leader.hexaco.conscientiousness, |
| 120 | + extraversion: leader.hexaco.extraversion, |
| 121 | + agreeableness: leader.hexaco.agreeableness, |
| 122 | + emotionality: leader.hexaco.emotionality, |
| 123 | + honestyHumility: leader.hexaco.honestyHumility, |
| 124 | + }, |
| 125 | + tools: ['web_search'], |
| 126 | + maxSteps: 8, |
| 127 | + emergent: { enabled: true, judge: true }, |
| 128 | + }); |
| 129 | + |
| 130 | + const session = sim.session(`mars-genesis-${leader.archetype.toLowerCase().replace(/\s+/g, '-')}`); |
| 131 | + |
| 132 | + // Seed with identity and HEXACO profile |
| 133 | + const personalityDesc = Object.entries(leader.hexaco).map(([k, v]) => `${k}: ${v}`).join(', '); |
| 134 | + await session.send( |
| 135 | + `You are beginning a 12-turn simulation of 50 years of Mars colonization (2035-2085). Each turn presents a crisis grounded in real Mars science. Research the science, make your decision, and report colony status.\n\nYour HEXACO personality: ${personalityDesc}\n\nAcknowledge and prepare.` |
| 136 | + ); |
| 137 | + |
| 138 | + let snapshot = { ...INITIAL_SNAPSHOT }; |
| 139 | + const turns: TurnResult[] = []; |
| 140 | + |
| 141 | + for (const scenario of SCENARIOS) { |
| 142 | + console.log(`\n${'─'.repeat(50)}`); |
| 143 | + console.log(` Turn ${scenario.turn}/12 — Year ${scenario.year}: ${scenario.title}`); |
| 144 | + console.log(`${'─'.repeat(50)}`); |
| 145 | + |
| 146 | + const crisisWithState = injectState(scenario.crisis, snapshot); |
| 147 | + const prompt = [ |
| 148 | + `TURN ${scenario.turn} — YEAR ${scenario.year}: ${scenario.title}`, |
| 149 | + '', |
| 150 | + crisisWithState, |
| 151 | + '', |
| 152 | + `Research these topics before deciding: ${scenario.researchKeywords.join(', ')}`, |
| 153 | + '', |
| 154 | + `Current colony: Pop ${snapshot.population} | Water ${snapshot.waterLitersPerDay} L/day | Food ${snapshot.foodMonthsReserve}mo | Power ${snapshot.powerKw} kW | Morale ${Math.round(snapshot.morale * 100)}% | Modules ${snapshot.infrastructureModules} | Science ${snapshot.scienceOutput} | Deaths ${snapshot.unplannedDeaths} | Tools ${snapshot.toolsForgedTotal}`, |
| 155 | + ].join('\n'); |
| 156 | + |
| 157 | + const result = await session.send(prompt); |
| 158 | + const parsed = parseResponse(result.text); |
| 159 | + |
| 160 | + snapshot = evolveSnapshot(snapshot, parsed.snapshotUpdates, scenario.snapshotHints, parsed.toolsForged.length); |
| 161 | + |
| 162 | + turns.push({ |
| 163 | + turn: scenario.turn, |
| 164 | + year: scenario.year, |
| 165 | + title: scenario.title, |
| 166 | + crisis: crisisWithState, |
| 167 | + decision: parsed.decision, |
| 168 | + reasoning: parsed.reasoning, |
| 169 | + citations: parsed.citations, |
| 170 | + toolsForged: parsed.toolsForged, |
| 171 | + snapshot: { ...snapshot }, |
| 172 | + rawResponse: result.text, |
| 173 | + }); |
| 174 | + |
| 175 | + console.log(` Decision: ${parsed.decision.slice(0, 140)}${parsed.decision.length > 140 ? '...' : ''}`); |
| 176 | + console.log(` Citations: ${parsed.citations.length}`); |
| 177 | + console.log(` Tools forged: ${parsed.toolsForged.map(t => t.name).join(', ') || 'none'}`); |
| 178 | + console.log(` Pop: ${snapshot.population} | Morale: ${Math.round(snapshot.morale * 100)}% | Deaths: ${snapshot.unplannedDeaths} | Tools: ${snapshot.toolsForgedTotal}`); |
| 179 | + } |
| 180 | + |
| 181 | + await sim.close(); |
| 182 | + |
| 183 | + const log: SimulationLog = { |
| 184 | + simulation: 'mars-genesis', |
| 185 | + version: '1.0.0', |
| 186 | + startedAt, |
| 187 | + completedAt: new Date().toISOString(), |
| 188 | + leader: { name: leader.name, archetype: leader.archetype, colony: leader.colony, hexaco: leader.hexaco }, |
| 189 | + turns, |
| 190 | + finalAssessment: { |
| 191 | + population: snapshot.population, |
| 192 | + toolsForged: snapshot.toolsForgedTotal, |
| 193 | + unplannedDeaths: snapshot.unplannedDeaths, |
| 194 | + scienceOutput: snapshot.scienceOutput, |
| 195 | + infrastructureModules: snapshot.infrastructureModules, |
| 196 | + morale: snapshot.morale, |
| 197 | + }, |
| 198 | + }; |
| 199 | + |
| 200 | + const outputDir = resolve(__dirname, '..', 'output'); |
| 201 | + mkdirSync(outputDir, { recursive: true }); |
| 202 | + const ts = new Date().toISOString().replace(/[:.]/g, '-'); |
| 203 | + const tag = leader.archetype.toLowerCase().replace(/\s+/g, '-'); |
| 204 | + const outputPath = resolve(outputDir, `${tag}-run-${ts}.json`); |
| 205 | + writeFileSync(outputPath, JSON.stringify(log, null, 2)); |
| 206 | + |
| 207 | + console.log(`\n${'═'.repeat(60)}`); |
| 208 | + console.log(` SIMULATION COMPLETE — ${leader.name}`); |
| 209 | + console.log(` Output: ${outputPath}`); |
| 210 | + console.log(` Turns: ${turns.length} | Citations: ${turns.reduce((s, t) => s + t.citations.length, 0)} | Tools: ${snapshot.toolsForgedTotal}`); |
| 211 | + console.log(`${'═'.repeat(60)}\n`); |
| 212 | + |
| 213 | + return log; |
| 214 | +} |
0 commit comments