paracosm 0.7.409 → 0.7.431

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.
Files changed (146) hide show
  1. package/README.md +79 -14
  2. package/dist/cli/compile.d.ts +1 -1
  3. package/dist/cli/compile.js +4 -4
  4. package/dist/cli/fetch-seed-url.d.ts +48 -0
  5. package/dist/cli/fetch-seed-url.d.ts.map +1 -0
  6. package/dist/cli/fetch-seed-url.js +109 -0
  7. package/dist/cli/fetch-seed-url.js.map +1 -0
  8. package/dist/cli/fork-preconditions.d.ts +17 -0
  9. package/dist/cli/fork-preconditions.d.ts.map +1 -0
  10. package/dist/cli/fork-preconditions.js +51 -0
  11. package/dist/cli/fork-preconditions.js.map +1 -0
  12. package/dist/cli/init-templates.d.ts +43 -0
  13. package/dist/cli/init-templates.d.ts.map +1 -0
  14. package/dist/cli/init-templates.js +141 -0
  15. package/dist/cli/init-templates.js.map +1 -0
  16. package/dist/cli/init.d.ts +46 -0
  17. package/dist/cli/init.d.ts.map +1 -0
  18. package/dist/cli/init.js +207 -0
  19. package/dist/cli/init.js.map +1 -0
  20. package/dist/cli/pair-runner.d.ts +25 -2
  21. package/dist/cli/pair-runner.d.ts.map +1 -1
  22. package/dist/cli/pair-runner.js +141 -5
  23. package/dist/cli/pair-runner.js.map +1 -1
  24. package/dist/cli/quickstart-routes.d.ts +35 -0
  25. package/dist/cli/quickstart-routes.d.ts.map +1 -0
  26. package/dist/cli/quickstart-routes.js +100 -0
  27. package/dist/cli/quickstart-routes.js.map +1 -0
  28. package/dist/cli/run.js +8 -0
  29. package/dist/cli/run.js.map +1 -1
  30. package/dist/cli/server/enrich-run-record.d.ts +22 -0
  31. package/dist/cli/server/enrich-run-record.d.ts.map +1 -0
  32. package/dist/cli/server/enrich-run-record.js +27 -0
  33. package/dist/cli/server/enrich-run-record.js.map +1 -0
  34. package/dist/cli/server/routes/platform-api.d.ts +19 -3
  35. package/dist/cli/server/routes/platform-api.d.ts.map +1 -1
  36. package/dist/cli/server/routes/platform-api.js +174 -5
  37. package/dist/cli/server/routes/platform-api.js.map +1 -1
  38. package/dist/cli/server/run-history-store.d.ts +27 -1
  39. package/dist/cli/server/run-history-store.d.ts.map +1 -1
  40. package/dist/cli/server/run-history-store.js +5 -0
  41. package/dist/cli/server/run-history-store.js.map +1 -1
  42. package/dist/cli/server/run-record.d.ts +13 -0
  43. package/dist/cli/server/run-record.d.ts.map +1 -1
  44. package/dist/cli/server/run-record.js.map +1 -1
  45. package/dist/cli/server/sqlite-run-history-store.d.ts +6 -0
  46. package/dist/cli/server/sqlite-run-history-store.d.ts.map +1 -0
  47. package/dist/cli/server/sqlite-run-history-store.js +226 -0
  48. package/dist/cli/server/sqlite-run-history-store.js.map +1 -0
  49. package/dist/cli/server-app.d.ts +73 -2
  50. package/dist/cli/server-app.d.ts.map +1 -1
  51. package/dist/cli/server-app.js +409 -159
  52. package/dist/cli/server-app.js.map +1 -1
  53. package/dist/cli/session-title.js +1 -1
  54. package/dist/cli/sim-config.d.ts +13 -0
  55. package/dist/cli/sim-config.d.ts.map +1 -1
  56. package/dist/cli/sim-config.js +8 -1
  57. package/dist/cli/sim-config.js.map +1 -1
  58. package/dist/cli/simulate-route.d.ts +103 -0
  59. package/dist/cli/simulate-route.d.ts.map +1 -0
  60. package/dist/cli/simulate-route.js +95 -0
  61. package/dist/cli/simulate-route.js.map +1 -0
  62. package/dist/engine/compiler/cache.d.ts +2 -2
  63. package/dist/engine/compiler/cache.js +2 -2
  64. package/dist/engine/compiler/compile-from-seed.d.ts +78 -0
  65. package/dist/engine/compiler/compile-from-seed.d.ts.map +1 -0
  66. package/dist/engine/compiler/compile-from-seed.js +93 -0
  67. package/dist/engine/compiler/compile-from-seed.js.map +1 -0
  68. package/dist/engine/compiler/generate-fingerprint.d.ts.map +1 -1
  69. package/dist/engine/compiler/generate-fingerprint.js +3 -6
  70. package/dist/engine/compiler/generate-fingerprint.js.map +1 -1
  71. package/dist/engine/compiler/generate-politics.d.ts.map +1 -1
  72. package/dist/engine/compiler/generate-politics.js +3 -6
  73. package/dist/engine/compiler/generate-politics.js.map +1 -1
  74. package/dist/engine/compiler/generate-progression.d.ts +10 -1
  75. package/dist/engine/compiler/generate-progression.d.ts.map +1 -1
  76. package/dist/engine/compiler/generate-progression.js +15 -15
  77. package/dist/engine/compiler/generate-progression.js.map +1 -1
  78. package/dist/engine/compiler/generate-prompts.d.ts.map +1 -1
  79. package/dist/engine/compiler/generate-prompts.js +4 -7
  80. package/dist/engine/compiler/generate-prompts.js.map +1 -1
  81. package/dist/engine/compiler/generate-reactions.d.ts.map +1 -1
  82. package/dist/engine/compiler/generate-reactions.js +3 -6
  83. package/dist/engine/compiler/generate-reactions.js.map +1 -1
  84. package/dist/engine/compiler/index.d.ts +9 -6
  85. package/dist/engine/compiler/index.d.ts.map +1 -1
  86. package/dist/engine/compiler/index.js +9 -6
  87. package/dist/engine/compiler/index.js.map +1 -1
  88. package/dist/engine/compiler/sandbox-runner.d.ts +40 -0
  89. package/dist/engine/compiler/sandbox-runner.d.ts.map +1 -0
  90. package/dist/engine/compiler/sandbox-runner.js +278 -0
  91. package/dist/engine/compiler/sandbox-runner.js.map +1 -0
  92. package/dist/engine/compiler/scenario-fixture.d.ts +4 -4
  93. package/dist/engine/compiler/scenario-fixture.js +4 -4
  94. package/dist/engine/compiler/seed-ingestion.d.ts +2 -2
  95. package/dist/engine/compiler/seed-ingestion.js +2 -2
  96. package/dist/engine/compiler/state-shape-block.d.ts +1 -1
  97. package/dist/engine/compiler/state-shape-block.js +5 -5
  98. package/dist/engine/compiler/types.d.ts +2 -2
  99. package/dist/engine/compiler/types.d.ts.map +1 -1
  100. package/dist/engine/compiler/validate.js +1 -1
  101. package/dist/engine/core/kernel.d.ts +4 -4
  102. package/dist/engine/core/kernel.js +6 -6
  103. package/dist/engine/core/progression.d.ts +3 -3
  104. package/dist/engine/core/progression.js +21 -21
  105. package/dist/engine/core/state.d.ts +7 -5
  106. package/dist/engine/core/state.d.ts.map +1 -1
  107. package/dist/engine/digital-twin/index.d.ts +41 -0
  108. package/dist/engine/digital-twin/index.d.ts.map +1 -0
  109. package/dist/engine/digital-twin/index.js +40 -0
  110. package/dist/engine/digital-twin/index.js.map +1 -0
  111. package/dist/engine/index.d.ts +1 -1
  112. package/dist/engine/leader-presets.d.ts +39 -0
  113. package/dist/engine/leader-presets.d.ts.map +1 -0
  114. package/dist/engine/leader-presets.js +118 -0
  115. package/dist/engine/leader-presets.js.map +1 -0
  116. package/dist/engine/lunar/fingerprint.js +2 -2
  117. package/dist/engine/lunar/prompts.js +2 -2
  118. package/dist/engine/mars/fingerprint.js +1 -1
  119. package/dist/engine/mars/metrics.d.ts +2 -2
  120. package/dist/engine/mars/metrics.js +2 -2
  121. package/dist/engine/mars/prompts.js +3 -3
  122. package/dist/runtime/build-artifact.d.ts +2 -1
  123. package/dist/runtime/build-artifact.d.ts.map +1 -1
  124. package/dist/runtime/build-artifact.js +2 -1
  125. package/dist/runtime/build-artifact.js.map +1 -1
  126. package/dist/runtime/canonical-json.d.ts +25 -0
  127. package/dist/runtime/canonical-json.d.ts.map +1 -0
  128. package/dist/runtime/canonical-json.js +46 -0
  129. package/dist/runtime/canonical-json.js.map +1 -0
  130. package/dist/runtime/contracts.d.ts +1 -1
  131. package/dist/runtime/departments.js +1 -1
  132. package/dist/runtime/orchestrator.d.ts +44 -4
  133. package/dist/runtime/orchestrator.d.ts.map +1 -1
  134. package/dist/runtime/orchestrator.js +125 -33
  135. package/dist/runtime/orchestrator.js.map +1 -1
  136. package/dist/runtime/parsers.js +5 -5
  137. package/dist/runtime/reaction-step.js +2 -2
  138. package/dist/runtime/world-model/index.d.ts +172 -18
  139. package/dist/runtime/world-model/index.d.ts.map +1 -1
  140. package/dist/runtime/world-model/index.js +242 -4
  141. package/dist/runtime/world-model/index.js.map +1 -1
  142. package/dist/runtime/world-snapshot.d.ts +13 -0
  143. package/dist/runtime/world-snapshot.d.ts.map +1 -0
  144. package/dist/runtime/world-snapshot.js +19 -0
  145. package/dist/runtime/world-snapshot.js.map +1 -0
  146. package/package.json +19 -4
