paracosm 0.4.146 → 0.4.148

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,8 +3,10 @@ import { resolve, dirname } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { webSearchTool, createEmergentEngine, createCallForgedTool, wrapForgeTool, } from './emergent-setup.js';
5
5
  import { humanizeToolName, parseDeptReport, parseCmdDecision, emptyReport, emptyDecision, decisionToPolicy, } from './parsers.js';
6
- import { buildPriceForSite, getDefaultPricing } from './pricing.js';
7
- import { buildAvailableToolsBlock } from './tool-ledger.js';
6
+ import { createCostTracker } from './cost-tracker.js';
7
+ import { buildPersonalityCue, buildCommanderBootstrap, runDepartmentPromotions, } from './commander-setup.js';
8
+ import { buildAvailableToolsBlock, buildForgedToolbox } from './tool-ledger.js';
9
+ import { buildCitationCatalog } from './citations-catalog.js';
8
10
  import { SeededRng } from '../engine/core/rng.js';
9
11
  import { classifyOutcome, classifyOutcomeById } from '../engine/core/progression.js';
10
12
  import { SimulationKernel } from '../engine/core/kernel.js';
@@ -16,7 +18,7 @@ import { EventDirector } from './director.js';
16
18
  import { generateAgentReactions } from './agent-reactions.js';
17
19
  import { recordReactionMemory, consolidateMemory, updateRelationshipsFromReactions } from './agent-memory.js';
18
20
  import { DEFAULT_EXECUTION, resolveSimulationModels, } from '../cli/sim-config.js';
19
- import { applyCustomEventToCrisis, buildPromotionPrompt, buildYearSchedule } from './runtime-helpers.js';
21
+ import { applyCustomEventToCrisis, buildYearSchedule } from './runtime-helpers.js';
20
22
  import { classifyProviderError, shouldAbortRun } from './provider-errors.js';
21
23
  import { EffectRegistry } from '../engine/effect-registry.js';
22
24
  import { marsScenario } from '../engine/mars/index.js';
@@ -30,128 +32,20 @@ export async function runSimulation(leader, keyPersonnel, opts = {}) {
30
32
  const sid = `${sc.labels.shortName}-v2-${leader.archetype.toLowerCase().replace(/\s+/g, '-')}`;
31
33
  const modelConfig = resolveSimulationModels(provider, opts.models);
32
34
  // Cost tracking: accumulate token usage and estimated cost across all LLM calls
33
- let totalTokens = 0;
34
- let totalCostUSD = 0;
35
- let llmCalls = 0;
36
- /**
37
- * Run-wide prompt-cache accounting. Tracks cache-tier token counts
38
- * returned by AgentOS on Anthropic calls (and OpenAI's automatic
39
- * caching when it eventually exposes them). Surfaces as proof that
40
- * the prompt-caching wiring actually hit — a run with `cacheReadTokens
41
- * = 0` despite configured cache breakpoints means caching didn't work
42
- * and the reported cost is not reflecting the savings we expected.
43
- *
44
- * Reported via `_cost` on every SSE emit so the dashboard breakdown
45
- * modal can display cache hit rate and estimated savings.
46
- */
47
- let cacheReadTokens = 0;
48
- let cacheCreationTokens = 0;
49
- const newBucket = () => ({
50
- totalTokens: 0, totalCostUSD: 0, calls: 0,
51
- cacheReadTokens: 0, cacheCreationTokens: 0,
52
- });
53
- const costBySite = {
54
- director: newBucket(),
55
- commander: newBucket(),
56
- departments: newBucket(),
57
- judge: newBucket(),
58
- reactions: newBucket(),
59
- other: newBucket(),
60
- };
61
- // Pricing comes from pricing.ts so MODEL_PRICING has one home.
62
- const defaultPricing = getDefaultPricing(modelConfig);
63
- const priceForSite = buildPriceForSite(modelConfig);
64
- /**
65
- * Record token/cost usage from a single LLM call.
66
- *
67
- * @param result The result object from generateText / session.send(),
68
- * which carries an optional `usage` field populated by AgentOS.
69
- * @param site Which pipeline stage made the call. Used to build the
70
- * per-site cost breakdown the dashboard StatsBar can drill into.
71
- * Defaults to 'other' when a call-site isn't tagged (harmless
72
- * fallback, but tag every new call site so the breakdown stays
73
- * meaningful).
74
- */
75
- function trackUsage(result, site = 'other') {
76
- if (!result?.usage)
77
- return;
78
- const tokensThisCall = result.usage.totalTokens ?? 0;
79
- const callCacheRead = result.usage.cacheReadTokens ?? 0;
80
- const callCacheCreate = result.usage.cacheCreationTokens ?? 0;
81
- let costThisCall;
82
- if (typeof result.usage.costUSD === 'number') {
83
- // Provider returned a cost that already accounts for cache tiers.
84
- costThisCall = result.usage.costUSD;
85
- }
86
- else {
87
- // Fallback: compute a cache-aware estimate using commander-tier
88
- // pricing as an approximation. Cache reads billed at 0.1×, cache
89
- // creations at 1.25× input rate. Previous estimate ignored cache
90
- // entirely so a run with heavy caching would UNDER-report the
91
- // billed amount by ~10-15%.
92
- const input = result.usage.promptTokens ?? 0;
93
- const output = result.usage.completionTokens ?? 0;
94
- costThisCall =
95
- (input * defaultPricing.input / 1_000_000)
96
- + (output * defaultPricing.output / 1_000_000)
97
- + (callCacheRead * defaultPricing.input * 0.10 / 1_000_000)
98
- + (callCacheCreate * defaultPricing.input * 1.25 / 1_000_000);
99
- }
100
- totalTokens += tokensThisCall;
101
- totalCostUSD += costThisCall;
102
- llmCalls++;
103
- cacheReadTokens += callCacheRead;
104
- cacheCreationTokens += callCacheCreate;
105
- const bucket = costBySite[site];
106
- bucket.totalTokens += tokensThisCall;
107
- bucket.totalCostUSD += costThisCall;
108
- bucket.calls++;
109
- bucket.cacheReadTokens += callCacheRead;
110
- bucket.cacheCreationTokens += callCacheCreate;
111
- }
35
+ // Cost tracker: per-site buckets + cache-aware fallback estimation.
36
+ // Lives in cost-tracker.ts so the math has one home and the turn loop
37
+ // reads as dispatch. trackUsage(result, site) is called at every LLM
38
+ // call site with a site tag; buildCostPayload() is called before each
39
+ // SSE emit so the dashboard breakdown modal sees live data.
40
+ const costTracker = createCostTracker(modelConfig);
41
+ const trackUsage = costTracker.trackUsage;
112
42
  const emit = (type, data) => {
113
- // Per-site cache savings math: estimate USD saved vs a hypothetical
114
- // no-caching run. Anthropic bills cache_read at 0.10x input rate
115
- // (saves 0.90x) and cache_creation at 1.25x (costs +0.25x). So net
116
- // savings per site = (reads × 0.90 - creates × 0.25) × inputPrice.
117
- // A fresh creation on turn 1 is a net COST relative to no-cache
118
- // (the run hasn't amortized the 1.25x overhead yet). By turn 3+
119
- // with reads dominating, savings are substantial.
120
- let runCacheSavingsUSD = 0;
121
- const breakdown = {};
122
- for (const [k, v] of Object.entries(costBySite)) {
123
- if (v.calls > 0) {
124
- const sitePricing = priceForSite(k);
125
- const siteSavings = ((v.cacheReadTokens * 0.90) - (v.cacheCreationTokens * 0.25))
126
- * sitePricing.input / 1_000_000;
127
- runCacheSavingsUSD += siteSavings;
128
- breakdown[k] = {
129
- totalTokens: v.totalTokens,
130
- totalCostUSD: Math.round(v.totalCostUSD * 10000) / 10000,
131
- calls: v.calls,
132
- ...(v.cacheReadTokens > 0 ? { cacheReadTokens: v.cacheReadTokens } : {}),
133
- ...(v.cacheCreationTokens > 0 ? { cacheCreationTokens: v.cacheCreationTokens } : {}),
134
- ...(Math.abs(siteSavings) >= 0.0001
135
- ? { cacheSavingsUSD: Math.round(siteSavings * 10000) / 10000 }
136
- : {}),
137
- };
138
- }
139
- }
140
43
  opts.onEvent?.({
141
44
  type,
142
45
  leader: leader.name,
143
46
  data: {
144
47
  ...data,
145
- _cost: {
146
- totalTokens,
147
- totalCostUSD: Math.round(totalCostUSD * 10000) / 10000,
148
- llmCalls,
149
- cacheReadTokens,
150
- cacheCreationTokens,
151
- /** Run-wide USD saved by prompt caching vs a no-cache run. */
152
- cacheSavingsUSD: Math.round(runCacheSavingsUSD * 10000) / 10000,
153
- breakdown,
154
- },
48
+ _cost: costTracker.buildCostPayload(),
155
49
  },
156
50
  });
157
51
  };
@@ -263,100 +157,31 @@ export async function runSimulation(leader, keyPersonnel, opts = {}) {
263
157
  maxSteps: opts.execution?.commanderMaxSteps ?? DEFAULT_EXECUTION.commanderMaxSteps,
264
158
  });