@@ -3,7 +3,10 @@ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync, unlinkS
3
3
  import { resolve, dirname } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { normalizeSimulationConfig, applyDemoCaps } from './sim-config.js';
6
- import { runPairSimulations, runForkSimulation } from './pair-runner.js';
6
+ import { runPairSimulations, runForkSimulation, runBatchSimulations } from './pair-runner.js';
7
+ import { handleFetchSeed, handleCompileFromSeed, handleGenerateLeaders, } from './quickstart-routes.js';
8
+ import { handleSimulate } from './simulate-route.js';
9
+ import { compileScenario as compileScenarioReal } from '../engine/compiler/index.js';
7
10
  import { marsScenario } from '../engine/mars/index.js';
8
11
  import { lunarScenario } from '../engine/lunar/index.js';
9
12
  import { describeCustomScenarioSource, isRunnableScenarioPackage, loadDiskCustomScenarios, } from './custom-scenarios.js';
@@ -14,9 +17,13 @@ import { openSessionStore } from './session-store.js';
14
17
  import { generateSessionTitle } from './session-title.js';
15
18
  import { resolveServerMode } from './server/server-mode.js';
16
19
  import { createRunRecord, hashLeaderConfig } from './server/run-record.js';
20
+ import { enrichRunRecordFromArtifact } from './server/enrich-run-record.js';
17
21
  import { createNoopRunHistoryStore } from './server/run-history-store.js';
22
+ import { createSqliteRunHistoryStore } from './server/sqlite-run-history-store.js';
18
23
  import { handlePublicDemoRoute } from './server/routes/public-demo.js';
19
24
  import { handlePlatformApiRoute } from './server/routes/platform-api.js';