265
159
  const cmdSess = commander.session(`${sid}-cmd`);
266
- // Bootstrap commander session. The HEXACO traits passed to agent() above
267
- // already shape the LLM's voice; this kickoff message reinforces that
268
- // decisions should be visibly personality-driven so two extreme leaders
269
- // produce visibly different timelines, not centrist convergent choices.
270
- const personalityCue = (() => {
271
- const h = leader.hexaco;
272
- const cues = [];
273
- if (h.openness > 0.7)
274
- cues.push('You favor novel, untested approaches over proven ones');
275
- if (h.openness < 0.3)
276
- cues.push('You favor proven protocols over experiments');
277
- if (h.conscientiousness > 0.7)
278
- cues.push('You demand evidence and contingency plans before committing');
279
- if (h.conscientiousness < 0.3)
280
- cues.push('You move fast and accept ambiguity');
281
- if (h.emotionality > 0.7)
282
- cues.push('You weigh human cost heavily — even small mortality risks deter you');
283
- if (h.emotionality < 0.3)
284
- cues.push('You will accept casualties for strategic gain');
285
- if (h.agreeableness < 0.4)
286
- cues.push('You override department consensus when you see a better path');
287
- if (h.honestyHumility < 0.4)
288
- cues.push('You leverage information asymmetries when useful');
289
- return cues.length ? `Your decision style: ${cues.join('. ')}.` : '';
290
- })();
291
- // Bootstrap the commander. This is the FIRST LLM call in the run, so if
292
- // the user's API key is invalid or credits are exhausted, this is where
293
- // we find out. Report it through the classifier so the dashboard banner
294
- // fires before we burn compute launching a sim that has no hope of
295
- // producing valid output.
160
+ // Bootstrap the commander with a HEXACO-derived personality cue. This
161
+ // is the FIRST LLM call in the run, so if the user's key is invalid
162
+ // or credits are exhausted, the classifier fires here before we burn
163
+ // compute on a run that has no hope of producing valid output.
296
164
  try {
297
- trackUsage(await cmdSess.send(`You are the colony commander. You receive department reports and make strategic decisions. ` +
298
- `${personalityCue} ` +
299
- `Your personality MUST visibly shape your choices — do not converge on a centrist option just because ` +
300
- `it sounds reasonable. If your traits push you toward the risky option, take it; if they push you toward ` +
301
- `the safe option, take it. The simulation's value is in how different leaders produce different outcomes ` +
302
- `from the same starting state. ` +
303
- `When the crisis includes options with IDs, you MUST include selectedOptionId in your JSON response. ` +
304
- `Return JSON with selectedOptionId, decision, rationale, selectedPolicies, rejectedPolicies, ` +
305
- `expectedTradeoffs, watchMetricsNextTurn. Acknowledge.`), 'commander');
165
+ trackUsage(await cmdSess.send(buildCommanderBootstrap(buildPersonalityCue(leader.hexaco))), 'commander');
306
166
  }