25
+ import { validateForkSetupPreconditions } from './fork-preconditions.js';
26
+ import { fetchSeedFromUrl } from './fetch-seed-url.js';
20
27
  function projectScenarioForClient(sc) {
21
28
  return {
22
29
  id: sc.id,
@@ -58,14 +65,167 @@ const PARACOSM_VERSION = (() => {
58
65
  return 'unknown';
59
66
  }
60
67
  })();
61
- function readBody(req) {
68
+ class RequestBodyTooLargeError extends Error {
69
+ statusCode = 413;
70
+ constructor(maxBytes) {
71
+ super(`Request body too large. Maximum ${maxBytes} bytes.`);
72
+ }
73
+ }
74
+ function writeJsonError(res, error, fallbackStatus = 400) {
75
+ const status = error instanceof RequestBodyTooLargeError
76
+ ? error.statusCode
77
+ : fallbackStatus;
78
+ res.writeHead(status, { 'Content-Type': 'application/json' });
79
+ res.end(JSON.stringify({ error: String(error) }));
80
+ }
81
+ function readBody(req, maxBytes) {
62
82
  return new Promise((resolveBody, reject) => {
63
83
  let body = '';
64
- req.on('data', chunk => { body += chunk; });
65
- req.on('end', () => resolveBody(body));
66
- req.on('error', reject);
84
+ let receivedBytes = 0;
85
+ let settled = false;
86
+ let tooLarge = false;
87
+ const fail = (error) => {
88
+ if (settled)
89
+ return;
90
+ settled = true;
91
+ reject(error);
92
+ };
93
+ req.on('data', chunk => {
94
+ if (tooLarge)
95
+ return;
96
+ receivedBytes += Buffer.isBuffer(chunk)
97
+ ? chunk.length
98
+ : Buffer.byteLength(String(chunk));
99
+ if (receivedBytes > maxBytes) {
100
+ tooLarge = true;
101
+ body = '';
102
+ fail(new RequestBodyTooLargeError(maxBytes));
103
+ return;
104
+ }
105
+ body += chunk;
106
+ });
107
+ req.on('end', () => {
108
+ if (settled)
109
+ return;
110
+ settled = true;
111
+ resolveBody(body);
112
+ });
113
+ req.on('error', fail);
67
114
  });
68
115
  }
116
+ /**
117
+ * Resolve the production run-history store. SQLite by default at
118
+ * `${APP_DIR}/data/runs.db`; env override `PARACOSM_RUN_HISTORY_DB_PATH`.
119
+ * Set `PARACOSM_DISABLE_RUN_HISTORY=1` to fall back to the noop store
120
+ * (useful for ephemeral test environments and CLI smoke tests).
121
+ */
122
+ function resolveRunHistoryStore(env) {
123
+ if (env.PARACOSM_DISABLE_RUN_HISTORY === '1') {
124
+ return createNoopRunHistoryStore();
125
+ }
126
+ const dbPath = env.PARACOSM_RUN_HISTORY_DB_PATH
127
+ ?? resolve(env.APP_DIR || '.', 'data', 'runs.db');
128
+ return createSqliteRunHistoryStore({ dbPath });
129
+ }
130
+ export function buildResultsPayloadFromEventBuffer(eventBuffer) {
131
+ const simEvents = eventBuffer
132
+ .filter(msg => msg.startsWith('event: sim\n') || msg.startsWith('event: result\n') || msg.startsWith('event: verdict\n') || msg.startsWith('event: complete\n'))
133
+ .map(msg => {
134
+ const lines = msg.split('\n');
135
+ const eventType = lines[0]?.replace('event: ', '') || '';
136
+ try {
137
+ return { event: eventType, data: JSON.parse(lines[1]?.replace('data: ', '') || '{}') };
138
+ }
139
+ catch {
140
+ return { event: eventType, data: {} };
141
+ }
142
+ });
143
+ const results = simEvents.filter(e => e.event === 'result').map(e => e.data);
144
+ const verdict = simEvents.find(e => e.event === 'verdict')?.data || null;
145
+ const isComplete = simEvents.some(e => e.event === 'complete');
146
+ const turns = simEvents.filter(e => e.event === 'sim' && e.data?.type === 'turn_start').length / 2;
147
+ // Reconstruct per-leader timelines from the sim event stream.
148
+ // Group every sim event by leader name, then bucket interesting
149
+ // payload types into typed lists so consumers can pull turn-by-turn
150
+ // crisis info, dept reports, decisions, forges, citations, reactions.
151
+ const byLeader = new Map();
152
+ const ensureLeader = (name) => {
153
+ if (!byLeader.has(name))
154
+ byLeader.set(name, { events: [], decisions: [], forges: [], citations: [], deptReports: [], agentReactions: [], promotions: [], systemsSnapshots: [] });
155
+ return byLeader.get(name);
156
+ };
157
+ // Track decision pending state so we can attach it to outcomes per event.
158
+ const pendingDecision = new Map();
159
+ for (const e of simEvents) {
160
+ if (e.event !== 'sim')
161
+ continue;
162
+ const inner = e.data;
163
+ const type = String(inner.type || '');
164
+ const leader = String(inner.leader || '');
165
+ if (!leader)
166
+ continue;
167
+ const slot = ensureLeader(leader);
168
+ const data = inner.data ?? {};
169
+ const turn = data.turn;
170
+ const time = data.time;
171
+ const eventIndex = data.eventIndex;
172
+ const pendKey = `${leader}-${turn}-${eventIndex ?? 0}`;
173
+ if (type === 'event_start') {
174
+ slot.events.push({ turn, time, eventIndex, title: data.title, category: data.category, description: data.description, emergent: data.emergent });
175
+ }
176
+ else if (type === 'turn_start' && data.title && data.title !== 'Director generating...') {
177
+ slot.events.push({ turn, time, title: data.title, category: data.category, description: data.crisis, emergent: data.emergent });
178
+ }
179
+ else if (type === 'decision_made') {
180
+ pendingDecision.set(pendKey, {
181
+ decision: data.decision,
182
+ rationale: data.rationale,
183
+ selectedPolicies: data.selectedPolicies,
184
+ });
185
+ }
186
+ else if (type === 'outcome') {
187
+ const p = pendingDecision.get(pendKey);
188
+ slot.decisions.push({ turn, time, eventIndex, ...p, outcome: data.outcome });
189
+ pendingDecision.delete(pendKey);
190
+ }
191
+ else if (type === 'specialist_done') {
192
+ const dept = data.department;
193
+ const cites = data.citationList || [];
194
+ slot.deptReports.push({
195
+ turn, time, eventIndex, department: dept,
196
+ summary: data.summary,
197
+ risks: data.risks,
198
+ recommendedActions: data.recommendedActions,
199
+ citations: cites.length,
200
+ toolCount: Array.isArray(data.forgedTools) ? data.forgedTools.length : 0,
201
+ });
202
+ for (const c of cites) {
203
+ slot.citations.push({ ...c, department: dept, turn });
204
+ }
205
+ }
206
+ else if (type === 'forge_attempt') {
207
+ slot.forges.push({ turn, time, eventIndex, ...data });
208
+ }
209
+ else if (type === 'agent_reactions') {
210
+ slot.agentReactions.push({ turn, time, reactions: data.reactions, totalReactions: data.totalReactions });
211
+ }
212
+ else if (type === 'promotion') {
213
+ slot.promotions.push({ ...data });
214
+ }
215
+ else if (type === 'systems_snapshot') {
216
+ slot.systemsSnapshots.push({ turn, time, ...data });
217
+ }
218
+ }
219
+ const leaders = [...byLeader.entries()].map(([name, slot]) => ({ name, ...slot }));
220
+ return {
221
+ results,
222
+ verdict,
223
+ isComplete,
224
+ turnsCompleted: Math.floor(turns),
225
+ totalEvents: simEvents.length,
226
+ leaders,
227
+ };
228
+ }
69
229
  export function createMarsServer(options = {}) {
70
230
  const env = options.env ?? process.env;
71
231
  const serverMode = resolveServerMode(env);
@@ -77,6 +237,11 @@ export function createMarsServer(options = {}) {
77
237
  // env var or maxSimsPerDay option when hosting on your own infra.
78
238
  const maxSims = options.maxSimsPerDay ?? parseInt(env.RATE_LIMIT || '1', 10);
79
239
  const adminWrite = (env.ADMIN_WRITE || 'false').toLowerCase() === 'true';
240
+ const parsedMaxRequestBodyBytes = Number.parseInt(env.MAX_REQUEST_BODY_BYTES || '', 10);
241
+ const defaultMaxRequestBodyBytes = Number.isFinite(parsedMaxRequestBodyBytes)
242
+ ? parsedMaxRequestBodyBytes
243
+ : 5 * 1024 * 1024;
244
+ const maxRequestBodyBytes = options.maxRequestBodyBytes ?? defaultMaxRequestBodyBytes;
80
245
  const scenarioDir = options.scenarioDir ?? resolve(__dirname, '..', '..', 'scenarios');
81
246
  // Rate-limit state survives pm2 restarts via a JSON file alongside
82
247
  // the repo. Without this, a restart gives every blocked IP a full
@@ -197,7 +362,7 @@ export function createMarsServer(options = {}) {
197
362
  // routes just return 503.
198
363
  console.log(` [sessions] Failed to open session store: ${err}`);
199
364
  }
200
- const runHistoryStore = options.runHistoryStore ?? createNoopRunHistoryStore();
365
+ const runHistoryStore = options.runHistoryStore ?? resolveRunHistoryStore(env);
201
366
  // Coalesce disk writes so a burst of broadcasts (e.g. 50 forge_attempt
202
367
  // events during a turn) only triggers one persist call. 500ms debounce
203
368
  // is short enough that a crash loses at most a half-second of events
@@ -563,7 +728,17 @@ export function createMarsServer(options = {}) {
563
728
  if (handlePublicDemoRoute(serverMode, req, res, corsHeaders)) {
564
729
  return;
565
730
  }
566
- if (await handlePlatformApiRoute(serverMode, req, res, { runHistoryStore, corsHeaders })) {
731
+ const runHistoryRoutesDefault = serverMode !== 'hosted_demo';
732
+ const runHistoryEnvFlag = (env.PARACOSM_ENABLE_RUN_HISTORY_ROUTES ?? '').toLowerCase();
733
+ const paracosmRoutesEnabled = runHistoryEnvFlag === 'true' ? true :
734
+ runHistoryEnvFlag === 'false' ? false :
735
+ runHistoryRoutesDefault;
736
+ if (await handlePlatformApiRoute(req, res, {
737
+ runHistoryStore,
738
+ corsHeaders,
739
+ paracosmRoutesEnabled,
740
+ scenarioLookup: (id) => customScenarioCatalog.get(id)?.scenario,
741
+ })) {
567
742
  return;
568
743
  }
569
744
  if (req.url === '/events') {
@@ -681,7 +856,7 @@ export function createMarsServer(options = {}) {
681
856
  // Store a scenario in memory (always allowed) or save to disk (requires ADMIN_WRITE)
682
857
  if (req.url === '/scenario/store' && req.method === 'POST') {
683
858
  try {
684
- const { scenario: scenarioJson, saveToDisk } = JSON.parse(await readBody(req));
859
+ const { scenario: scenarioJson, saveToDisk } = JSON.parse(await readBody(req, maxRequestBodyBytes));
685
860
  if (!scenarioJson || !scenarioJson.id) {
686
861
  res.writeHead(400, { 'Content-Type': 'application/json' });
687
862
  res.end(JSON.stringify({ error: 'scenario with id required' }));
@@ -714,8 +889,7 @@ export function createMarsServer(options = {}) {
714
889
  res.end(JSON.stringify({ stored: true, id: scenarioJson.id, savedToDisk, adminWrite, switchable }));
715
890
  }
716
891
  catch (err) {
717
- res.writeHead(400, { 'Content-Type': 'application/json' });
718
- res.end(JSON.stringify({ error: String(err) }));
892
+ writeJsonError(res, err);
719
893
  }
720
894
  return;
721
895
  }
@@ -726,35 +900,166 @@ export function createMarsServer(options = {}) {
726
900
  // were /scenario/store'd but never compiled get a specific
727
901
  // "needs compile" error instead of the misleading "Unknown".
728
902
  if (req.url === '/scenario/switch' && req.method === 'POST') {
729
- const { id } = JSON.parse(await readBody(req));
730
- const entry = customScenarioCatalog.get(id);
731
- if (entry) {
732
- activeScenario = entry.scenario;
903
+ try {
904
+ const { id } = JSON.parse(await readBody(req, maxRequestBodyBytes));
905
+ const entry = customScenarioCatalog.get(id);
906
+ if (entry) {
907
+ activeScenario = entry.scenario;
908
+ }
909
+ else if (memoryScenarios.has(id)) {
910
+ res.writeHead(400, { 'Content-Type': 'application/json' });
911
+ res.end(JSON.stringify({
912
+ error: `Scenario "${id}" is stored but not runnable — it's a source JSON (missing hooks, world, or canonical policies shape). Click Compile in the Scenario Editor to generate hooks before switching to it.`,
913
+ storedButUnrunnable: true,
914
+ id,
915
+ }));
916
+ return;
917
+ }
918
+ else {
919
+ res.writeHead(400, { 'Content-Type': 'application/json' });
920
+ res.end(JSON.stringify({ error: `Unknown scenario: ${id}. Use /compile or /scenario/store for custom scenarios.` }));
921
+ return;
922
+ }
923
+ clearEventBuffer();
924
+ simConfig = null;
925
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
926
+ res.end(JSON.stringify({ active: activeScenario.id, name: activeScenario.labels?.name }));
733
927
  }
734
- else if (memoryScenarios.has(id)) {
735
- res.writeHead(400, { 'Content-Type': 'application/json' });
736
- res.end(JSON.stringify({
737
- error: `Scenario "${id}" is stored but not runnable — it's a source JSON (missing hooks, world, or canonical policies shape). Click Compile in the Scenario Editor to generate hooks before switching to it.`,
738
- storedButUnrunnable: true,
739
- id,
740
- }));
741
- return;
928
+ catch (err) {
929
+ writeJsonError(res, err);
742
930
  }
743
- else {
744
- res.writeHead(400, { 'Content-Type': 'application/json' });
745
- res.end(JSON.stringify({ error: `Unknown scenario: ${id}. Use /compile or /scenario/store for custom scenarios.` }));
746
- return;
931
+ return;
932
+ }
933
+ // Tier 5 Quickstart onboarding endpoints.
934
+ if (req.url?.startsWith('/api/quickstart/') && req.method === 'POST') {
935
+ try {
936
+ const body = JSON.parse(await readBody(req, maxRequestBodyBytes));
937
+ const quickstartDeps = {
938
+ setActiveScenario: (sc) => {
939
+ activeScenario = sc;
940
+ // Quickstart-compiled scenarios reuse the 'compiled' source
941
+ // tag; same post-compile semantics, no new source state
942
+ // required.
943
+ customScenarioCatalog.set(sc.id, { scenario: sc, source: 'compiled' });
944
+ },
945
+ getScenarioById: (id) => {
946
+ if (id === activeScenario.id)
947
+ return activeScenario;
948
+ return customScenarioCatalog.get(id)?.scenario;
949
+ },
950
+ fetchSeedFromUrl,
951
+ defaultProvider: 'anthropic',
952
+ defaultModel: 'claude-sonnet-4-6',
953
+ };
954
+ if (req.url === '/api/quickstart/fetch-seed') {
955
+ await handleFetchSeed(req, res, body, quickstartDeps);
956
+ return;
957
+ }
958
+ if (req.url === '/api/quickstart/compile-from-seed') {
959
+ await handleCompileFromSeed(req, res, body, quickstartDeps);
960
+ return;
961
+ }
962
+ if (req.url === '/api/quickstart/generate-leaders') {
963
+ await handleGenerateLeaders(req, res, body, quickstartDeps);
964
+ return;
965
+ }
966
+ res.writeHead(404, { 'Content-Type': 'application/json' });
967
+ res.end(JSON.stringify({ error: `Unknown quickstart route: ${req.url}` }));
968
+ }
969
+ catch (err) {
970
+ writeJsonError(res, err);
971
+ }
972
+ return;
973
+ }
974
+ // Tier 4 T4.2: POST /simulate one-shot HTTP endpoint. Gated behind
975
+ // `PARACOSM_ENABLE_SIMULATE_ENDPOINT=true` so the hosted demo's
976
+ // SSE path stays the default; self-hosted deployments flip the
977
+ // flag on to expose the sync request-response surface.
978
+ if (req.url === '/simulate' &&
979
+ req.method === 'POST' &&
980
+ (env.PARACOSM_ENABLE_SIMULATE_ENDPOINT || '').toLowerCase() === 'true') {
981
+ try {
982
+ const body = JSON.parse(await readBody(req, maxRequestBodyBytes));
983
+ const userApiKey = typeof req.headers['x-api-key'] === 'string' ? req.headers['x-api-key'] : undefined;
984
+ const userAnthropicKey = typeof req.headers['x-anthropic-key'] === 'string' ? req.headers['x-anthropic-key'] : undefined;
985
+ const hasUserKeys = !!(userApiKey || userAnthropicKey);
986
+ // Rate limit with BYO-key bypass, matching the /setup pattern.
987
+ // `.check()` alone is advisory; `.record()` consumes the slot.
988
+ if (rateLimiter && !hasUserKeys) {
989
+ const ip = IpRateLimiter.getIp(req);
990
+ const { allowed, remaining, limit } = rateLimiter.check(ip);
991
+ if (!allowed) {
992
+ res.writeHead(429, {
993
+ ...corsHeaders,
994
+ 'Content-Type': 'application/json',
995
+ 'Retry-After': '86400',
996
+ 'X-RateLimit-Limit': String(limit),
997
+ 'X-RateLimit-Remaining': '0',
998
+ });
999
+ res.end(JSON.stringify({
1000
+ error: `Rate limit exceeded. Maximum ${limit} simulations per day. Set X-API-Key or X-Anthropic-Key to bypass.`,
1001
+ limit,
1002
+ remaining: 0,
1003
+ }));
1004
+ return;
1005
+ }
1006
+ rateLimiter.record(ip);
1007
+ console.log(` [rate-limit] /simulate ${ip}: ${remaining - 1} remaining of ${limit}`);
1008
+ }
1009
+ else if (hasUserKeys) {
1010
+ console.log(` [rate-limit] /simulate bypassed. BYO key present.`);
1011
+ }
1012
+ // Scope user-supplied API keys into the env for the duration of
1013
+ // the simulate call. The orchestrator's downstream LLM providers
1014
+ // read `process.env.OPENAI_API_KEY` / `ANTHROPIC_API_KEY`; passing
1015
+ // the keys through `RunOptions` does nothing because RunOptions
1016
+ // does not declare those fields. Mirrors the inline `scopeKey`
1017
+ // pattern used at the /setup handler around line 1732.
1018
+ const simulateEnvSnapshot = [];
1019
+ const scopeSimulateKey = (envName, userValue) => {
1020
+ if (!userValue || userValue.includes('...'))
1021
+ return;
1022
+ simulateEnvSnapshot.push([envName, env[envName]]);
1023
+ env[envName] = userValue;
1024
+ };
1025
+ scopeSimulateKey('OPENAI_API_KEY', userApiKey);
1026
+ scopeSimulateKey('ANTHROPIC_API_KEY', userAnthropicKey);
1027
+ const restoreSimulateEnv = () => {
1028
+ for (const [name, prior] of simulateEnvSnapshot) {
1029
+ if (prior === undefined)
1030
+ delete env[name];
1031
+ else
1032
+ env[name] = prior;
1033
+ }
1034
+ };
1035
+ const deps = {
1036
+ compileScenario: (raw, opts) => {
1037
+ const userCompile = options.compileScenario;
1038
+ if (userCompile)
1039
+ return userCompile(raw, opts);
1040
+ return compileScenarioReal(raw, opts);
1041
+ },
1042
+ runSimulation: async (leader, keyPersonnel, runOpts) => {
1043
+ const { runSimulation } = await import('../runtime/orchestrator.js');
1044
+ return runSimulation(leader, keyPersonnel, runOpts);
1045
+ },
1046
+ };
1047
+ try {
1048
+ await handleSimulate(req, res, body, deps);
1049
+ }
1050
+ finally {
1051
+ restoreSimulateEnv();
1052
+ }
1053
+ }
1054
+ catch (err) {
1055
+ writeJsonError(res, err);
747
1056
  }
748
- clearEventBuffer();
749
- simConfig = null;
750
- res.writeHead(200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
751
- res.end(JSON.stringify({ active: activeScenario.id, name: activeScenario.labels?.name }));
752
1057
  return;
753
1058
  }
754
1059
  // Compile a custom scenario JSON into a ScenarioPackage
755
1060
  if (req.url === '/compile' && req.method === 'POST') {
756
1061
  try {
757
- const body = JSON.parse(await readBody(req));
1062
+ const body = JSON.parse(await readBody(req, maxRequestBodyBytes));
758
1063
  const { scenario: scenarioJson, provider: requestedProvider, model: requestedModel, seedText, seedUrl, webSearch, maxSearches, apiKey, anthropicKey } = body;
759
1064
  if (!scenarioJson || typeof scenarioJson !== 'object') {
760
1065
  res.writeHead(400, { 'Content-Type': 'application/json' });
@@ -893,8 +1198,7 @@ export function createMarsServer(options = {}) {
893
1198
  }
894
1199
  catch (err) {
895
1200
  if (!res.headersSent) {
896
- res.writeHead(500, { 'Content-Type': 'application/json' });
897
- res.end(JSON.stringify({ error: String(err) }));
1201
+ writeJsonError(res, err, 500);
898
1202
  }
899
1203
  else {
900
1204
  res.write(`event: error\ndata: ${JSON.stringify({ error: String(err) })}\n\n`);
@@ -934,7 +1238,7 @@ export function createMarsServer(options = {}) {
934
1238
  }
935
1239
  };
936
1240
  try {
937
- const { agentId, message, apiKey, anthropicKey } = JSON.parse(await readBody(req));
1241
+ const { agentId, message, apiKey, anthropicKey } = JSON.parse(await readBody(req, maxRequestBodyBytes));
938
1242
  if (!agentId || !message) {
939
1243
  res.writeHead(400, { 'Content-Type': 'application/json' });
940
1244
  res.end(JSON.stringify({ error: 'agentId and message required' }));
@@ -1069,8 +1373,7 @@ export function createMarsServer(options = {}) {
1069
1373
  restoreChatEnv();
1070
1374
  }
1071
1375
  catch { }
1072
- res.writeHead(500, { 'Content-Type': 'application/json' });
1073
- res.end(JSON.stringify({ error: String(err) }));
1376
+ writeJsonError(res, err, 500);
1074
1377
  }
1075
1378
  return;
1076
1379
  }
@@ -1079,105 +1382,8 @@ export function createMarsServer(options = {}) {
1079
1382
  // get the same rich data the dashboard sees, without having to scrape
1080
1383
  // raw events themselves.
1081
1384
  if (req.url === '/results' && req.method === 'GET') {
1082
- const simEvents = eventBuffer
1083
- .filter(msg => msg.startsWith('event: sim\n') || msg.startsWith('event: result\n') || msg.startsWith('event: verdict\n') || msg.startsWith('event: complete\n'))
1084
- .map(msg => {
1085
- const lines = msg.split('\n');
1086
- const eventType = lines[0]?.replace('event: ', '') || '';
1087
- try {
1088
- return { event: eventType, data: JSON.parse(lines[1]?.replace('data: ', '') || '{}') };
1089
- }
1090
- catch {
1091
- return { event: eventType, data: {} };
1092
- }
1093
- });
1094
- const results = simEvents.filter(e => e.event === 'result').map(e => e.data);
1095
- const verdict = simEvents.find(e => e.event === 'verdict')?.data || null;
1096
- const isComplete = simEvents.some(e => e.event === 'complete');
1097
- const turns = simEvents.filter(e => e.event === 'sim' && e.data?.type === 'turn_start').length / 2;
1098
- // Reconstruct per-leader timelines from the sim event stream.
1099
- // Group every sim event by leader name, then bucket interesting
1100
- // payload types into typed lists so consumers can pull turn-by-turn
1101
- // crisis info, dept reports, decisions, forges, citations, reactions.
1102
- const byLeader = new Map();
1103
- const ensureLeader = (name) => {
1104
- if (!byLeader.has(name))
1105
- byLeader.set(name, { events: [], decisions: [], forges: [], citations: [], deptReports: [], agentReactions: [], promotions: [], systemsSnapshots: [] });
1106
- return byLeader.get(name);
1107
- };
1108
- // Track decision pending state so we can attach it to outcomes per event
1109
- const pendingDecision = new Map();
1110
- for (const e of simEvents) {
1111
- if (e.event !== 'sim')
1112
- continue;
1113
- const inner = e.data;
1114
- const type = String(inner.type || '');
1115
- const leader = String(inner.leader || '');
1116
- if (!leader)
1117
- continue;
1118
- const slot = ensureLeader(leader);
1119
- const data = inner.data ?? {};
1120
- const turn = data.turn;
1121
- const time = data.time;
1122
- const eventIndex = data.eventIndex;
1123
- const pendKey = `${leader}-${turn}-${eventIndex ?? 0}`;
1124
- if (type === 'event_start') {
1125
- slot.events.push({ turn, time, eventIndex, title: data.title, category: data.category, description: data.description, emergent: data.emergent });
1126
- }
1127
- else if (type === 'turn_start' && data.title && data.title !== 'Director generating...') {
1128
- slot.events.push({ turn, time, title: data.title, category: data.category, description: data.crisis, emergent: data.emergent });
1129
- }
1130
- else if (type === 'commander_decided') {
1131
- pendingDecision.set(pendKey, {
1132
- decision: data.decision,
1133
- rationale: data.rationale,
1134
- selectedPolicies: data.selectedPolicies,
1135
- });
1136
- }
1137
- else if (type === 'outcome') {
1138
- const p = pendingDecision.get(pendKey);
1139
- slot.decisions.push({ turn, time, eventIndex, ...p, outcome: data.outcome });
1140
- pendingDecision.delete(pendKey);
1141
- }
1142
- else if (type === 'dept_done') {
1143
- const dept = data.department;
1144
- const cites = data.citationList || [];
1145
- slot.deptReports.push({
1146
- turn, time, eventIndex, department: dept,
1147
- summary: data.summary,
1148
- risks: data.risks,
1149
- recommendedActions: data.recommendedActions,
1150
- citations: cites.length,
1151
- toolCount: Array.isArray(data.forgedTools) ? data.forgedTools.length : 0,
1152
- });
1153
- for (const c of cites) {
1154
- slot.citations.push({ ...c, department: dept, turn });
1155
- }
1156
- }
1157
- else if (type === 'forge_attempt') {
1158
- slot.forges.push({ turn, time, eventIndex, ...data });
1159
- }
1160
- else if (type === 'agent_reactions') {
1161
- slot.agentReactions.push({ turn, time, reactions: data.reactions, totalReactions: data.totalReactions });
1162
- }
1163
- else if (type === 'promotion') {
1164
- slot.promotions.push({ ...data });
1165
- }
1166
- else if (type === 'systems_snapshot') {
1167
- slot.systemsSnapshots.push({ turn, time, ...data });
1168
- }
1169
- }
1170
- const leaders = [...byLeader.entries()].map(([name, slot]) => ({ name, ...slot }));
1171
1385
  res.writeHead(200, { ...corsHeaders, 'Content-Type': 'application/json' });
1172
- res.end(JSON.stringify({
1173
- results,
1174
- verdict,
1175
- isComplete,
1176
- turnsCompleted: Math.floor(turns),
1177
- totalEvents: simEvents.length,
1178
- // New: structured per-leader payloads built from the SSE buffer
1179
- leaders,
1180
- }));
1386
+ res.end(JSON.stringify(buildResultsPayloadFromEventBuffer(eventBuffer)));
1181
1387
  return;
1182
1388
  }
1183
1389
  if (req.url === '/rate-limit' && req.method === 'GET') {
@@ -1386,7 +1592,7 @@ export function createMarsServer(options = {}) {
1386
1592
  }
1387
1593
  if (req.url === '/setup' && req.method === 'POST') {
1388
1594
  try {
1389
- const config = JSON.parse(await readBody(req));
1595
+ const config = JSON.parse(await readBody(req, maxRequestBodyBytes));
1390
1596
  // Rate limit check: bypass when user provides their own API keys
1391
1597
  const hasUserKeys = !!(config.apiKey || config.anthropicKey);
1392
1598
  if (rateLimiter && !hasUserKeys) {
@@ -1433,22 +1639,22 @@ export function createMarsServer(options = {}) {
1433
1639
  // parent artifact before spinning up the orchestrator.
1434
1640
  if (simConfig.forkFrom) {
1435
1641
  const { parentArtifact } = simConfig.forkFrom;
1436
- const parentScenarioId = parentArtifact?.metadata?.scenario?.id;
1437
- if (parentScenarioId !== activeScenario.id) {
1438
- res.writeHead(400, { 'Content-Type': 'application/json' });
1439
- res.end(JSON.stringify({
1440
- error: `Fork parent scenario '${parentScenarioId}' does not match active scenario '${activeScenario.id}'. Cross-scenario forks are not supported.`,
1441
- }));
1442
- return;
1443
- }
1444
- const snapshots = parentArtifact?.scenarioExtensions?.kernelSnapshotsPerTurn;
1445
- if (!Array.isArray(snapshots) || snapshots.length === 0) {
1446
- res.writeHead(400, { 'Content-Type': 'application/json' });
1642
+ const forkTurn = simConfig.forkFrom.atTurn;
1643
+ const forkValidation = validateForkSetupPreconditions({
1644
+ parentArtifact,
1645
+ atTurn: forkTurn,
1646
+ activeScenarioId: activeScenario.id,
1647
+ activeRunInProgress: simRunning && !!activeSimAbortController,
1648
+ });
1649
+ if (!forkValidation.ok) {
1650
+ res.writeHead(forkValidation.statusCode, { 'Content-Type': 'application/json' });
1447
1651
  res.end(JSON.stringify({
1448
- error: 'Fork parent has no embedded kernel snapshots. Re-run the parent simulation with `captureSnapshots: true` to enable forking.',
1652
+ error: forkValidation.error,
1653
+ issues: forkValidation.issues,
1449
1654
  }));
1450
1655
  return;
1451
1656
  }
1657
+ simConfig.forkFrom.parentArtifact = forkValidation.parentArtifact;
1452
1658
  // Force captureSnapshots on for forked children so they are
1453
1659
  // themselves forkable.
1454
1660
  simConfig.captureSnapshots = true;
@@ -1465,6 +1671,13 @@ export function createMarsServer(options = {}) {
1465
1671
  console.log(` [demo-mode] Capped run: turns=${simConfig.turns} pop=${simConfig.initialPopulation} ` +
1466
1672
  `depts=${simConfig.activeDepartments.length} models=${simConfig.models.commander}`);
1467
1673
  }
1674
+ // Build a base RunRecord for the /setup response (line ~1830). The
1675
+ // record is NOT inserted at run-start; instead, server-app inserts
1676
+ // one enriched record per completed artifact via the onArtifact
1677
+ // callback wired into the runner functions below. That callback
1678
+ // captures artifact-derived fields (artifactPath, costUSD,
1679
+ // durationMs, mode, leaderName, leaderArchetype) which the Library
1680
+ // tab needs to render gallery cards and to load full artifacts.
1468
1681
  const runRecord = createRunRecord({
1469
1682
  scenarioId: activeScenario.id,
1470
1683
  scenarioVersion: activeScenario.version,
@@ -1477,12 +1690,32 @@ export function createMarsServer(options = {}) {
1477
1690
  sourceMode: serverMode,
1478
1691
  createdBy: hasUserKeys ? 'user' : 'anonymous',
1479
1692
  });
1480
- try {
1481
- await runHistoryStore.insertRun(runRecord);
1482
- }
1483
- catch (error) {
1484
- console.warn('[run-history] insert failed:', error);
1485
- }
1693
+ // Insert a per-artifact RunRecord at run-end: the runner fires
1694
+ // `onArtifact` for each completed leader, we enrich the base
1695
+ // record with artifact fields and use the artifact's own runId
1696
+ // so the Library tab links each card to a specific artifact.
1697
+ // Capture the resolved simConfig values now so the closure does
1698
+ // not depend on the (nullable) closure-level `simConfig` later.
1699
+ const persistTurns = simConfig.turns;
1700
+ const persistSeed = simConfig.seed;
1701
+ const onArtifactPersist = async (artifact, leader) => {
1702
+ try {
1703
+ const perArtifactBase = {
1704
+ ...runRecord,
1705
+ runId: artifact.metadata.runId,
1706
+ leaderConfigHash: hashLeaderConfig({
1707
+ leader,
1708
+ turns: persistTurns,
1709
+ seed: persistSeed,
1710
+ }),
1711
+ };
1712
+ const enriched = enrichRunRecordFromArtifact(perArtifactBase, artifact);
1713
+ await runHistoryStore.insertRun(enriched);
1714
+ }
1715
+ catch (error) {
1716
+ console.warn('[run-history] per-artifact insert failed:', error);
1717
+ }
1718
+ };
1486
1719
  // Key-scope safety: snapshot the env values we're about to mutate
1487
1720
  // so we can restore them when THIS sim ends. Without this, user A's
1488
1721
  // key persists in process.env after their sim completes; user B's
@@ -1546,7 +1779,7 @@ export function createMarsServer(options = {}) {
1546
1779
  // Start the sim and restore env when it finishes — prevents the
1547
1780
  // caller's keys from leaking into any subsequent /setup from a
1548
1781
  // different user that doesn't pass their own keys.
1549
- marsServer.startWithConfig(simConfig).finally(restoreEnv);
1782
+ marsServer.startWithConfig(simConfig, { onArtifact: onArtifactPersist }).finally(restoreEnv);
1550
1783
  res.writeHead(200, { 'Content-Type': 'application/json' });
1551
1784
  res.end(JSON.stringify({
1552
1785
  redirect: '/sim',
@@ -1560,8 +1793,7 @@ export function createMarsServer(options = {}) {
1560
1793
  }));
1561
1794
  }
1562
1795
  catch (error) {
1563
- res.writeHead(400, { 'Content-Type': 'application/json' });
1564
- res.end(JSON.stringify({ error: String(error) }));
1796
+ writeJsonError(res, error);
1565
1797
  }
1566
1798
  return;
1567
1799
  }
@@ -1581,6 +1813,19 @@ export function createMarsServer(options = {}) {
1581
1813
  return;
1582
1814
  }
1583
1815
  }
1816
+ // Serve demo videos (Remotion-rendered loops shown on the landing page).
1817
+ // Long cache: rendered output is content-addressable enough that the
1818
+ // landing's <video src> changes when content changes.
1819
+ if (req.url?.split('?')[0].startsWith('/demo/')) {
1820
+ const demoPath = resolve(__dirname, '..', '..', 'assets', 'demo', req.url.split('?')[0].replace('/demo/', ''));
1821
+ if (existsSync(demoPath)) {
1822
+ const ext = demoPath.split('.').pop() || '';
1823
+ const types = { mp4: 'video/mp4', webm: 'video/webm', png: 'image/png', jpg: 'image/jpeg' };
1824
+ res.writeHead(200, { 'Content-Type': types[ext] || 'application/octet-stream', 'Cache-Control': 'public, max-age=86400' });
1825
+ res.end(readFileSync(demoPath));
1826
+ return;
1827
+ }
1828
+ }
1584
1829
  if (req.url === '/favicon.svg' || req.url === '/favicon.png' || req.url === '/favicon.ico' || req.url === '/icon.svg' || req.url === '/apple-touch-icon.png') {
1585
1830
  try {
1586
1831
  const assetsDir = resolve(__dirname, '..', '..', 'assets');
@@ -1843,7 +2088,7 @@ export function createMarsServer(options = {}) {
1843
2088
  server.on('close', () => { if (rateLimiter)
1844
2089
  rateLimiter.destroy(); });
1845
2090
  const marsServer = Object.assign(server, {
1846
- async startWithConfig(config) {
2091
+ async startWithConfig(config, hooks) {
1847
2092
  // Clear previous run data
1848
2093
  clearEventBuffer();
1849
2094
  simConfig = config;
@@ -1864,10 +2109,15 @@ export function createMarsServer(options = {}) {
1864
2109
  // runForkSimulation instead of the pair runner when the
1865
2110
  // config carries a forkFrom reference.
1866
2111
  if (config.forkFrom) {
1867
- await runForkSimulation(config, broadcast, controller.signal, activeScenario);
2112
+ await runForkSimulation(config, broadcast, controller.signal, activeScenario, hooks?.onArtifact);
2113
+ }
2114
+ else if (config.leaders.length >= 3) {
2115
+ // Tier 5 Quickstart dispatches N >= 3 leaders to the batch
2116
+ // runner (same SSE contract per leader, no verdict).
2117
+ await runBatchSimulations(config, broadcast, controller.signal, activeScenario, hooks?.onArtifact);
1868
2118
  }
1869
2119
  else {
1870
- await startSimulations(config, broadcast, controller.signal, activeScenario);
2120
+ await startSimulations(config, broadcast, controller.signal, activeScenario, hooks?.onArtifact);
1871
2121
  }
1872
2122
  }
1873
2123
  finally {