307
167
  catch (err) {
308
168
  reportProviderError(err, 'commander-bootstrap');
309
- // If this is a terminal provider error, `isAborted()` is now true and
310
- // the turn loop below will skip all LLM work. Continue into the turn
311
- // loop anyway so the normal end-of-run cleanup path runs and the user
312
- // gets a proper `complete` SSE event (with the `provider_error` event
313
- // already sent above).
314
- }
315
- // Turn 0: Commander promotes department heads from agent roster
316
- console.log(' [Turn 0] Commander evaluating roster for promotions...');
317
- const promotionDepts = sc.departments.map(d => d.id);
318
- const roleNames = Object.fromEntries(sc.departments.map(d => [d.id, d.role]));
319
- const candidateSummaries = promotionDepts.map(dept => {
320
- const candidates = kernel.getCandidates(dept, 5);
321
- return `## ${dept.toUpperCase()} — Top 5 Candidates:\n${candidates.map(c => {
322
- const age = startYear - c.core.birthYear;
323
- const h = c.hexaco;
324
- return `- ${c.core.name} (${c.core.id}), age ${age}, spec: ${c.career.specialization}, O:${h.openness.toFixed(2)} C:${h.conscientiousness.toFixed(2)} E:${h.extraversion.toFixed(2)} A:${h.agreeableness.toFixed(2)} Em:${h.emotionality.toFixed(2)} HH:${h.honestyHumility.toFixed(2)}`;
325
- }).join('\n')}`;
326
- }).join('\n\n');
327
- const promoResult = await cmdSess.send(buildPromotionPrompt(candidateSummaries));
328
- // Promotion is a commander-session call, so it lands in the commander bucket.
329
- trackUsage(promoResult, 'commander');
330
- const promoMatch = promoResult.text.match(/\{[\s\S]*"promotions"[\s\S]*\}/);
331
- if (promoMatch) {
332
- try {
333
- const pd = JSON.parse(promoMatch[0]);
334
- for (const p of pd.promotions || []) {
335
- try {
336
- kernel.promoteAgent(p.agentId, p.department, p.role, leader.name);
337
- console.log(` ✦ ${p.agentId} → ${p.role}: ${p.reason?.slice(0, 80)}`);
338
- emit('promotion', { agentId: p.agentId, department: p.department, role: p.role, reason: p.reason?.slice(0, 120) });
339
- }
340
- catch (err) {
341
- console.log(` ✦ Promotion failed: ${err}`);
342
- }
343
- }
344
- }
345
- catch (e) {
346
- console.warn(' [promotion] Failed to parse promotion JSON:', e);
347
- }
348
- }
349
- // Fallback: promote top candidate per dept if commander didn't produce valid JSON
350
- for (const dept of promotionDepts) {
351
- const hasLeader = kernel.getState().agents.some(c => c.promotion?.department === dept);
352
- if (!hasLeader) {
353
- const top = kernel.getCandidates(dept, 1)[0];
354
- if (top) {
355
- kernel.promoteAgent(top.core.id, dept, roleNames[dept] || `Head of ${dept}`, leader.name);
356
- console.log(` ✦ [fallback] ${top.core.name} → ${roleNames[dept]}`);
357
- }
358
- }
169
+ // If this is a terminal provider error, isAborted() is now true; the
170
+ // turn loop skips LLM work but continues so the end-of-run cleanup
171
+ // path runs and the user gets a proper `complete` SSE event.
359
172
  }
173
+ // Turn 0: commander promotes department heads from the kernel's
174
+ // candidate roster. Any dept the commander skips gets its top
175
+ // candidate promoted via fallback so turn 1 starts with a full cabinet.
176
+ await runDepartmentPromotions({
177
+ kernel,
178
+ scenario: sc,
179
+ leader,
180
+ startYear,
181
+ sendToCommander: (prompt) => cmdSess.send(prompt),
182
+ trackUsage,
183
+ emit,
184
+ });
360
185
  // Captured forge events keyed by department. Each `wrapForgeTool` push
361
186
  // here on every successful or failed forge; we drain the dept's bucket
362
187
  // around each `dept_done` emit to attribute forges to the right event.
@@ -1343,89 +1168,13 @@ Respond with valid JSON ONLY (no markdown, no prose outside the JSON):
1343
1168
  ? sc.hooks.fingerprintHook(final, outcomeLog, leader, toolRegs, maxTurns)
1344
1169
  : {};
1345
1170
  const fingerprint = { ...generic, ...scenarioOverlay };
1346
- // Build the canonical Forged Toolbox: deduplicate forge attempts by
1347
- // tool name, keep the first-forge metadata + accumulated reuse count.
1348
- // Includes the full input/output JSON Schemas pulled from the
1349
- // EmergentToolRegistry on first forge — the same data the dashboard's
1350
- // ToolboxSection renders, now available to programmatic consumers.
1351
- // Build the canonical Forged Toolbox from the forgedLedger (which has
1352
- // the authoritative history). allForges is the raw stream of judge
1353
- // calls; the ledger.history captures every USE (forge or reuse).
1354
- const forgedToolbox = (() => {
1355
- const out = [];
1356
- for (const [name, entry] of forgedLedger) {
1357
- // Find the original forge metadata (description, mode) from the
1358
- // first matching captured forge if available; fall back to history.
1359
- const firstForge = allForges.find(f => f.name === name);
1360
- const departments = new Set();
1361
- let approved = false;
1362
- let maxConfidence = 0;
1363
- let sampleOutput = null;
1364
- let reforgeCount = 0;
1365
- let rejectedReforges = 0;
1366
- for (const h of entry.history) {
1367
- departments.add(h.department);
1368
- if (!h.rejected)
1369
- approved = true;
1370
- if (typeof h.confidence === 'number' && h.confidence > maxConfidence)
1371
- maxConfidence = h.confidence;
1372
- if (h.output)
1373
- sampleOutput = h.output;
1374
- if (h.isReforge) {
1375
- reforgeCount++;
1376
- if (h.rejected)
1377
- rejectedReforges++;
1378
- }
1379
- }
1380
- const totalUses = entry.history.length;
1381
- out.push({
1382
- name,
1383
- description: firstForge?.description || name,
1384
- mode: firstForge?.mode || 'sandbox',
1385
- firstForgedTurn: entry.firstForgedTurn,
1386
- firstForgedDepartment: entry.firstForgedDepartment,
1387
- firstForgedEventTitle: entry.firstForgedEventTitle,
1388
- departments: [...departments],
1389
- reuseCount: Math.max(0, totalUses - 1),
1390
- totalUses,
1391
- reforgeCount,
1392
- rejectedReforges,
1393
- approved: approved || (firstForge?.approved ?? false),
1394
- // Use the highest judge confidence across this tool's invocations.
1395
- // If the tool was never approved (only failed forges), confidence
1396
- // is 0 — never fabricate an 0.85 default that misrepresents
1397
- // rejected tools as borderline-passable.
1398
- confidence: maxConfidence || (firstForge?.approved ? firstForge.confidence : 0),
1399
- inputSchema: entry.inputSchema,
1400
- outputSchema: entry.outputSchema,
1401
- sampleOutput,
1402
- history: entry.history.slice(),
1403
- });
1404
- }
1405
- return out.sort((a, b) => a.firstForgedTurn - b.firstForgedTurn);
1406
- })();
1407
- // Flat unique citation list across all department reports.
1408
- const citationCatalog = (() => {
1409
- const byKey = new Map();
1410
- for (const { turn: t, report } of allDepartmentReports) {
1411
- for (const c of report.citations) {
1412
- const key = (c.url || '').trim() || `text:${(c.text || '').trim()}`;
1413
- if (!key)
1414
- continue;
1415
- let entry = byKey.get(key);
1416
- if (!entry) {
1417
- entry = { text: c.text || c.url || '', url: c.url || '', doi: c.doi, departments: new Set(), turns: new Set() };
1418
- byKey.set(key, entry);
1419
- }
1420
- if (report.department)
1421
- entry.departments.add(report.department);
1422
- entry.turns.add(t);
1423
- if (!entry.doi && c.doi)
1424
- entry.doi = c.doi;
1425
- }
1426
- }
1427
- return [...byKey.values()].map(e => ({ ...e, departments: [...e.departments], turns: [...e.turns].sort((a, b) => a - b) }));
1428
- })();
1171
+ // Canonical Forged Toolbox: deduplicated by tool name with first-forge
1172
+ // metadata, full invocation history, and reuse/rejection rollups.
1173
+ // Matches the data the dashboard's ToolboxSection renders.
1174
+ const forgedToolbox = buildForgedToolbox(forgedLedger, allForges);
1175
+ // Flat unique citation list across all department reports. URL is the
1176
+ // dedup key; each entry carries the departments + turns that cited it.
1177
+ const citationCatalog = buildCitationCatalog(allDepartmentReports);
1429
1178
  const output = {
1430
1179
  simulation: `${sc.id}-v3`,
1431
1180
  leader: { name: leader.name, archetype: leader.archetype, colony: leader.colony, hexaco: leader.hexaco },
@@ -1451,7 +1200,7 @@ Respond with valid JSON ONLY (no markdown, no prose outside the JSON):
1451
1200
  /** Per-turn agent reactions with mood + quote + memory snippets. */
1452
1201
  agentReactions: allAgentReactions,
1453
1202
  /** Cost telemetry for the run. */
1454
- cost: { totalTokens, totalCostUSD: Math.round(totalCostUSD * 10000) / 10000, llmCalls },
1203
+ cost: costTracker.finalCost(),
1455
1204
  /**
1456
1205
  * When a terminal provider error (quota exhausted, invalid API key)
1457
1206
  * was hit during the run, this carries the classified reason. `null`