hypermind 0.11.0__py3-none-any.whl

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 (181) hide show
  1. hmctl/__init__.py +7 -0
  2. hmctl/cli.py +947 -0
  3. hypermind/__init__.py +208 -0
  4. hypermind/_deprecations.py +108 -0
  5. hypermind/agent.py +2896 -0
  6. hypermind/agent_card.py +127 -0
  7. hypermind/api/__init__.py +32 -0
  8. hypermind/api/app.py +686 -0
  9. hypermind/capability.py +923 -0
  10. hypermind/consult.py +493 -0
  11. hypermind/consult_bias.py +112 -0
  12. hypermind/contract_net.py +219 -0
  13. hypermind/crypto/__init__.py +21 -0
  14. hypermind/crypto/cose_encrypt.py +126 -0
  15. hypermind/crypto/dkg.py +246 -0
  16. hypermind/crypto/domain.py +59 -0
  17. hypermind/crypto/frost.py +771 -0
  18. hypermind/crypto/hashing.py +38 -0
  19. hypermind/crypto/hlc.py +163 -0
  20. hypermind/crypto/hpke.py +277 -0
  21. hypermind/crypto/kms.py +196 -0
  22. hypermind/crypto/kms_backends/__init__.py +56 -0
  23. hypermind/crypto/kms_backends/aws.py +128 -0
  24. hypermind/crypto/kms_backends/azure.py +114 -0
  25. hypermind/crypto/kms_backends/gcp.py +97 -0
  26. hypermind/crypto/kms_backends/vault.py +120 -0
  27. hypermind/crypto/namespace_root.py +204 -0
  28. hypermind/crypto/pq.py +152 -0
  29. hypermind/crypto/room_epoch.py +97 -0
  30. hypermind/crypto/signing.py +245 -0
  31. hypermind/deliberation.py +355 -0
  32. hypermind/did_web.py +256 -0
  33. hypermind/dispute.py +858 -0
  34. hypermind/errors.py +218 -0
  35. hypermind/eval/__init__.py +49 -0
  36. hypermind/eval/calibration.py +307 -0
  37. hypermind/eval/comparison.py +251 -0
  38. hypermind/eval/replay.py +202 -0
  39. hypermind/eval/swarm_iq.py +718 -0
  40. hypermind/knowledge/__init__.py +17 -0
  41. hypermind/knowledge/citation_graph.py +141 -0
  42. hypermind/knowledge/correlated_agreement.py +167 -0
  43. hypermind/knowledge/embedding_registry.py +115 -0
  44. hypermind/knowledge/kernel_store.py +197 -0
  45. hypermind/knowledge/rekor.py +173 -0
  46. hypermind/knowledge/reputation.py +364 -0
  47. hypermind/knowledge/swarm_memory.py +523 -0
  48. hypermind/knowledge/transparency.py +409 -0
  49. hypermind/mcp/__init__.py +60 -0
  50. hypermind/mcp/bridge.py +151 -0
  51. hypermind/mcp/client.py +142 -0
  52. hypermind/mcp/provenance.py +214 -0
  53. hypermind/mcp/relata_tools.py +152 -0
  54. hypermind/mcp/server.py +248 -0
  55. hypermind/mcp/transport.py +206 -0
  56. hypermind/mind/__init__.py +150 -0
  57. hypermind/mind/active.py +234 -0
  58. hypermind/mind/belief.py +369 -0
  59. hypermind/mind/deliberator.py +625 -0
  60. hypermind/mind/diversity.py +144 -0
  61. hypermind/mind/evolve.py +184 -0
  62. hypermind/mind/federation.py +154 -0
  63. hypermind/mind/goals.py +117 -0
  64. hypermind/mind/integration.py +190 -0
  65. hypermind/mind/mediator.py +74 -0
  66. hypermind/mind/memory_store.py +129 -0
  67. hypermind/mind/policy.py +307 -0
  68. hypermind/mind/records.py +260 -0
  69. hypermind/mind/roles.py +190 -0
  70. hypermind/mind/sybil_guard.py +41 -0
  71. hypermind/mind/world_model.py +166 -0
  72. hypermind/namespace.py +515 -0
  73. hypermind/namespace_policy.py +254 -0
  74. hypermind/observability/__init__.py +25 -0
  75. hypermind/observability/asgi.py +214 -0
  76. hypermind/observability/cost.py +277 -0
  77. hypermind/observability/otel_export.py +200 -0
  78. hypermind/observability/recorder.py +344 -0
  79. hypermind/observability/swarm_trace.py +438 -0
  80. hypermind/pin_policy.py +116 -0
  81. hypermind/py.typed +0 -0
  82. hypermind/responders/__init__.py +87 -0
  83. hypermind/responders/anthropic.py +159 -0
  84. hypermind/responders/base.py +162 -0
  85. hypermind/responders/openai.py +199 -0
  86. hypermind/responders/router.py +254 -0
  87. hypermind/responders/structured.py +287 -0
  88. hypermind/responders/tools.py +444 -0
  89. hypermind/revocation.py +270 -0
  90. hypermind/rule_ids.py +135 -0
  91. hypermind/schemas/encrypted_statement.cddl +28 -0
  92. hypermind/schemas/namespace_accept.cddl +20 -0
  93. hypermind/schemas/namespace_create.cddl +25 -0
  94. hypermind/schemas/namespace_founding_attest.cddl +30 -0
  95. hypermind/schemas/namespace_invite.cddl +22 -0
  96. hypermind/schemas/wire-v0.1.cddl +73 -0
  97. hypermind/simlab/__init__.py +15 -0
  98. hypermind/simlab/_persona_registry.py +93 -0
  99. hypermind/simlab/_scenario_impl.py +2778 -0
  100. hypermind/simlab/app.py +2538 -0
  101. hypermind/simlab/backends/__init__.py +27 -0
  102. hypermind/simlab/backends/anchor.py +88 -0
  103. hypermind/simlab/backends/local.py +32 -0
  104. hypermind/simlab/backends/server.py +79 -0
  105. hypermind/simlab/cli_command.py +108 -0
  106. hypermind/simlab/config.py +106 -0
  107. hypermind/simlab/deployment.py +26 -0
  108. hypermind/simlab/knowledge.py +716 -0
  109. hypermind/simlab/learning.py +295 -0
  110. hypermind/simlab/modes/__init__.py +115 -0
  111. hypermind/simlab/modes/_eval_real_llm.py +373 -0
  112. hypermind/simlab/modes/aar.py +611 -0
  113. hypermind/simlab/modes/backtest.py +610 -0
  114. hypermind/simlab/modes/binary_forecast.py +69 -0
  115. hypermind/simlab/modes/conformance.py +1869 -0
  116. hypermind/simlab/modes/evaluation.py +2271 -0
  117. hypermind/simlab/modes/governance.py +648 -0
  118. hypermind/simlab/modes/redteam.py +534 -0
  119. hypermind/simlab/modes/tabletop.py +797 -0
  120. hypermind/simlab/modes/tournament.py +448 -0
  121. hypermind/simlab/modes/whatif.py +523 -0
  122. hypermind/simlab/namespace.py +125 -0
  123. hypermind/simlab/personas.py +477 -0
  124. hypermind/simlab/prompt_generator.py +172 -0
  125. hypermind/simlab/question_sets/geopolitics_q20.json +122 -0
  126. hypermind/simlab/question_sets/governance_q5.json +67 -0
  127. hypermind/simlab/question_sets/msft_outlook_q10.json +62 -0
  128. hypermind/simlab/question_sets/whatif_q3.json +38 -0
  129. hypermind/simlab/registry.py +325 -0
  130. hypermind/simlab/runner.py +239 -0
  131. hypermind/simlab/scenario.py +147 -0
  132. hypermind/simlab/swarms.py +180 -0
  133. hypermind/simlab/tool_handlers/__init__.py +4 -0
  134. hypermind/simlab/tool_handlers/alpha_vantage.py +39 -0
  135. hypermind/simlab/tool_handlers/brave.py +46 -0
  136. hypermind/simlab/tool_handlers/newsapi.py +50 -0
  137. hypermind/simlab/tool_handlers/openweather.py +46 -0
  138. hypermind/simlab/tool_handlers/pinecone.py +52 -0
  139. hypermind/simlab/tool_handlers/qdrant.py +57 -0
  140. hypermind/simlab/tool_handlers/query_knowledge_base.py +21 -0
  141. hypermind/simlab/tool_handlers/relata_recall.py +61 -0
  142. hypermind/simlab/tool_handlers/retrieve_document.py +21 -0
  143. hypermind/simlab/tool_handlers/serper.py +46 -0
  144. hypermind/simlab/tool_handlers/tavily.py +53 -0
  145. hypermind/simlab/tool_handlers/weaviate.py +65 -0
  146. hypermind/simlab/tool_handlers/wikipedia.py +64 -0
  147. hypermind/simlab/tool_handlers/wolfram.py +48 -0
  148. hypermind/simlab/tool_handlers/yahoo_finance.py +49 -0
  149. hypermind/simlab/tool_registry.py +568 -0
  150. hypermind/simlab/topic_adapter.py +338 -0
  151. hypermind/simlab/topic_questions.py +259 -0
  152. hypermind/storage/__init__.py +69 -0
  153. hypermind/storage/backend.py +71 -0
  154. hypermind/storage/relata.py +245 -0
  155. hypermind/storage/sqlite.py +258 -0
  156. hypermind/sync.py +191 -0
  157. hypermind/tal.py +175 -0
  158. hypermind/tasks.py +334 -0
  159. hypermind/testing.py +16 -0
  160. hypermind/tools/__init__.py +32 -0
  161. hypermind/tools/registry.py +61 -0
  162. hypermind/transport/__init__.py +22 -0
  163. hypermind/transport/anti_entropy.py +708 -0
  164. hypermind/transport/bus.py +206 -0
  165. hypermind/transport/knows_delta.py +204 -0
  166. hypermind/transport/libp2p.py +298 -0
  167. hypermind/transport/placement.py +58 -0
  168. hypermind/transport/tcp.py +797 -0
  169. hypermind/transport/tls_profile.py +417 -0
  170. hypermind/types.py +59 -0
  171. hypermind/uncertainty.py +222 -0
  172. hypermind/wire.py +897 -0
  173. hypermind/workflow/__init__.py +72 -0
  174. hypermind/workflow/engine.py +655 -0
  175. hypermind/workflow/plan.py +182 -0
  176. hypermind/workflow/repair.py +203 -0
  177. hypermind-0.11.0.dist-info/METADATA +524 -0
  178. hypermind-0.11.0.dist-info/RECORD +181 -0
  179. hypermind-0.11.0.dist-info/WHEEL +4 -0
  180. hypermind-0.11.0.dist-info/entry_points.txt +2 -0
  181. hypermind-0.11.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,2538 @@
1
+ """ASGI app exposing /v1/sims/* and the static SPA.
2
+
3
+ This is a standalone app (not bound to any HyperMindAgent). It re-uses
4
+ the helpers from ``hypermind.api.app`` (``_send_json``, ``_err``, etc.)
5
+ and follows the same manual-dispatch pattern.
6
+
7
+ Mounted at ``/`` by ``hmctl simlab``. Routes:
8
+
9
+ GET / → index.html (SPA)
10
+ GET /assets/* → static SPA bundle
11
+ GET /v1/healthz → liveness
12
+ GET /v1/sims → list of summaries
13
+ POST /v1/sims → enqueue new sim, returns {sim_id}
14
+ GET /v1/sims/{id} → full trace.json (200 once done)
15
+ GET /v1/sims/{id}/status → status.json
16
+ GET /v1/sims/{id}/events/stream → SSE per-sim recorder events
17
+ GET /v1/stats → namespace + federation summary
18
+ GET /v1/disputes → dispute records across all sims
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import json
25
+ import mimetypes
26
+ import re
27
+ from collections.abc import Awaitable, Callable
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ from hypermind import __version__
32
+ from hypermind.api.app import (
33
+ _read_body,
34
+ )
35
+
36
+ from .config import SimConfig
37
+ from .knowledge import KnowledgeIndex, _ingest_trace, build_knowledge_index
38
+ from .registry import (
39
+ _write_json_atomic,
40
+ list_sims,
41
+ load_status,
42
+ load_trace,
43
+ sim_dir,
44
+ update_status,
45
+ )
46
+ from .runner import get_runner
47
+
48
+ _Scope = dict
49
+ _Receive = Callable[[], Awaitable[dict]]
50
+ _Send = Callable[[dict], Awaitable[None]]
51
+
52
+ # Validate sim_id to prevent path traversal attacks
53
+ _SIM_ID_RE = re.compile(r"^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9]{10}$")
54
+
55
+ # Maximum number of sims that may be queued at once.
56
+ MAX_QUEUE = 10
57
+
58
+ _CORS_HEADERS = [
59
+ (b"access-control-allow-origin", b"*"),
60
+ (b"access-control-allow-methods", b"GET, POST, PUT, DELETE, OPTIONS"),
61
+ (b"access-control-allow-headers", b"Content-Type, Authorization"),
62
+ ]
63
+
64
+
65
+ def _add_cors(headers: list) -> list:
66
+ """Return a copy of *headers* with CORS headers appended."""
67
+ return list(headers) + _CORS_HEADERS
68
+
69
+
70
+ async def _send_json(send: _Send, *, status: int, payload: Any) -> None:
71
+ """Like api._send_json but always includes CORS headers."""
72
+ import json as _json
73
+
74
+ def _json_default(obj: Any) -> Any:
75
+ if isinstance(obj, (bytes, bytearray)):
76
+ return obj.hex()
77
+ if hasattr(obj, "to_dict"):
78
+ return obj.to_dict()
79
+ if hasattr(obj, "__dict__"):
80
+ return obj.__dict__
81
+ return str(obj)
82
+
83
+ body = _json.dumps(payload, default=_json_default).encode("utf-8")
84
+ await send(
85
+ {
86
+ "type": "http.response.start",
87
+ "status": status,
88
+ "headers": _add_cors(
89
+ [
90
+ (b"content-type", b"application/json"),
91
+ (b"content-length", str(len(body)).encode()),
92
+ ]
93
+ ),
94
+ }
95
+ )
96
+ await send({"type": "http.response.body", "body": body, "more_body": False})
97
+
98
+
99
+ async def _err(send: _Send, status: int, message: str) -> None:
100
+ """Like api._err but always includes CORS headers."""
101
+ await _send_json(send, status=status, payload={"error": message})
102
+
103
+
104
+ # ---- SPA static dispatch ---------------------------------------------------
105
+
106
+
107
+ def _ui_dist_dir() -> Path | None:
108
+ """Locate the bundled SPA dist directory.
109
+
110
+ Resolution order:
111
+ 1. The dev-source webui/dist (when running from a checkout)
112
+ 2. The package's bundled simlab/ui/dist (after pip install)
113
+ """
114
+ pkg_path = Path(__file__).resolve().parent / "ui" / "dist"
115
+ if pkg_path.exists():
116
+ return pkg_path
117
+ # Dev tree: webui/dist sibling of src/
118
+ for parent in Path(__file__).resolve().parents:
119
+ candidate = parent / "webui" / "dist"
120
+ if candidate.exists():
121
+ return candidate
122
+ if (parent / "pyproject.toml").exists():
123
+ break
124
+ return None
125
+
126
+
127
+ _PLACEHOLDER_INDEX = b"""<!DOCTYPE html><html><head><meta charset="utf-8">
128
+ <title>HyperMind SimLab</title>
129
+ <style>body{font-family:system-ui;max-width:720px;margin:80px auto;padding:24px;
130
+ color:#11161D;background:#FDFDFC}h1{font-weight:500}code{background:#F6F7F9;
131
+ padding:2px 6px;border-radius:4px}</style></head>
132
+ <body><h1>HyperMind SimLab</h1>
133
+ <p>The Vue SPA has not been built yet.</p>
134
+ <p>From the repository root, run:</p>
135
+ <pre>cd webui && npm ci && npm run build</pre>
136
+ <p>Then refresh this page. The API is live at
137
+ <a href="/v1/sims"><code>/v1/sims</code></a>.</p>
138
+ </body></html>
139
+ """
140
+
141
+
142
+ async def _serve_static(scope: _Scope, send: _Send, *, dist: Path | None) -> bool:
143
+ """Serve a static file from the SPA dist. Returns True if handled."""
144
+ path: str = scope.get("path", "/")
145
+ method: str = scope.get("method", "GET").upper()
146
+ if method != "GET":
147
+ return False
148
+
149
+ if dist is None:
150
+ # No SPA built yet — return placeholder for /, 404 for assets.
151
+ if path in ("/", "/index.html"):
152
+ await send(
153
+ {
154
+ "type": "http.response.start",
155
+ "status": 200,
156
+ "headers": [(b"content-type", b"text/html; charset=utf-8")],
157
+ }
158
+ )
159
+ await send(
160
+ {"type": "http.response.body", "body": _PLACEHOLDER_INDEX, "more_body": False}
161
+ )
162
+ return True
163
+ return False
164
+
165
+ if path in ("/", "/index.html"):
166
+ target = dist / "index.html"
167
+ else:
168
+ # Strip leading /
169
+ target = dist / path.lstrip("/")
170
+
171
+ # Resolve symlinks; reject path traversal.
172
+ try:
173
+ resolved = target.resolve()
174
+ resolved.relative_to(dist.resolve())
175
+ except (OSError, ValueError):
176
+ return False
177
+
178
+ if not resolved.exists() or not resolved.is_file():
179
+ # SPA fallback: any unknown route serves index.html for client routing
180
+ if path.startswith("/v1/") or path.startswith("/assets/"):
181
+ return False
182
+ target = dist / "index.html"
183
+ if not target.exists():
184
+ return False
185
+ resolved = target.resolve()
186
+
187
+ ct = mimetypes.guess_type(resolved.name)[0] or "application/octet-stream"
188
+ body = resolved.read_bytes()
189
+ await send(
190
+ {
191
+ "type": "http.response.start",
192
+ "status": 200,
193
+ "headers": [
194
+ (b"content-type", ct.encode()),
195
+ (b"content-length", str(len(body)).encode()),
196
+ (b"cache-control", b"no-cache"),
197
+ ],
198
+ }
199
+ )
200
+ await send({"type": "http.response.body", "body": body, "more_body": False})
201
+ return True
202
+
203
+
204
+ # ---- /v1/namespaces handlers -----------------------------------------------
205
+
206
+
207
+ async def _handle_create_namespace(body: bytes, send: _Send) -> None:
208
+ import time
209
+
210
+ from .namespace import (
211
+ DeploymentProfile,
212
+ Namespace,
213
+ get_namespace,
214
+ save_namespace,
215
+ validate_slug,
216
+ )
217
+
218
+ try:
219
+ payload = json.loads(body) if body else {}
220
+ slug = payload.get("slug", "").strip()
221
+ validate_slug(slug)
222
+ if get_namespace(slug) is not None:
223
+ await _send_json(
224
+ send, status=409, payload={"error": f"namespace {slug!r} already exists"}
225
+ )
226
+ return
227
+ profile = DeploymentProfile.from_dict(
228
+ {
229
+ "tier": payload.get("tier", "local"),
230
+ "storage_url": payload.get("storage_url"),
231
+ "visibility": payload.get("visibility", "private"),
232
+ "anchor_chain": payload.get("anchor_chain"),
233
+ "anchor_contract": payload.get("anchor_contract"),
234
+ "anchor_rpc_url": payload.get("anchor_rpc_url"),
235
+ }
236
+ )
237
+ ns = Namespace(
238
+ slug=slug,
239
+ label=payload.get("label", slug),
240
+ profile=profile,
241
+ created_at_ms=int(time.time() * 1000),
242
+ )
243
+ save_namespace(ns)
244
+ await _send_json(send, status=201, payload=ns.to_dict())
245
+ except ValueError as e:
246
+ await _send_json(send, status=422, payload={"error": str(e)})
247
+ except Exception as e:
248
+ await _send_json(send, status=500, payload={"error": str(e)})
249
+
250
+
251
+ # ---- /v1/modes handler -----------------------------------------------------
252
+
253
+
254
+ async def _handle_list_modes(send: _Send) -> None:
255
+ """GET /v1/modes — return the registered deliberation modes.
256
+
257
+ Powers the schema-driven SimNew form: the SPA reads this on page
258
+ load, renders a dropdown, and (for modes with input_schema != null)
259
+ renders a dynamic form based on the schema.
260
+ """
261
+ from .modes import list_modes
262
+
263
+ payload = [m.to_dict() for m in list_modes()]
264
+ await _send_json(send, status=200, payload=payload)
265
+
266
+
267
+ # ---- /v1/sims handlers -----------------------------------------------------
268
+
269
+
270
+ async def _handle_list_sims(send: _Send) -> None:
271
+ sims = [s.to_dict() for s in list_sims(limit=1000)]
272
+ await _send_json(send, status=200, payload=sims)
273
+
274
+
275
+ async def _handle_create_sim(body: bytes, send: _Send) -> None:
276
+ try:
277
+ payload = json.loads(body) if body else {}
278
+ except json.JSONDecodeError as exc:
279
+ await _err(send, 400, f"invalid JSON: {exc}")
280
+ return
281
+ if not isinstance(payload, dict):
282
+ await _err(send, 400, "expected JSON object")
283
+ return
284
+
285
+ # Topic-adaptive configuration: if the caller supplied only a topic
286
+ # (or left numeric params at sentinel defaults), infer the right
287
+ # n_questions / personas / replicas / deliberation_rounds from the topic.
288
+ topic = (payload.get("topic") or "").strip()
289
+ if topic:
290
+ from .topic_adapter import apply_profile_to_config, infer_topic_profile
291
+ from .topic_questions import generate_questions_for_topic
292
+ model = payload.get("model") or SimConfig.model
293
+ no_llm = bool(payload.get("no_llm", False))
294
+ n_questions_hint = int(payload.get("n_questions") or 8)
295
+ try:
296
+ # Run profile inference and question generation in parallel
297
+ # so both LLM round-trips overlap instead of serialising.
298
+ import asyncio as _asyncio
299
+ profile, pregenerated = await _asyncio.gather(
300
+ infer_topic_profile(topic, model=model, no_llm=no_llm),
301
+ generate_questions_for_topic(topic, n_questions_hint, model=model, no_llm=no_llm),
302
+ return_exceptions=True,
303
+ )
304
+ if isinstance(profile, BaseException):
305
+ profile = None
306
+ if isinstance(pregenerated, BaseException):
307
+ pregenerated = None
308
+ if profile is not None:
309
+ payload = apply_profile_to_config(payload, profile)
310
+ if pregenerated:
311
+ payload["_pregenerated_questions"] = pregenerated
312
+ except Exception:
313
+ pass # never block a sim because adaptation failed
314
+
315
+ try:
316
+ cfg = SimConfig.from_dict(payload)
317
+ except ValueError as exc:
318
+ await _err(send, 400, str(exc))
319
+ return
320
+ runner = get_runner()
321
+ if runner.queue_size >= MAX_QUEUE:
322
+ body = json.dumps({"error": "queue_full", "message": "Too many sims queued"}).encode(
323
+ "utf-8"
324
+ )
325
+ await send(
326
+ {
327
+ "type": "http.response.start",
328
+ "status": 503,
329
+ "headers": _add_cors(
330
+ [
331
+ (b"content-type", b"application/json"),
332
+ (b"content-length", str(len(body)).encode()),
333
+ (b"retry-after", b"30"),
334
+ ]
335
+ ),
336
+ }
337
+ )
338
+ await send({"type": "http.response.body", "body": body, "more_body": False})
339
+ return
340
+ try:
341
+ sim_id = await runner.submit(cfg)
342
+ except asyncio.QueueFull:
343
+ body = json.dumps({"error": "queue_full", "message": "Too many sims queued"}).encode(
344
+ "utf-8"
345
+ )
346
+ await send(
347
+ {
348
+ "type": "http.response.start",
349
+ "status": 503,
350
+ "headers": _add_cors(
351
+ [
352
+ (b"content-type", b"application/json"),
353
+ (b"content-length", str(len(body)).encode()),
354
+ (b"retry-after", b"30"),
355
+ ]
356
+ ),
357
+ }
358
+ )
359
+ await send({"type": "http.response.body", "body": body, "more_body": False})
360
+ return
361
+ await _send_json(send, status=202, payload={"sim_id": sim_id})
362
+
363
+
364
+ async def _handle_get_status(sim_id: str, send: _Send) -> None:
365
+ if not sim_dir(sim_id).exists():
366
+ await _err(send, 404, f"unknown sim: {sim_id}")
367
+ return
368
+ status = load_status(sim_id)
369
+ # Enrich with the topic the user entered (so the live view can show
370
+ # "Watching: <topic>" without a second request to /sims).
371
+ try:
372
+ from .registry import load_config as _load_cfg
373
+
374
+ cfg = _load_cfg(sim_id)
375
+ if cfg.get("topic"):
376
+ status["topic"] = cfg["topic"]
377
+ if cfg.get("questions"):
378
+ status["questions_set"] = cfg["questions"]
379
+ status["model"] = cfg.get("model")
380
+ status["no_llm"] = cfg.get("no_llm")
381
+ except Exception:
382
+ pass
383
+ await _send_json(send, status=200, payload=status)
384
+
385
+
386
+ async def _handle_get_stderr(sim_id: str, send: _Send) -> None:
387
+ """GET /v1/sims/{id}/stderr — returns the captured stderr log
388
+ (last ~64KB) so a failed sim's detail page can show what went wrong.
389
+ """
390
+ if not sim_dir(sim_id).exists():
391
+ await _err(send, 404, f"unknown sim: {sim_id}")
392
+ return
393
+ log_path = sim_dir(sim_id) / "stderr.log"
394
+ if not log_path.exists():
395
+ await _send_json(send, status=200, payload={"stderr": "", "size": 0})
396
+ return
397
+ # Read last 64KB only — these can grow
398
+ size = log_path.stat().st_size
399
+ with log_path.open("rb") as f:
400
+ if size > 65536:
401
+ f.seek(-65536, 2)
402
+ f.readline() # discard partial line
403
+ data = f.read().decode("utf-8", errors="replace")
404
+ await _send_json(
405
+ send, status=200, payload={"stderr": data, "size": size, "truncated": size > 65536}
406
+ )
407
+
408
+
409
+ async def _handle_get_trace(sim_id: str, send: _Send) -> None:
410
+ if not sim_dir(sim_id).exists():
411
+ await _err(send, 404, f"unknown sim: {sim_id}")
412
+ return
413
+ status = load_status(sim_id)
414
+ state = status.get("state")
415
+ if state == "failed":
416
+ await _err(send, 410, status.get("error", "sim failed"))
417
+ return
418
+ if state != "done":
419
+ await _err(send, 404, f"sim not done yet (state={state})")
420
+ return
421
+ trace = load_trace(sim_id)
422
+ if trace is None:
423
+ await _err(send, 404, "trace.json missing")
424
+ return
425
+ await _send_json(send, status=200, payload=trace)
426
+
427
+
428
+ # ---- /v1/knowledge handlers (federation memory across all sims) ----
429
+
430
+
431
+ def _build_knowledge_index_for_namespace(namespace_slug: str) -> KnowledgeIndex:
432
+ """Return a KnowledgeIndex appropriate for *namespace_slug*.
433
+
434
+ - If the namespace has tier=="server" and a storage_url is set:
435
+ reads sim IDs + traces from the ServerBackend (SQLite).
436
+ - Otherwise (tier=="local" or namespace not found):
437
+ delegates to the standard disk-walk ``build_knowledge_index()``.
438
+
439
+ The local path is 100% unchanged — no disk reads are altered.
440
+ """
441
+ from .namespace import get_namespace
442
+
443
+ ns = get_namespace(namespace_slug) if namespace_slug else None
444
+ if ns is not None and ns.profile.tier == "server" and ns.profile.storage_url:
445
+ import time as _time
446
+
447
+ from .backends.server import ServerBackend
448
+
449
+ backend = ServerBackend(ns.profile.storage_url)
450
+ idx = KnowledgeIndex()
451
+ for sim_id in backend.list_sim_ids():
452
+ trace = backend.read_trace(sim_id)
453
+ if trace is None:
454
+ continue
455
+ try:
456
+ _ingest_trace(idx, sim_id, trace)
457
+ idx.n_sims += 1
458
+ except Exception:
459
+ continue
460
+ idx.last_built_ts_ms = int(_time.time() * 1000)
461
+ return idx
462
+ # Default: use the existing cached disk-walk index
463
+ return build_knowledge_index()
464
+
465
+
466
+ async def _handle_knowledge_overview(send: _Send) -> None:
467
+ idx = build_knowledge_index()
468
+ await _send_json(send, status=200, payload=idx.overview())
469
+
470
+
471
+ def _qs_param(qs: bytes, key: str, default: str = "") -> str:
472
+ """Tiny query-string getter; mirrors api/app.py:_parse_qs but
473
+ avoids a circular import."""
474
+ for pair in qs.split(b"&"):
475
+ if not pair or b"=" not in pair:
476
+ continue
477
+ k, v = pair.split(b"=", 1)
478
+ if k.decode() == key:
479
+ from urllib.parse import unquote_plus
480
+
481
+ return unquote_plus(v.decode())
482
+ return default
483
+
484
+
485
+ async def _handle_knowledge_claims(qs: bytes, send: _Send) -> None:
486
+ namespace_slug = _qs_param(qs, "namespace", "default")
487
+ idx = _build_knowledge_index_for_namespace(namespace_slug)
488
+ try:
489
+ limit = max(1, min(2000, int(_qs_param(qs, "limit", "100"))))
490
+ offset = max(0, int(_qs_param(qs, "offset", "0")))
491
+ except ValueError:
492
+ await _err(send, 400, "limit/offset must be ints")
493
+ return
494
+ payload = idx.list_claims(
495
+ q=_qs_param(qs, "q"),
496
+ persona=_qs_param(qs, "persona"),
497
+ topic=_qs_param(qs, "topic"),
498
+ sim_id=_qs_param(qs, "sim_id"),
499
+ limit=limit,
500
+ offset=offset,
501
+ )
502
+ await _send_json(send, status=200, payload=payload)
503
+
504
+
505
+ async def _handle_knowledge_agents(send: _Send) -> None:
506
+ idx = build_knowledge_index()
507
+ await _send_json(send, status=200, payload=idx.list_agents())
508
+
509
+
510
+ async def _handle_knowledge_topics(send: _Send) -> None:
511
+ idx = build_knowledge_index()
512
+ await _send_json(send, status=200, payload=idx.list_topics())
513
+
514
+
515
+ async def _handle_knowledge_ask(qs: bytes, send: _Send) -> None:
516
+ """GET /v1/knowledge/ask?q=… — federation Q&A across all claims."""
517
+ idx = build_knowledge_index()
518
+ q = _qs_param(qs, "q", "")
519
+ try:
520
+ top_k = max(1, min(500, int(_qs_param(qs, "top_k", "20"))))
521
+ except ValueError:
522
+ top_k = 20
523
+ payload = idx.ask(q, top_k=top_k)
524
+ await _send_json(send, status=200, payload=payload)
525
+
526
+
527
+ # ---- /v1/personas + /v1/swarms ---------------------------------------------
528
+
529
+
530
+ async def _handle_list_personas(send: _Send) -> None:
531
+ from .personas import load_personas
532
+
533
+ payload = [p.to_dict() for p in load_personas()]
534
+ await _send_json(send, status=200, payload=payload)
535
+
536
+
537
+ async def _handle_get_persona(slug: str, send: _Send) -> None:
538
+ from .personas import get_persona
539
+
540
+ p = get_persona(slug)
541
+ if p is None:
542
+ await _err(send, 404, f"unknown persona: {slug}")
543
+ return
544
+ await _send_json(send, status=200, payload=p.to_dict())
545
+
546
+
547
+ async def _handle_create_persona(body: bytes, send: _Send) -> None:
548
+ from .personas import PersonaSpec, is_builtin, save_persona
549
+
550
+ try:
551
+ data = json.loads(body) if body else {}
552
+ except json.JSONDecodeError as exc:
553
+ await _err(send, 400, f"invalid JSON: {exc}")
554
+ return
555
+ if not isinstance(data, dict):
556
+ await _err(send, 400, "expected JSON object")
557
+ return
558
+ try:
559
+ spec = PersonaSpec.from_dict(data)
560
+ if is_builtin(spec.slug):
561
+ await _err(send, 422, f"slug {spec.slug!r} is a built-in; pick another")
562
+ return
563
+ save_persona(spec)
564
+ except ValueError as exc:
565
+ await _err(send, 400, str(exc))
566
+ return
567
+ await _send_json(send, status=201, payload=spec.to_dict())
568
+
569
+
570
+ async def _handle_update_persona(slug: str, body: bytes, send: _Send) -> None:
571
+ from .personas import PersonaSpec, get_persona, is_builtin, save_persona
572
+
573
+ if is_builtin(slug):
574
+ await _err(send, 422, f"persona {slug!r} is built-in and immutable")
575
+ return
576
+ try:
577
+ data = json.loads(body) if body else {}
578
+ except json.JSONDecodeError as exc:
579
+ await _err(send, 400, f"invalid JSON: {exc}")
580
+ return
581
+ existing = get_persona(slug)
582
+ if existing is None:
583
+ await _err(send, 404, f"unknown persona: {slug}")
584
+ return
585
+ merged = {**existing.to_dict(), **data, "slug": slug}
586
+ try:
587
+ spec = PersonaSpec.from_dict(merged)
588
+ save_persona(spec)
589
+ except ValueError as exc:
590
+ await _err(send, 400, str(exc))
591
+ return
592
+ await _send_json(send, status=200, payload=spec.to_dict())
593
+
594
+
595
+ async def _handle_delete_persona(slug: str, send: _Send) -> None:
596
+ from .personas import delete_persona, is_builtin
597
+
598
+ if is_builtin(slug):
599
+ await _err(send, 422, f"cannot delete built-in persona {slug!r}")
600
+ return
601
+ delete_persona(slug)
602
+ await send(
603
+ {
604
+ "type": "http.response.start",
605
+ "status": 204,
606
+ "headers": [(b"content-length", b"0")],
607
+ }
608
+ )
609
+ await send({"type": "http.response.body", "body": b"", "more_body": False})
610
+
611
+
612
+ async def _handle_batch_generate_personas(body: bytes, send: _Send) -> None:
613
+ """POST /v1/personas/batch-generate — generate a population of personas."""
614
+ from .personas import get_persona, is_builtin, save_persona
615
+ from .prompt_generator import generate_persona
616
+
617
+ try:
618
+ data = json.loads(body) if body else {}
619
+ except json.JSONDecodeError as exc:
620
+ await _err(send, 400, f"invalid JSON: {exc}")
621
+ return
622
+ if not isinstance(data, dict):
623
+ await _err(send, 400, "expected JSON object")
624
+ return
625
+
626
+ population = data.get("population", [])
627
+ if not isinstance(population, list) or not population:
628
+ await _err(send, 400, "population must be a non-empty list")
629
+ return
630
+
631
+ model = data.get("model")
632
+ no_llm = bool(data.get("no_llm", False))
633
+
634
+ # Build the flat list of (description, idx_within_group) tuples
635
+ tasks: list[tuple[str, str]] = [] # (varied_description, base_description)
636
+ for entry in population:
637
+ if not isinstance(entry, dict):
638
+ continue
639
+ raw_desc = str(entry.get("description", "")).strip()
640
+ if not raw_desc:
641
+ continue
642
+ try:
643
+ count = max(1, min(10, int(entry.get("count", 1))))
644
+ except (TypeError, ValueError):
645
+ count = 1
646
+ for i in range(count):
647
+ if i == 0:
648
+ varied = raw_desc
649
+ elif i == 1:
650
+ varied = f"{raw_desc} (variant 2: slightly more optimistic)"
651
+ elif i == 2:
652
+ varied = f"{raw_desc} (variant 3: more conservative on macro)"
653
+ else:
654
+ varied = f"{raw_desc} (variant {i + 1}: distinct perspective)"
655
+ tasks.append((varied, raw_desc))
656
+
657
+ total = len(tasks)
658
+ if total == 0:
659
+ await _err(send, 400, "no valid population entries")
660
+ return
661
+
662
+ # Generate all personas in parallel
663
+ async def _gen(desc: str) -> Any:
664
+ try:
665
+ return await generate_persona(desc, model=model, no_llm=no_llm)
666
+ except Exception as exc:
667
+ return exc
668
+
669
+ results = await asyncio.gather(*[_gen(desc) for desc, _ in tasks])
670
+
671
+ saved_specs: list[dict] = []
672
+ errors: list[str] = []
673
+
674
+ for spec_or_exc in results:
675
+ if isinstance(spec_or_exc, Exception):
676
+ errors.append(str(spec_or_exc))
677
+ continue
678
+ spec = spec_or_exc
679
+ # Resolve slug collisions by appending -01, -02, etc.
680
+ base_slug = spec.slug
681
+ candidate = base_slug
682
+ if is_builtin(candidate) or get_persona(candidate) is not None:
683
+ for n in range(1, 100):
684
+ candidate = f"{base_slug}-{n:02d}"
685
+ if not is_builtin(candidate) and get_persona(candidate) is None:
686
+ break
687
+ spec.slug = candidate
688
+ try:
689
+ save_persona(spec)
690
+ saved_specs.append(spec.to_dict())
691
+ except Exception as exc:
692
+ errors.append(f"{spec.slug}: {exc}")
693
+
694
+ await _send_json(
695
+ send,
696
+ status=200,
697
+ payload={
698
+ "saved": len(saved_specs),
699
+ "personas": saved_specs,
700
+ "errors": errors,
701
+ },
702
+ )
703
+
704
+
705
+ async def _handle_generate_persona(body: bytes, send: _Send) -> None:
706
+ from .prompt_generator import generate_persona
707
+
708
+ try:
709
+ data = json.loads(body) if body else {}
710
+ except json.JSONDecodeError as exc:
711
+ await _err(send, 400, f"invalid JSON: {exc}")
712
+ return
713
+ description = str(data.get("description", "")).strip()
714
+ if not description:
715
+ await _err(send, 400, "description is required")
716
+ return
717
+ model = data.get("model")
718
+ no_llm = bool(data.get("no_llm", False))
719
+ try:
720
+ spec = await generate_persona(description, model=model, no_llm=no_llm)
721
+ except Exception as exc:
722
+ await _err(send, 502, f"generator failed: {exc}")
723
+ return
724
+ await _send_json(send, status=200, payload=spec.to_dict())
725
+
726
+
727
+ # ---------------------------------------------------------------------------
728
+ # Tool library handlers
729
+ # ---------------------------------------------------------------------------
730
+
731
+
732
+ async def _handle_list_tool_catalog(send: _Send) -> None:
733
+ from .tool_registry import list_catalog
734
+
735
+ await _send_json(send, status=200, payload=[t.to_dict() for t in list_catalog()])
736
+
737
+
738
+ async def _handle_get_tool_def(slug: str, send: _Send) -> None:
739
+ from .tool_registry import get_tool_def
740
+
741
+ t = get_tool_def(slug)
742
+ if t is None:
743
+ await _err(send, 404, f"unknown tool: {slug}")
744
+ return
745
+ await _send_json(send, status=200, payload=t.to_dict())
746
+
747
+
748
+ async def _handle_list_activated_tools(send: _Send) -> None:
749
+ from .tool_registry import load_activated_tools
750
+
751
+ await _send_json(send, status=200, payload=[t.to_api_dict() for t in load_activated_tools()])
752
+
753
+
754
+ async def _handle_activate_tool(body: bytes, send: _Send) -> None:
755
+ import time as _time
756
+
757
+ from .tool_registry import (
758
+ ActivatedTool,
759
+ encode_credential,
760
+ get_activated_tool,
761
+ get_tool_def,
762
+ save_activated_tool,
763
+ )
764
+
765
+ try:
766
+ data = json.loads(body or b"{}")
767
+ except Exception:
768
+ await _err(send, 400, "invalid JSON")
769
+ return
770
+ tool_slug = str(data.get("tool_slug", ""))
771
+ if not get_tool_def(tool_slug):
772
+ await _err(send, 422, f"unknown tool slug: {tool_slug}")
773
+ return
774
+ existing = get_activated_tool(tool_slug)
775
+ if existing and existing.status != "disabled":
776
+ await _err(send, 409, f"tool already activated: {tool_slug} (status={existing.status})")
777
+ return
778
+ raw_creds: dict = data.get("credentials", {})
779
+ encoded_creds = {k: encode_credential(str(v)) for k, v in raw_creds.items()}
780
+ t = ActivatedTool(
781
+ tool_slug=tool_slug,
782
+ status="pending",
783
+ credentials=encoded_creds,
784
+ label=str(data.get("label", "") or tool_slug),
785
+ activated_at_ms=int(_time.time() * 1000),
786
+ )
787
+ save_activated_tool(t)
788
+ await _send_json(send, status=201, payload=t.to_api_dict())
789
+
790
+
791
+ async def _handle_test_tool(slug: str, send: _Send) -> None:
792
+ import importlib
793
+ import time as _time
794
+
795
+ from .tool_registry import (
796
+ decode_credential,
797
+ get_activated_tool,
798
+ get_tool_def,
799
+ save_activated_tool,
800
+ )
801
+
802
+ tool_def = get_tool_def(slug)
803
+ if tool_def is None:
804
+ await _err(send, 404, f"unknown tool: {slug}")
805
+ return
806
+ activated = get_activated_tool(slug)
807
+ if activated is None:
808
+ await _err(send, 404, f"tool not activated: {slug}")
809
+ return
810
+ # No-key tools don't need a live network test — auto-verify immediately.
811
+ if tool_def.auth_type == "none":
812
+ ok, message = True, "No API key required — auto-verified"
813
+ else:
814
+ creds = {k: decode_credential(v) for k, v in activated.credentials.items()}
815
+ try:
816
+ mod = importlib.import_module(tool_def.handler_module)
817
+ ok, message = await mod.test_connection(creds)
818
+ except Exception as e:
819
+ ok, message = False, str(e)
820
+ activated.last_tested_ms = int(_time.time() * 1000)
821
+ activated.error_msg = None if ok else message
822
+ if ok and activated.status == "pending":
823
+ activated.status = "verified"
824
+ save_activated_tool(activated)
825
+ await _send_json(
826
+ send, status=200, payload={"ok": ok, "message": message, "status": activated.status}
827
+ )
828
+
829
+
830
+ async def _handle_set_tool_active(slug: str, send: _Send) -> None:
831
+ import time as _time
832
+
833
+ from .tool_registry import get_activated_tool, get_tool_def, save_activated_tool
834
+
835
+ tool_def = get_tool_def(slug)
836
+ activated = get_activated_tool(slug)
837
+ if activated is None:
838
+ await _err(send, 404, f"tool not activated: {slug}")
839
+ return
840
+ # No-key tools skip the verify step — promote from pending or verified.
841
+ no_key = tool_def is not None and tool_def.auth_type == "none"
842
+ if activated.status not in ("verified", "pending" if no_key else "__never__"):
843
+ await _err(
844
+ send,
845
+ 422,
846
+ f"tool must be verified before activating; current status: {activated.status}",
847
+ )
848
+ return
849
+ if activated.status == "pending" and no_key:
850
+ activated.last_tested_ms = int(_time.time() * 1000)
851
+ activated.status = "active"
852
+ save_activated_tool(activated)
853
+ await _send_json(send, status=200, payload=activated.to_api_dict())
854
+
855
+
856
+ async def _handle_update_tool_credentials(slug: str, body: bytes, send: _Send) -> None:
857
+ from .tool_registry import encode_credential, get_activated_tool, save_activated_tool
858
+
859
+ activated = get_activated_tool(slug)
860
+ if activated is None:
861
+ await _err(send, 404, f"tool not activated: {slug}")
862
+ return
863
+ try:
864
+ data = json.loads(body or b"{}")
865
+ except Exception:
866
+ await _err(send, 400, "invalid JSON")
867
+ return
868
+ raw_creds: dict = data.get("credentials", {})
869
+ activated.credentials = {k: encode_credential(str(v)) for k, v in raw_creds.items()}
870
+ activated.status = "pending"
871
+ activated.error_msg = None
872
+ save_activated_tool(activated)
873
+ await _send_json(send, status=200, payload=activated.to_api_dict())
874
+
875
+
876
+ async def _handle_deactivate_tool(slug: str, send: _Send) -> None:
877
+ from .tool_registry import get_activated_tool, save_activated_tool
878
+
879
+ activated = get_activated_tool(slug)
880
+ if activated is None:
881
+ await _err(send, 404, f"tool not activated: {slug}")
882
+ return
883
+ activated.status = "disabled"
884
+ save_activated_tool(activated)
885
+ await _send_json(send, status=200, payload={"ok": True, "status": "disabled"})
886
+
887
+
888
+ # ---------------------------------------------------------------------------
889
+ # Swarm handlers
890
+ # ---------------------------------------------------------------------------
891
+
892
+
893
+ async def _handle_list_swarms(send: _Send) -> None:
894
+ from .swarms import load_swarms
895
+
896
+ payload = [s.to_dict() for s in load_swarms()]
897
+ await _send_json(send, status=200, payload=payload)
898
+
899
+
900
+ async def _handle_get_swarm(slug: str, send: _Send) -> None:
901
+ from .swarms import get_swarm
902
+
903
+ s = get_swarm(slug)
904
+ if s is None:
905
+ await _err(send, 404, f"unknown swarm: {slug}")
906
+ return
907
+ await _send_json(send, status=200, payload=s.to_dict())
908
+
909
+
910
+ async def _handle_create_swarm(body: bytes, send: _Send) -> None:
911
+ from .swarms import Swarm, save_swarm
912
+
913
+ try:
914
+ data = json.loads(body) if body else {}
915
+ except json.JSONDecodeError as exc:
916
+ await _err(send, 400, f"invalid JSON: {exc}")
917
+ return
918
+ try:
919
+ s = Swarm.from_dict(data)
920
+ save_swarm(s)
921
+ except (TypeError, ValueError) as exc:
922
+ await _err(send, 400, str(exc))
923
+ return
924
+ await _send_json(send, status=201, payload=s.to_dict())
925
+
926
+
927
+ async def _handle_update_swarm(slug: str, body: bytes, send: _Send) -> None:
928
+ from .swarms import Swarm, get_swarm, save_swarm
929
+
930
+ existing = get_swarm(slug)
931
+ if existing is None:
932
+ await _err(send, 404, f"unknown swarm: {slug}")
933
+ return
934
+ try:
935
+ data = json.loads(body) if body else {}
936
+ except json.JSONDecodeError as exc:
937
+ await _err(send, 400, f"invalid JSON: {exc}")
938
+ return
939
+ merged = {**existing.to_dict(), **data, "slug": slug}
940
+ try:
941
+ s = Swarm.from_dict(merged)
942
+ save_swarm(s)
943
+ except (TypeError, ValueError) as exc:
944
+ await _err(send, 400, str(exc))
945
+ return
946
+ await _send_json(send, status=200, payload=s.to_dict())
947
+
948
+
949
+ async def _handle_delete_swarm(slug: str, send: _Send) -> None:
950
+ from .swarms import delete_swarm
951
+
952
+ delete_swarm(slug)
953
+ await send(
954
+ {
955
+ "type": "http.response.start",
956
+ "status": 204,
957
+ "headers": [(b"content-length", b"0")],
958
+ }
959
+ )
960
+ await send({"type": "http.response.body", "body": b"", "more_body": False})
961
+
962
+
963
+ async def _handle_knowledge_claim(kid: str, send: _Send) -> None:
964
+ idx = build_knowledge_index()
965
+ payload = idx.claim(kid)
966
+ if payload is None:
967
+ await _err(send, 404, f"unknown claim kid: {kid}")
968
+ return
969
+ await _send_json(send, status=200, payload=payload)
970
+
971
+
972
+ async def _handle_lineage(send: _Send) -> None:
973
+ """S4 — Cross-sim KernelStore lineage summary.
974
+
975
+ Returns one row per persona that has accumulated Brier observations
976
+ across sims. Powers the Lineage section on SimDetail Trust tab and
977
+ the Home page lifetime-Brier headline.
978
+ """
979
+ from hypermind.knowledge.kernel_store import KernelStore
980
+
981
+ rows = []
982
+ for persona in KernelStore.list_personas():
983
+ s = KernelStore.lifetime_summary(persona)
984
+ rows.append(s)
985
+ rows.sort(key=lambda r: r.get("mean_brier") if r.get("mean_brier") is not None else 1.0)
986
+ await _send_json(send, status=200, payload={"lineage": rows})
987
+
988
+
989
+ async def _handle_knowledge_scoreboard(send: _Send) -> None:
990
+ """Per-persona-replica calibration scoreboard.
991
+
992
+ Returns a list of agents with their accuracy, Brier score, and
993
+ claim count, sorted by Brier (lower = better).
994
+ """
995
+ idx = build_knowledge_index()
996
+ rows = []
997
+ for ag in idx.agents.values():
998
+ if ag.oracle_total == 0:
999
+ continue # only score agents that have resolved claims
1000
+ # Brier across resolved claims for this agent
1001
+ briers = []
1002
+ for c in idx.claims.values():
1003
+ if c.agent != ag.name or c.resolved_outcome is None:
1004
+ continue
1005
+ target = 1.0 if c.resolved_outcome else 0.0
1006
+ briers.append((c.confidence - target) ** 2)
1007
+ if not briers:
1008
+ continue
1009
+ brier = sum(briers) / len(briers)
1010
+ rows.append(
1011
+ {
1012
+ "name": ag.name,
1013
+ "persona": ag.persona,
1014
+ "accuracy": ag.accuracy,
1015
+ "brier": brier,
1016
+ "n_resolved": len(briers),
1017
+ "n_claims": ag.claims_published,
1018
+ "citations_received": ag.citations_received,
1019
+ "last_reputation": ag.last_reputation,
1020
+ "runs": ag.runs_participated,
1021
+ }
1022
+ )
1023
+ rows.sort(key=lambda r: r["brier"])
1024
+ await _send_json(send, status=200, payload=rows)
1025
+
1026
+
1027
+ async def _handle_knowledge_similar(kid: str, qs: bytes, send: _Send) -> None:
1028
+ """Find claims with similar query text to the one identified by kid.
1029
+
1030
+ Useful for cross-sim synthesis: an analyst opens a claim and wants
1031
+ to see "what else has the federation said on this question?"
1032
+ """
1033
+ idx = build_knowledge_index()
1034
+ target = idx.claim(kid)
1035
+ if target is None:
1036
+ await _err(send, 404, f"unknown claim kid: {kid}")
1037
+ return
1038
+ try:
1039
+ top_k = max(1, min(50, int(_qs_param(qs, "top_k", "10"))))
1040
+ except ValueError:
1041
+ top_k = 10
1042
+ payload = idx.ask(target["claim"]["query"], top_k=top_k + 1) # +1 to drop self
1043
+ # Remove self from results
1044
+ payload["items"] = [c for c in payload["items"] if c["kid"] != target["claim"]["kid"]][:top_k]
1045
+ payload["seed_kid"] = target["claim"]["kid"]
1046
+ payload["seed_query"] = target["claim"]["query"]
1047
+ await _send_json(send, status=200, payload=payload)
1048
+
1049
+
1050
+ async def _send_bytes(
1051
+ send: _Send,
1052
+ data: bytes,
1053
+ *,
1054
+ content_type: str = "application/octet-stream",
1055
+ filename: str = "file",
1056
+ ) -> None:
1057
+ """Send a raw bytes response with Content-Disposition."""
1058
+ await send(
1059
+ {
1060
+ "type": "http.response.start",
1061
+ "status": 200,
1062
+ "headers": [
1063
+ [b"content-type", content_type.encode()],
1064
+ [b"content-length", str(len(data)).encode()],
1065
+ [b"content-disposition", f'attachment; filename="{filename}"'.encode()],
1066
+ [b"access-control-allow-origin", b"*"],
1067
+ ],
1068
+ }
1069
+ )
1070
+ await send({"type": "http.response.body", "body": data, "more_body": False})
1071
+
1072
+
1073
+ async def _handle_knowledge_synthesis(qs: bytes, send: _Send) -> None:
1074
+ """GET /v1/knowledge/synthesis — cross-sim consensus synthesis per question."""
1075
+ from collections import defaultdict
1076
+
1077
+ idx = build_knowledge_index()
1078
+ topic = _qs_param(qs, "topic", "")
1079
+ q = _qs_param(qs, "q", "")
1080
+ try:
1081
+ top_k = max(1, min(100, int(_qs_param(qs, "top_k", "20"))))
1082
+ except ValueError:
1083
+ top_k = 20
1084
+
1085
+ all_claims_resp = idx.list_claims(
1086
+ q=q or "",
1087
+ topic=topic or "",
1088
+ sim_id="",
1089
+ limit=2000,
1090
+ offset=0,
1091
+ )
1092
+ all_claims = all_claims_resp.get("items", [])
1093
+
1094
+ if not all_claims:
1095
+ await _send_json(
1096
+ send,
1097
+ status=200,
1098
+ payload={
1099
+ "topic": topic,
1100
+ "n_sims_contributing": 0,
1101
+ "questions": [],
1102
+ "contradiction_map": [],
1103
+ },
1104
+ )
1105
+ return
1106
+
1107
+ # Group claims by query (first 80 chars, lowercased)
1108
+ query_groups: dict[str, list] = defaultdict(list)
1109
+ for claim in all_claims:
1110
+ key = claim.get("query", "")[:80].lower().strip()
1111
+ query_groups[key].append(claim)
1112
+
1113
+ questions = []
1114
+ for query_key, claims in sorted(query_groups.items(), key=lambda x: -len(x[1])):
1115
+ if not claims:
1116
+ continue
1117
+ confs = [c.get("confidence", 0.5) for c in claims if c.get("confidence") is not None]
1118
+ if not confs:
1119
+ continue
1120
+
1121
+ mean_conf = sum(confs) / len(confs)
1122
+ spread = max(confs) - min(confs) if len(confs) > 1 else 0.0
1123
+
1124
+ sim_map: dict[str, list] = defaultdict(list)
1125
+ for c in claims:
1126
+ sim_map[c.get("sim_id", "unknown")].append(c)
1127
+
1128
+ confidence_history = []
1129
+ for sim_id_key, sim_claims in sim_map.items():
1130
+ sim_confs = [c.get("confidence", 0.5) for c in sim_claims]
1131
+ sim_ts = min(c.get("ts_wall_ms", 0) for c in sim_claims)
1132
+ confidence_history.append(
1133
+ {
1134
+ "sim_id": sim_id_key,
1135
+ "ts": sim_ts / 1000.0,
1136
+ "mean_conf": sum(sim_confs) / len(sim_confs),
1137
+ "spread": max(sim_confs) - min(sim_confs) if len(sim_confs) > 1 else 0.0,
1138
+ "n_claims": len(sim_claims),
1139
+ }
1140
+ )
1141
+ confidence_history.sort(key=lambda x: x["ts"])
1142
+
1143
+ n_sims = len(sim_map)
1144
+ if n_sims >= 3 and spread < 0.10:
1145
+ status_val = "settled"
1146
+ elif spread >= 0.30:
1147
+ status_val = "contested"
1148
+ else:
1149
+ status_val = "emerging"
1150
+
1151
+ cited_claims = sorted(claims, key=lambda c: c.get("citations_received", 0), reverse=True)
1152
+ best_reasoning = [
1153
+ c.get("reasoning", "")[:400] for c in cited_claims[:3] if c.get("reasoning", "")
1154
+ ]
1155
+
1156
+ n_supporting = sum(1 for c in claims if c.get("confidence", 0.5) >= mean_conf + 0.15)
1157
+ n_contradicting = sum(1 for c in claims if c.get("confidence", 0.5) <= mean_conf - 0.15)
1158
+
1159
+ questions.append(
1160
+ {
1161
+ "query": claims[0].get("query", query_key),
1162
+ "status": status_val,
1163
+ "confidence_history": confidence_history,
1164
+ "current_consensus": round(mean_conf, 4),
1165
+ "current_spread": round(spread, 4),
1166
+ "n_sims": n_sims,
1167
+ "n_claims": len(claims),
1168
+ "n_supporting_claims": n_supporting,
1169
+ "n_contradicting_claims": n_contradicting,
1170
+ "best_reasoning": best_reasoning,
1171
+ }
1172
+ )
1173
+
1174
+ questions.sort(key=lambda qt: -qt["n_claims"])
1175
+ questions = questions[:top_k]
1176
+
1177
+ # Build contradiction map
1178
+ contradiction_map = []
1179
+ for qt in questions:
1180
+ query_claims = [
1181
+ c
1182
+ for c in all_claims
1183
+ if c.get("query", "")[:80].lower().strip() == qt["query"][:80].lower().strip()
1184
+ ]
1185
+ persona_confs: dict[str, list[float]] = defaultdict(list)
1186
+ for c in query_claims:
1187
+ p = c.get("persona", "unknown")
1188
+ persona_confs[p].append(c.get("confidence", 0.5))
1189
+
1190
+ persona_means = {p: sum(v) / len(v) for p, v in persona_confs.items()}
1191
+ sorted_personas = sorted(persona_means.items(), key=lambda x: x[1])
1192
+
1193
+ if len(sorted_personas) >= 2:
1194
+ low_p, low_v = sorted_personas[0]
1195
+ high_p, high_v = sorted_personas[-1]
1196
+ delta = high_v - low_v
1197
+ if delta >= 0.20:
1198
+ contradiction_map.append(
1199
+ {
1200
+ "query": qt["query"],
1201
+ "high_persona": high_p,
1202
+ "high_conf": round(high_v, 4),
1203
+ "low_persona": low_p,
1204
+ "low_conf": round(low_v, 4),
1205
+ "delta": round(delta, 4),
1206
+ }
1207
+ )
1208
+
1209
+ contradiction_map.sort(key=lambda x: -x["delta"])
1210
+
1211
+ n_sims_contributing = len({c.get("sim_id") for c in all_claims})
1212
+
1213
+ await _send_json(
1214
+ send,
1215
+ status=200,
1216
+ payload={
1217
+ "topic": topic,
1218
+ "n_sims_contributing": n_sims_contributing,
1219
+ "questions": questions,
1220
+ "contradiction_map": contradiction_map[:20],
1221
+ },
1222
+ )
1223
+
1224
+
1225
+ async def _handle_knowledge_followup(qs: bytes, send: _Send) -> None:
1226
+ """GET /v1/knowledge/followup — autonomous follow-up question generation."""
1227
+ from collections import defaultdict
1228
+
1229
+ from hypermind.simlab._scenario_impl import generate_followup_questions
1230
+
1231
+ idx = build_knowledge_index()
1232
+ sim_id = _qs_param(qs, "sim_id", "") or ""
1233
+ topic = _qs_param(qs, "topic", "") or ""
1234
+ try:
1235
+ top_k = max(1, min(50, int(_qs_param(qs, "top_k", "10"))))
1236
+ except ValueError:
1237
+ top_k = 10
1238
+
1239
+ claims_resp = idx.list_claims(
1240
+ topic=topic,
1241
+ sim_id=sim_id,
1242
+ limit=500,
1243
+ offset=0,
1244
+ )
1245
+ claims = claims_resp.get("items", [])
1246
+
1247
+ if not claims:
1248
+ await _send_json(send, status=200, payload={"questions": []})
1249
+ return
1250
+
1251
+ query_claims: dict[str, list] = defaultdict(list)
1252
+ for c in claims:
1253
+ query_claims[c.get("query", "")].append(c)
1254
+
1255
+ panel_records = []
1256
+ for query, qclaims in query_claims.items():
1257
+ confs = [c.get("confidence", 0.5) for c in qclaims if c.get("confidence") is not None]
1258
+ if not confs:
1259
+ continue
1260
+ disputed = [c for c in qclaims if c.get("disputes_received", 0) > 0]
1261
+ panel_records.append(
1262
+ {
1263
+ "query": query,
1264
+ "mean_confidence": sum(confs) / len(confs),
1265
+ "spread": max(confs) - min(confs) if len(confs) > 1 else 0.0,
1266
+ "disputes": [
1267
+ {"from_persona": c.get("persona", "unknown"), "delta": 0.35} for c in disputed
1268
+ ],
1269
+ "per_agent": [
1270
+ {
1271
+ "persona": c.get("persona", ""),
1272
+ "reasoning": c.get("reasoning", ""),
1273
+ "confidence": c.get("confidence", 0.5),
1274
+ }
1275
+ for c in qclaims
1276
+ ],
1277
+ }
1278
+ )
1279
+
1280
+ followups = generate_followup_questions(panel_records, top_k=top_k)
1281
+ await _send_json(send, status=200, payload={"questions": followups})
1282
+
1283
+
1284
+ def _render_report_markdown(report: dict) -> str:
1285
+ """Render a report data dict to a Markdown string."""
1286
+ import datetime
1287
+
1288
+ lines = []
1289
+ dt = datetime.datetime.fromtimestamp(report.get("generated_at", 0)).strftime(
1290
+ "%Y-%m-%d %H:%M UTC"
1291
+ )
1292
+ topic = report.get("topic") or "All Topics"
1293
+
1294
+ lines.append("# Swarm Intelligence Report")
1295
+ lines.append("")
1296
+ lines.append(
1297
+ f"**Topic:** {topic} | **Generated:** {dt} | "
1298
+ f"**Simulations:** {report.get('n_sims', 0)} | **Agents:** {report.get('n_agents', 0)}"
1299
+ )
1300
+ lines.append("")
1301
+ lines.append("---")
1302
+ lines.append("")
1303
+ lines.append("## Abstract")
1304
+ lines.append("")
1305
+ lines.append(report.get("abstract", ""))
1306
+ lines.append("")
1307
+ lines.append("---")
1308
+ lines.append("")
1309
+ lines.append("## Key Findings")
1310
+ lines.append("")
1311
+ lines.append("| # | Question | Confidence | Spread | Verdict | Sims | Calibration |")
1312
+ lines.append("|---|----------|-----------|--------|---------|------|-------------|")
1313
+
1314
+ verdict_emoji = {"YES": "✅", "LEAN YES": "🟢", "SPLIT": "🟡", "LEAN NO": "🟠", "NO": "❌"}
1315
+
1316
+ for i, kf in enumerate(report.get("key_findings", []), 1):
1317
+ ve = verdict_emoji.get(kf.get("verdict", ""), "")
1318
+ q_short = kf.get("query", "")[:80] + ("…" if len(kf.get("query", "")) > 80 else "")
1319
+ cal = f"{kf['calibration_error']:.3f}" if kf.get("calibration_error") is not None else "—"
1320
+ lines.append(
1321
+ f"| {i} | {q_short} | {kf.get('mean_conf', 0):.1%} | ±{kf.get('spread', 0):.1%} "
1322
+ f"| {ve} {kf.get('verdict', '')} | {kf.get('n_sims', 0)} | {cal} |"
1323
+ )
1324
+
1325
+ lines.append("")
1326
+ lines.append("---")
1327
+ lines.append("")
1328
+ lines.append("## Per-Question Analysis")
1329
+ lines.append("")
1330
+
1331
+ for i, pq in enumerate(report.get("per_question", []), 1):
1332
+ lines.append(f"### Q{i}: {pq.get('query', '')}")
1333
+ lines.append("")
1334
+ lines.append(
1335
+ f"**Panel consensus:** {pq.get('mean_conf', 0):.1%} "
1336
+ f"(spread: ±{pq.get('spread', 0):.1%}) | **Verdict:** {pq.get('verdict', '')}"
1337
+ )
1338
+ lines.append("")
1339
+ lines.append(
1340
+ f"**Disputes:** {pq.get('n_disputes', 0)} | **Citations:** {pq.get('n_citations', 0)}"
1341
+ )
1342
+ lines.append("")
1343
+ for pr in pq.get("per_persona_reasoning", []):
1344
+ lines.append(f"**{pr.get('persona', '')}** ({pr.get('mean_confidence', 0):.1%})")
1345
+ reasoning = pr.get("reasoning", "").strip()
1346
+ if reasoning:
1347
+ lines.append(f"> {reasoning[:500]}")
1348
+ lines.append("")
1349
+
1350
+ lines.append("---")
1351
+ lines.append("")
1352
+ lines.append("## Persona Forensics")
1353
+ lines.append("")
1354
+ lines.append("| Persona | Brier Score | Claims | Citations | Resolved | Runs |")
1355
+ lines.append("|---------|------------|--------|-----------|----------|------|")
1356
+
1357
+ for pf in report.get("persona_forensics", []):
1358
+ lines.append(
1359
+ f"| {pf.get('emoji', '🤖')} {pf.get('label', pf.get('persona', ''))} "
1360
+ f"| {pf.get('brier', 0):.4f} | {pf.get('n_claims', 0)} "
1361
+ f"| {pf.get('citations_received', 0)} | {pf.get('n_resolved', 0)} "
1362
+ f"| {pf.get('runs', 0)} |"
1363
+ )
1364
+
1365
+ lines.append("")
1366
+ lines.append("---")
1367
+ lines.append("")
1368
+ lines.append("## Autonomous Follow-Up Questions")
1369
+ lines.append("")
1370
+ lines.append(
1371
+ "*The following questions were autonomously identified by the swarm "
1372
+ "as highest-priority for further investigation.*"
1373
+ )
1374
+ lines.append("")
1375
+
1376
+ trigger_labels = {
1377
+ "high_spread": "⚡ HIGH UNCERTAINTY",
1378
+ "dispute": "⚔️ DISPUTED",
1379
+ "low_confidence": "🌫️ AMBIGUOUS",
1380
+ "gap": "🔭 KNOWLEDGE GAP",
1381
+ }
1382
+
1383
+ for i, fq in enumerate(report.get("follow_up_questions", []), 1):
1384
+ label = trigger_labels.get(fq.get("trigger", ""), "❓")
1385
+ lines.append(f"{i}. **[{label}]** {fq.get('text', '')}")
1386
+ lines.append(f" > *{fq.get('rationale', '')[:200]}*")
1387
+ lines.append("")
1388
+
1389
+ return "\n".join(lines)
1390
+
1391
+
1392
+ async def _handle_report(body_bytes: bytes, send: _Send, *, force_markdown: bool = False) -> None:
1393
+ """POST /v1/report — generate a JSON (or Markdown) synthesis report."""
1394
+ import time as _time
1395
+ from collections import defaultdict
1396
+
1397
+ from hypermind.simlab._scenario_impl import generate_followup_questions
1398
+
1399
+ try:
1400
+ body = json.loads(body_bytes) if body_bytes else {}
1401
+ except Exception:
1402
+ body = {}
1403
+
1404
+ sim_ids = body.get("sim_ids")
1405
+ topic = body.get("topic") or ""
1406
+ persona_filter = body.get("persona") or ""
1407
+ fmt = "markdown" if force_markdown else body.get("format", "json")
1408
+
1409
+ idx = build_knowledge_index()
1410
+
1411
+ sim_id_filter = sim_ids[0] if sim_ids and len(sim_ids) == 1 else ""
1412
+
1413
+ overview = idx.overview()
1414
+ claims_resp = idx.list_claims(
1415
+ persona=persona_filter,
1416
+ topic=topic or "",
1417
+ sim_id=sim_id_filter,
1418
+ limit=2000,
1419
+ offset=0,
1420
+ )
1421
+ claims = claims_resp.get("items", [])
1422
+
1423
+ # Build scoreboard inline (mirrors _handle_knowledge_scoreboard)
1424
+ scoreboard_rows: list[dict] = []
1425
+ for ag in idx.agents.values():
1426
+ briers = []
1427
+ for c in idx.claims.values():
1428
+ if c.agent != ag.name or c.resolved_outcome is None:
1429
+ continue
1430
+ target = 1.0 if c.resolved_outcome else 0.0
1431
+ briers.append((c.confidence - target) ** 2)
1432
+ brier = sum(briers) / len(briers) if briers else 0.0
1433
+ scoreboard_rows.append(
1434
+ {
1435
+ "name": ag.name,
1436
+ "persona": ag.persona,
1437
+ "accuracy": ag.accuracy,
1438
+ "brier": brier,
1439
+ "n_resolved": len(briers),
1440
+ "n_claims": ag.claims_published,
1441
+ "citations_received": ag.citations_received,
1442
+ "last_reputation": ag.last_reputation,
1443
+ "runs": ag.runs_participated,
1444
+ }
1445
+ )
1446
+
1447
+ query_map: dict[str, list] = defaultdict(list)
1448
+ for c in claims:
1449
+ query_map[c.get("query", "")].append(c)
1450
+
1451
+ key_findings = []
1452
+ per_question = []
1453
+
1454
+ for query, qclaims in sorted(query_map.items(), key=lambda x: -len(x[1])):
1455
+ confs = [c.get("confidence", 0.5) for c in qclaims if c.get("confidence") is not None]
1456
+ if not confs:
1457
+ continue
1458
+ mean_conf = sum(confs) / len(confs)
1459
+ spread = max(confs) - min(confs) if len(confs) > 1 else 0.0
1460
+
1461
+ if mean_conf >= 0.70:
1462
+ verdict = "YES"
1463
+ elif mean_conf >= 0.55:
1464
+ verdict = "LEAN YES"
1465
+ elif mean_conf >= 0.45:
1466
+ verdict = "SPLIT"
1467
+ elif mean_conf >= 0.30:
1468
+ verdict = "LEAN NO"
1469
+ else:
1470
+ verdict = "NO"
1471
+
1472
+ gt_vals = [c.get("ground_truth") for c in qclaims if c.get("ground_truth") is not None]
1473
+ gt = gt_vals[0] if gt_vals else None
1474
+ cal_error = abs(mean_conf - gt) if gt is not None else None
1475
+
1476
+ resolved = [c for c in qclaims if c.get("resolved_outcome") is not None]
1477
+ n_sims_q = len({c.get("sim_id") for c in qclaims})
1478
+
1479
+ key_findings.append(
1480
+ {
1481
+ "query": query,
1482
+ "mean_conf": round(mean_conf, 4),
1483
+ "spread": round(spread, 4),
1484
+ "verdict": verdict,
1485
+ "n_sims": n_sims_q,
1486
+ "n_claims": len(qclaims),
1487
+ "calibration_error": round(cal_error, 4) if cal_error is not None else None,
1488
+ "ground_truth": gt,
1489
+ "n_resolved": len(resolved),
1490
+ }
1491
+ )
1492
+
1493
+ persona_map_q: dict[str, list] = defaultdict(list)
1494
+ for c in qclaims:
1495
+ persona_map_q[c.get("persona", "unknown")].append(c)
1496
+
1497
+ persona_reasoning: list[dict] = []
1498
+ for p, pclaims in persona_map_q.items():
1499
+ pconfs = [c.get("confidence", 0.5) for c in pclaims]
1500
+ per_persona_mean = sum(pconfs) / len(pconfs)
1501
+ best = max(pclaims, key=lambda c: c.get("citations_received", 0))
1502
+ persona_reasoning.append(
1503
+ {
1504
+ "persona": p,
1505
+ "mean_confidence": round(per_persona_mean, 4),
1506
+ "n_claims": len(pclaims),
1507
+ "reasoning": best.get("reasoning", "")[:1000],
1508
+ }
1509
+ )
1510
+
1511
+ disputes_q = [c for c in qclaims if c.get("disputes_received", 0) > 0]
1512
+ citations_q = sum(c.get("citations_received", 0) for c in qclaims)
1513
+
1514
+ per_question.append(
1515
+ {
1516
+ "query": query,
1517
+ "mean_conf": round(mean_conf, 4),
1518
+ "spread": round(spread, 4),
1519
+ "verdict": verdict,
1520
+ "per_persona_reasoning": persona_reasoning,
1521
+ "n_disputes": len(disputes_q),
1522
+ "n_citations": citations_q,
1523
+ "convergence_curve": [],
1524
+ }
1525
+ )
1526
+
1527
+ key_findings.sort(key=lambda x: x["n_claims"], reverse=True)
1528
+
1529
+ # Persona forensics: aggregate by slug from scoreboard
1530
+ persona_agg: dict[str, dict] = {}
1531
+ for ag_row in scoreboard_rows:
1532
+ slug = ag_row.get("persona", "")
1533
+ if not slug:
1534
+ continue
1535
+ if slug not in persona_agg:
1536
+ persona_agg[slug] = {
1537
+ "persona": slug,
1538
+ "label": slug,
1539
+ "emoji": "🤖",
1540
+ "total_brier": 0.0,
1541
+ "n_brier": 0,
1542
+ "n_claims": 0,
1543
+ "citations_received": 0,
1544
+ "n_resolved": 0,
1545
+ "runs": 0,
1546
+ }
1547
+ pa = persona_agg[slug]
1548
+ if ag_row["brier"] > 0:
1549
+ pa["total_brier"] += ag_row["brier"]
1550
+ pa["n_brier"] += 1
1551
+ pa["n_claims"] += ag_row.get("n_claims", 0)
1552
+ pa["citations_received"] += ag_row.get("citations_received", 0)
1553
+ pa["n_resolved"] += ag_row.get("n_resolved", 0)
1554
+ pa["runs"] += ag_row.get("runs", 0)
1555
+
1556
+ try:
1557
+ from hypermind.simlab._scenario_impl import PERSONAS as _PERSONAS
1558
+
1559
+ for slug, pa in persona_agg.items():
1560
+ if slug in _PERSONAS:
1561
+ pa["label"] = _PERSONAS[slug].get("label", slug)
1562
+ pa["emoji"] = _PERSONAS[slug].get("emoji", "🤖")
1563
+ except Exception:
1564
+ pass
1565
+
1566
+ forensics_list: list[dict] = []
1567
+ for slug, pa in persona_agg.items():
1568
+ mean_brier = pa["total_brier"] / pa["n_brier"] if pa["n_brier"] > 0 else 0.0
1569
+ forensics_list.append(
1570
+ {
1571
+ "persona": slug,
1572
+ "label": pa["label"],
1573
+ "emoji": pa["emoji"],
1574
+ "brier": round(mean_brier, 4),
1575
+ "n_claims": pa["n_claims"],
1576
+ "citations_received": pa["citations_received"],
1577
+ "n_resolved": pa["n_resolved"],
1578
+ "runs": pa["runs"],
1579
+ }
1580
+ )
1581
+ forensics_list.sort(key=lambda x: x["brier"])
1582
+
1583
+ n_sims = overview.get("n_sims", 0)
1584
+ n_agents = overview.get("n_agents", 0)
1585
+ n_claims_total = len(claims)
1586
+ n_questions_total = len(key_findings)
1587
+ panel_accuracy = overview.get("panel_accuracy")
1588
+
1589
+ yes_count = sum(1 for kf in key_findings if kf["verdict"] in ("YES", "LEAN YES"))
1590
+ no_count = sum(1 for kf in key_findings if kf["verdict"] in ("NO", "LEAN NO"))
1591
+ split_count = sum(1 for kf in key_findings if kf["verdict"] == "SPLIT")
1592
+
1593
+ topic_str = f" on the topic of '{topic}'" if topic else ""
1594
+ accuracy_str = f" Panel calibration accuracy: {panel_accuracy:.1%}." if panel_accuracy else ""
1595
+ verdict_str = (
1596
+ f"The swarm reached affirmative verdicts (YES or LEAN YES) on {yes_count} of "
1597
+ f"{n_questions_total} questions, negative verdicts on {no_count}, "
1598
+ f"and split verdicts on {split_count}."
1599
+ if n_questions_total > 0
1600
+ else ""
1601
+ )
1602
+
1603
+ abstract = (
1604
+ f"This report synthesizes the deliberations of a {n_agents}-agent intelligence swarm "
1605
+ f"across {n_sims} simulation(s){topic_str}. "
1606
+ f"The swarm evaluated {n_questions_total} distinct question(s), producing {n_claims_total} total claims "
1607
+ f"with {overview.get('total_citations', 0)} citations and {overview.get('total_disputes', 0)} disputes. "
1608
+ f"{verdict_str}"
1609
+ f"{accuracy_str} "
1610
+ f"Personas contributing to this report: "
1611
+ f"{', '.join(pa['label'] for pa in forensics_list[:6])}."
1612
+ ).strip()
1613
+
1614
+ panel_records_for_followup = []
1615
+ for kf in key_findings:
1616
+ q_claims = query_map.get(kf["query"], [])
1617
+ disputed = [c for c in q_claims if c.get("disputes_received", 0) > 0]
1618
+ panel_records_for_followup.append(
1619
+ {
1620
+ "query": kf["query"],
1621
+ "mean_confidence": kf["mean_conf"],
1622
+ "spread": kf["spread"],
1623
+ "disputes": [
1624
+ {"from_persona": c.get("persona", ""), "delta": 0.35} for c in disputed
1625
+ ],
1626
+ "per_agent": [],
1627
+ }
1628
+ )
1629
+ followup_questions = generate_followup_questions(panel_records_for_followup, top_k=10)
1630
+
1631
+ report_data = {
1632
+ "generated_at": _time.time(),
1633
+ "topic": topic,
1634
+ "n_sims": n_sims,
1635
+ "n_agents": n_agents,
1636
+ "n_claims": n_claims_total,
1637
+ "n_questions": n_questions_total,
1638
+ "abstract": abstract,
1639
+ "key_findings": key_findings,
1640
+ "per_question": per_question,
1641
+ "persona_forensics": forensics_list,
1642
+ "follow_up_questions": followup_questions,
1643
+ }
1644
+
1645
+ if fmt == "markdown":
1646
+ md = _render_report_markdown(report_data)
1647
+ await _send_bytes(
1648
+ send,
1649
+ md.encode("utf-8"),
1650
+ content_type="text/markdown; charset=utf-8",
1651
+ filename="swarm-intelligence-report.md",
1652
+ )
1653
+ return
1654
+
1655
+ await _send_json(send, status=200, payload=report_data)
1656
+
1657
+
1658
+ async def _handle_stats(send: _Send) -> None:
1659
+ """GET /v1/stats — simlab-level federation summary.
1660
+
1661
+ Aggregates stats across all namespaces found in the sim registry.
1662
+ If all sims share a single namespace the response reflects that
1663
+ namespace; otherwise ``namespace`` is ``"all"``.
1664
+ """
1665
+ import hashlib
1666
+
1667
+ from .registry import load_config as _load_cfg
1668
+
1669
+ idx = build_knowledge_index()
1670
+ sealed_count = len(idx.claims)
1671
+ dispute_count = sum(len(v) for v in idx.disputes_to.values())
1672
+
1673
+ # Collect distinct namespaces from sim configs; fall back gracefully.
1674
+ namespaces: set[str] = set()
1675
+ rooms: set[str] = set()
1676
+ for sim in list_sims(limit=500):
1677
+ try:
1678
+ cfg = _load_cfg(sim.sim_id)
1679
+ ns = cfg.get("namespace")
1680
+ if ns:
1681
+ namespaces.add(ns)
1682
+ room = cfg.get("room")
1683
+ if room:
1684
+ rooms.add(room)
1685
+ except Exception:
1686
+ pass
1687
+
1688
+ if not namespaces:
1689
+ # Fall back to the default scenario namespace when no sim configs exist.
1690
+ try:
1691
+ from ._scenario_impl import NAMESPACE as _NS
1692
+ from ._scenario_impl import ROOM as _ROOM
1693
+
1694
+ namespaces = {_NS}
1695
+ rooms = {_ROOM}
1696
+ except Exception:
1697
+ namespaces = {"default"}
1698
+
1699
+ namespace = next(iter(namespaces)) if len(namespaces) == 1 else "all"
1700
+ ns_id = hashlib.sha256(namespace.encode()).hexdigest()[:32]
1701
+ payload = {
1702
+ "agent_kid": ns_id,
1703
+ "did_key": f"did:key:z{ns_id[:24]}",
1704
+ "namespace": namespace,
1705
+ "rooms": sorted(rooms) if rooms else [],
1706
+ "role": "operator",
1707
+ "ramp_paused": False,
1708
+ "world": {
1709
+ "namespace_id": ns_id,
1710
+ "sealed_count": sealed_count,
1711
+ "dispute_count": dispute_count,
1712
+ "tree_size": sealed_count,
1713
+ },
1714
+ }
1715
+ await _send_json(send, status=200, payload=payload)
1716
+
1717
+
1718
+ async def _handle_disputes(send: _Send) -> None:
1719
+ """GET /v1/disputes — collect dispute records across all completed sims."""
1720
+ all_sims = list_sims()
1721
+ disputes: list[dict] = []
1722
+ for sim in all_sims:
1723
+ if sim.state != "done":
1724
+ continue
1725
+ try:
1726
+ trace = load_trace(sim.sim_id)
1727
+ if trace is None:
1728
+ continue
1729
+ raw = trace if isinstance(trace, dict) else {}
1730
+ for pr in raw.get("panel_records", []):
1731
+ for d in pr.get("disputes", []):
1732
+ disputes.append(
1733
+ {
1734
+ "kid": d.get("dispute_kid", ""),
1735
+ "initiator": d.get("initiator_kid", d.get("kid", "")),
1736
+ "state": d.get("state", "CLOSED"),
1737
+ "bond_topic_scope": d.get("topic", pr.get("query", "")[:60]),
1738
+ "bond_amount": d.get("bond_amount", 0),
1739
+ "transitions": d.get("transitions", []),
1740
+ "sim_id": sim.sim_id,
1741
+ }
1742
+ )
1743
+ except Exception:
1744
+ continue
1745
+ await _send_json(send, status=200, payload={"count": len(disputes), "disputes": disputes})
1746
+
1747
+
1748
+ async def _handle_sim_agents(sim_id: str, send: _Send) -> None:
1749
+ """Return the agent roster for a sim (live or completed).
1750
+
1751
+ Sourced from the in-process persona registry populated when the
1752
+ runner builds profiles. Lets the live Activity canvas seed bubbles
1753
+ before the first event arrives.
1754
+ """
1755
+ if not sim_dir(sim_id).exists():
1756
+ await _err(send, 404, f"unknown sim: {sim_id}")
1757
+ return
1758
+ from . import _persona_registry
1759
+
1760
+ seen: dict[str, dict[str, str]] = {}
1761
+ # Walk the registry, emitting one row per unique display_name.
1762
+ with _persona_registry._LOCK:
1763
+ bucket = _persona_registry._REGISTRY.get(sim_id, {})
1764
+ for key, meta in bucket.items():
1765
+ name = meta.get("display_name") or key
1766
+ if name in seen:
1767
+ continue
1768
+ seen[name] = {
1769
+ "name": name,
1770
+ "persona": meta.get("persona", ""),
1771
+ "persona_emoji": meta.get("persona_emoji", ""),
1772
+ "role": meta.get("role", ""), # S5
1773
+ }
1774
+ await _send_json(send, status=200, payload={"agents": list(seen.values())})
1775
+
1776
+
1777
+ async def _handle_resolve_outcomes(sim_id: str, body_bytes: bytes, send: _Send) -> None:
1778
+ """POST /v1/sims/{id}/resolve — apply ground-truth resolutions, recompute EIG/Q + Brier."""
1779
+ import math
1780
+ import time as _time
1781
+
1782
+ from hypermind.eval.calibration import brier_score
1783
+ from hypermind.eval.swarm_iq import effective_information_gain_per_question
1784
+
1785
+ sd = sim_dir(sim_id)
1786
+ if not sd.exists():
1787
+ await _err(send, 404, f"unknown sim: {sim_id}")
1788
+ return
1789
+ status = load_status(sim_id)
1790
+ if status.get("state") == "unknown":
1791
+ await _err(send, 404, f"unknown sim: {sim_id}")
1792
+ return
1793
+ if status.get("state") != "done":
1794
+ await _err(send, 409, f"sim {sim_id!r} is not done (state={status.get('state')!r})")
1795
+ return
1796
+ trace = load_trace(sim_id)
1797
+ if trace is None:
1798
+ await _err(send, 404, f"no trace for sim: {sim_id}")
1799
+ return
1800
+
1801
+ try:
1802
+ payload = json.loads(body_bytes or b"{}")
1803
+ except json.JSONDecodeError as exc:
1804
+ await _err(send, 400, f"invalid JSON: {exc}")
1805
+ return
1806
+ resolutions = payload.get("resolutions")
1807
+ if not isinstance(resolutions, list) or not resolutions:
1808
+ await _err(send, 400, "body must have non-empty 'resolutions' array")
1809
+ return
1810
+
1811
+ panel_records: list[dict] = trace.get("panel_records") or []
1812
+ qid_to_idx: dict[str, int] = {}
1813
+ for i, pr in enumerate(panel_records):
1814
+ stored_qid = pr.get("query_id", "")
1815
+ if stored_qid:
1816
+ qid_to_idx[stored_qid] = i
1817
+ qid_to_idx[f"q-{i}"] = i
1818
+
1819
+ ts_now = int(_time.time() * 1000)
1820
+ applied: list[dict] = []
1821
+ for res in resolutions:
1822
+ qid = res.get("question_id")
1823
+ if qid is None:
1824
+ await _err(send, 400, "each resolution must have 'question_id'")
1825
+ return
1826
+ if qid not in qid_to_idx:
1827
+ await _err(send, 400, f"unknown question_id: {qid!r}")
1828
+ return
1829
+ outcome_raw = res.get("outcome")
1830
+ if outcome_raw is None or not isinstance(outcome_raw, bool):
1831
+ await _err(send, 400, f"resolution for {qid!r} must have boolean 'outcome'")
1832
+ return
1833
+ gt_raw = res.get("ground_truth")
1834
+ if gt_raw is None:
1835
+ gt_raw = 1.0 if outcome_raw else 0.0
1836
+ try:
1837
+ gt_f = float(gt_raw)
1838
+ except (TypeError, ValueError):
1839
+ await _err(send, 400, f"ground_truth for {qid!r} must be a float 0-1")
1840
+ return
1841
+ if not (0.0 <= gt_f <= 1.0):
1842
+ await _err(send, 400, f"ground_truth for {qid!r} must be in [0, 1]")
1843
+ return
1844
+
1845
+ idx = qid_to_idx[qid]
1846
+ panel_records[idx]["outcome"] = outcome_raw
1847
+ panel_records[idx]["ground_truth"] = gt_f
1848
+ panel_records[idx]["resolved_by"] = res.get("resolved_by")
1849
+ panel_records[idx]["source_url"] = res.get("source_url")
1850
+ panel_records[idx]["resolved_at_ms"] = res.get("resolved_at_ms") or ts_now
1851
+ applied.append({"question_id": qid, "idx": idx, "outcome": outcome_raw, "ground_truth": gt_f})
1852
+
1853
+ delib_records: list[dict] = trace.get("deliberation_records") or []
1854
+ eig_mean: float | None = None
1855
+ try:
1856
+ eig_result = effective_information_gain_per_question(panel_records, delib_records)
1857
+ raw = eig_result.get("eig_mean")
1858
+ eig_mean = None if (raw is None or (isinstance(raw, float) and math.isnan(raw))) else raw
1859
+ if not trace.get("collective_iq"):
1860
+ trace["collective_iq"] = {}
1861
+ trace["collective_iq"]["eig_mean"] = eig_mean
1862
+ trace["collective_iq"]["eig_per_q"] = [
1863
+ None if (isinstance(v, float) and math.isnan(v)) else v
1864
+ for v in (eig_result.get("eig_per_q") or [])
1865
+ ]
1866
+ trace["collective_iq"]["eig_n_scored"] = eig_result.get("n_scored")
1867
+ except Exception:
1868
+ pass
1869
+
1870
+ agent_brier_acc: dict[str, dict] = {}
1871
+ for pr in panel_records:
1872
+ gt_f = pr.get("ground_truth")
1873
+ outcome_b = pr.get("outcome")
1874
+ if gt_f is None or outcome_b is None:
1875
+ continue
1876
+ stored_qid = pr.get("query_id", "")
1877
+ for claim in pr.get("claims") or []:
1878
+ agent = claim.get("agent", "unknown")
1879
+ conf = claim.get("confidence")
1880
+ if conf is None:
1881
+ continue
1882
+ try:
1883
+ conf = float(conf)
1884
+ except (TypeError, ValueError):
1885
+ continue
1886
+ bs = brier_score(conf, gt_f)
1887
+ gap = abs(conf - float(outcome_b))
1888
+ if agent not in agent_brier_acc:
1889
+ agent_brier_acc[agent] = {"sum_sq": 0.0, "n": 0, "cal_gap_sum": 0.0, "hallucinations": []}
1890
+ agent_brier_acc[agent]["sum_sq"] += bs
1891
+ agent_brier_acc[agent]["n"] += 1
1892
+ agent_brier_acc[agent]["cal_gap_sum"] += gap
1893
+ if gap > 0.4:
1894
+ agent_brier_acc[agent]["hallucinations"].append({
1895
+ "question_id": stored_qid,
1896
+ "confidence": conf,
1897
+ "outcome": outcome_b,
1898
+ "gap": round(gap, 4),
1899
+ })
1900
+
1901
+ agent_brier_scores: dict[str, dict] = {}
1902
+ total_brier_sum = 0.0
1903
+ total_brier_n = 0
1904
+ hallucination_count = 0
1905
+ for agent, acc in agent_brier_acc.items():
1906
+ n = acc["n"]
1907
+ mean_b = acc["sum_sq"] / n if n > 0 else None
1908
+ cal_gap_mean = acc["cal_gap_sum"] / n if n > 0 else None
1909
+ agent_brier_scores[agent] = {
1910
+ "mean_brier": round(mean_b, 6) if mean_b is not None else None,
1911
+ "n": n,
1912
+ "calibration_gap_mean": round(cal_gap_mean, 4) if cal_gap_mean is not None else None,
1913
+ "hallucinations": acc["hallucinations"],
1914
+ }
1915
+ if mean_b is not None:
1916
+ total_brier_sum += mean_b
1917
+ total_brier_n += 1
1918
+ hallucination_count += len(acc["hallucinations"])
1919
+
1920
+ mean_brier = round(total_brier_sum / total_brier_n, 6) if total_brier_n > 0 else None
1921
+ trace["agent_brier_scores"] = agent_brier_scores
1922
+
1923
+ existing: list[dict] = trace.get("oracle_resolutions") or []
1924
+ existing_by_qid: dict[str, int] = {r.get("question_id", ""): i for i, r in enumerate(existing)}
1925
+ for a in applied:
1926
+ pr = panel_records[a["idx"]]
1927
+ rec = {
1928
+ "question_id": a["question_id"],
1929
+ "outcome": a["outcome"],
1930
+ "ground_truth": a["ground_truth"],
1931
+ "source_url": pr.get("source_url"),
1932
+ "resolved_by": pr.get("resolved_by"),
1933
+ "resolved_at_ms": pr.get("resolved_at_ms"),
1934
+ "ts_wall_ms": ts_now,
1935
+ }
1936
+ if a["question_id"] in existing_by_qid:
1937
+ existing[existing_by_qid[a["question_id"]]] = rec
1938
+ else:
1939
+ existing.append(rec)
1940
+ trace["oracle_resolutions"] = existing
1941
+
1942
+ _write_json_atomic(sim_dir(sim_id) / "trace.json", trace)
1943
+ n_resolved = len([r for r in existing if r.get("outcome") is not None])
1944
+ update_status(sim_id, n_resolved=n_resolved, mean_brier=mean_brier, hallucination_count=hallucination_count)
1945
+
1946
+ await _send_json(
1947
+ send,
1948
+ status=200,
1949
+ payload={
1950
+ "sim_id": sim_id,
1951
+ "n_resolved": n_resolved,
1952
+ "eig_mean": eig_mean,
1953
+ "mean_brier": mean_brier,
1954
+ "agent_brier_scores": agent_brier_scores,
1955
+ "hallucination_count": hallucination_count,
1956
+ },
1957
+ )
1958
+
1959
+
1960
+ async def _handle_get_outcomes(sim_id: str, send: _Send) -> None:
1961
+ """GET /v1/sims/{id}/outcomes — current resolution state for all questions."""
1962
+ sd = sim_dir(sim_id)
1963
+ if not sd.exists():
1964
+ await _err(send, 404, f"unknown sim: {sim_id}")
1965
+ return
1966
+ status = load_status(sim_id)
1967
+ if status.get("state") == "unknown":
1968
+ await _err(send, 404, f"unknown sim: {sim_id}")
1969
+ return
1970
+ if status.get("state") != "done":
1971
+ await _err(send, 409, f"sim {sim_id!r} is not done (state={status.get('state')!r})")
1972
+ return
1973
+ trace = load_trace(sim_id)
1974
+ if trace is None:
1975
+ await _err(send, 404, f"no trace for sim: {sim_id}")
1976
+ return
1977
+
1978
+ panel_records: list[dict] = trace.get("panel_records") or []
1979
+ items = []
1980
+ for i, pr in enumerate(panel_records):
1981
+ resolved = pr.get("outcome") is not None
1982
+ items.append({
1983
+ "question_id": pr.get("query_id") or f"q-{i}",
1984
+ "query": pr.get("query", ""),
1985
+ "resolved": resolved,
1986
+ "outcome": pr.get("outcome"),
1987
+ "ground_truth": pr.get("ground_truth"),
1988
+ "resolved_by": pr.get("resolved_by"),
1989
+ "source_url": pr.get("source_url"),
1990
+ "resolved_at_ms": pr.get("resolved_at_ms"),
1991
+ })
1992
+
1993
+ n_resolved = sum(1 for it in items if it["resolved"])
1994
+ await _send_json(
1995
+ send,
1996
+ status=200,
1997
+ payload={
1998
+ "sim_id": sim_id,
1999
+ "n_total": len(items),
2000
+ "n_resolved": n_resolved,
2001
+ "n_pending": len(items) - n_resolved,
2002
+ "items": items,
2003
+ "agent_brier_scores": trace.get("agent_brier_scores") or {},
2004
+ },
2005
+ )
2006
+
2007
+
2008
+ async def _handle_adapt_topic(qs: bytes, send: _Send) -> None:
2009
+ """GET /v1/sims/adapt?topic=… — preview inferred config for a topic."""
2010
+ from .topic_adapter import infer_topic_profile
2011
+
2012
+ topic = _qs_param(qs, "topic", "")
2013
+ if not topic:
2014
+ await _err(send, 400, "topic query parameter is required")
2015
+ return
2016
+ no_llm = _qs_param(qs, "no_llm", "false").lower() in ("1", "true", "yes")
2017
+ model = _qs_param(qs, "model", "") or "openai/gpt-4o-mini"
2018
+ profile = await infer_topic_profile(topic, model=model, no_llm=no_llm)
2019
+ await _send_json(send, status=200, payload={
2020
+ "topic": topic,
2021
+ "profile": profile.profile_name,
2022
+ "rationale": profile.rationale,
2023
+ "n_questions": profile.n_questions,
2024
+ "personas": profile.personas,
2025
+ "replicas": profile.replicas,
2026
+ "deliberation_rounds": profile.deliberation_rounds,
2027
+ "n_agents": profile.personas * profile.replicas,
2028
+ "suggested_personas": profile.suggested_personas,
2029
+ })
2030
+
2031
+
2032
+ async def _handle_related_sims(qs: bytes, send: _Send) -> None:
2033
+ """GET /v1/sims/related?topic=… — find related sims by topic similarity."""
2034
+ from .learning import find_related_sims
2035
+
2036
+ topic = _qs_param(qs, "topic", "")
2037
+ if not topic:
2038
+ await _err(send, 400, "topic query parameter is required")
2039
+ return
2040
+ try:
2041
+ limit = max(1, min(20, int(_qs_param(qs, "limit", "5"))))
2042
+ except ValueError:
2043
+ limit = 5
2044
+ results = find_related_sims(topic, limit=limit)
2045
+ await _send_json(send, status=200, payload={"topic": topic, "sims": results})
2046
+
2047
+
2048
+ async def _handle_knowledge_reuse(sim_id: str, send: _Send) -> None:
2049
+ """GET /v1/sims/{id}/knowledge-reuse — cross-sim learning efficiency stats."""
2050
+ from .learning import compute_knowledge_reuse
2051
+
2052
+ if not sim_dir(sim_id).exists():
2053
+ await _err(send, 404, f"unknown sim: {sim_id}")
2054
+ return
2055
+ result = compute_knowledge_reuse(sim_id)
2056
+ if result is None:
2057
+ await _err(send, 409, f"sim {sim_id!r} is not done or has no trace")
2058
+ return
2059
+ await _send_json(send, status=200, payload=result)
2060
+
2061
+
2062
+ async def _handle_relata_recall(sim_id: str, qs: bytes, send: _Send) -> None:
2063
+ """GET /v1/sims/{id}/relata-recall?q=…&agent=…&top_k=5
2064
+
2065
+ Query each agent's private Relata memory for the given search string.
2066
+ Returns ranked rows per agent. Requires RELATA_URL to be configured.
2067
+ """
2068
+ import os
2069
+ import urllib.parse
2070
+
2071
+ url = os.getenv("RELATA_URL")
2072
+ if not url:
2073
+ await _send_json(send, 200, {"error": "RELATA_URL not set", "rows": []})
2074
+ return
2075
+
2076
+ params = dict(urllib.parse.parse_qsl(qs.decode()))
2077
+ q = params.get("q", "").strip()
2078
+ agent_filter = params.get("agent", "")
2079
+ top_k = max(1, min(20, int(params.get("top_k", "5"))))
2080
+
2081
+ trace_file = sim_dir(sim_id) / "trace.json"
2082
+ if not trace_file.exists():
2083
+ await _err(send, 404, f"sim {sim_id!r} not found or not complete")
2084
+ return
2085
+
2086
+ import json as _json
2087
+ trace = _json.loads(trace_file.read_text())
2088
+
2089
+ try:
2090
+ from hypermind.mind.memory_store import RelataMemoryStore
2091
+ except ImportError:
2092
+ await _send_json(send, 200, {"error": "hypermind[relata] not installed", "rows": []})
2093
+ return
2094
+
2095
+ results = []
2096
+ for agent in trace.get("agents", []):
2097
+ kid_hex = agent.get("kid", "")
2098
+ if agent_filter and kid_hex != agent_filter:
2099
+ continue
2100
+ try:
2101
+ kid_bytes = bytes.fromhex(kid_hex)
2102
+ store = RelataMemoryStore(
2103
+ url,
2104
+ namespace_id=f"simlab-{sim_id[:12]}",
2105
+ agent_kid=kid_bytes,
2106
+ purpose="agent_notes",
2107
+ )
2108
+ store.open()
2109
+ rows = store.recall(q, top_k=top_k) if q else []
2110
+ store.close()
2111
+ results.append({
2112
+ "agent": agent.get("persona", kid_hex[:12]),
2113
+ "kid": kid_hex[:16],
2114
+ "rows": rows,
2115
+ })
2116
+ except Exception as exc:
2117
+ results.append({"agent": agent.get("persona", kid_hex[:12]), "kid": kid_hex[:16], "rows": [], "error": str(exc)})
2118
+
2119
+ await _send_json(send, 200, {"sim_id": sim_id, "q": q, "results": results})
2120
+
2121
+
2122
+ async def _handle_persona_trajectory(persona: str, send: _Send) -> None:
2123
+ """GET /v1/personas/{slug}/trajectory — Brier history across sims."""
2124
+ from .learning import get_persona_trajectory
2125
+
2126
+ result = get_persona_trajectory(persona)
2127
+ await _send_json(send, status=200, payload=result)
2128
+
2129
+
2130
+ async def _handle_sim_events_stream(sim_id: str, send: _Send) -> None:
2131
+ if not sim_dir(sim_id).exists():
2132
+ await _err(send, 404, f"unknown sim: {sim_id}")
2133
+ return
2134
+ # Per-sim recorder lookup. The runner created Recorder.scoped(sim_id);
2135
+ # we re-create the same scoped instance here — both will share state
2136
+ # because Recorder.scoped() returns a fresh recorder, but the runner's
2137
+ # recorder is what emit() calls land on. Instead, tail the global
2138
+ # recorder filtered by namespace_id.
2139
+ from hypermind.observability.recorder import get_recorder
2140
+
2141
+ recorder = get_recorder()
2142
+
2143
+ await send(
2144
+ {
2145
+ "type": "http.response.start",
2146
+ "status": 200,
2147
+ "headers": [
2148
+ (b"content-type", b"text/event-stream"),
2149
+ (b"cache-control", b"no-cache"),
2150
+ (b"connection", b"keep-alive"),
2151
+ (b"access-control-allow-origin", b"*"),
2152
+ ],
2153
+ }
2154
+ )
2155
+ await send(
2156
+ {
2157
+ "type": "http.response.body",
2158
+ "body": b": connected\n\n",
2159
+ "more_body": True,
2160
+ }
2161
+ )
2162
+
2163
+ from . import _persona_registry
2164
+
2165
+ _HEARTBEAT_INTERVAL = 15.0 # seconds between SSE heartbeat comments
2166
+
2167
+ tail_iter = recorder.tail()
2168
+ try:
2169
+ while True:
2170
+ try:
2171
+ record = await asyncio.wait_for(
2172
+ tail_iter.__anext__(), # type: ignore[attr-defined]
2173
+ timeout=_HEARTBEAT_INTERVAL,
2174
+ )
2175
+ except TimeoutError:
2176
+ # No event in the last 15 s — send a heartbeat comment to
2177
+ # keep the connection alive through proxies/load balancers.
2178
+ await send(
2179
+ {
2180
+ "type": "http.response.body",
2181
+ "body": b": heartbeat\n\n",
2182
+ "more_body": True,
2183
+ }
2184
+ )
2185
+ continue
2186
+ except StopAsyncIteration:
2187
+ break
2188
+ # I0: per-sim isolation is now enforced at the source — the
2189
+ # runner constructs Recorder.scoped(sim_id) and passes it
2190
+ # through testbed_cohort → _NamespaceWorld → agent. Every
2191
+ # agent's emit() lands a record with namespace_id=sim_id, so
2192
+ # the only filtering we need is the cheap equality check.
2193
+ # Records with no namespace_id (legacy / direct-driver paths)
2194
+ # are kept for back-compat but excluded from concurrent sims.
2195
+ meta = _persona_registry.lookup(sim_id, record.agent_id)
2196
+ if record.namespace_id is None:
2197
+ # Legacy / unscoped event: only show it on a sim stream
2198
+ # if the agent is registered for this sim (back-compat
2199
+ # for non-runner code paths).
2200
+ if not meta:
2201
+ continue
2202
+ elif record.namespace_id != sim_id:
2203
+ continue
2204
+ display_name = (meta or {}).get("display_name") or record.agent_id
2205
+ payload = {
2206
+ "ts_wall_ms": record.ts_wall_ms,
2207
+ "namespace": record.namespace,
2208
+ "agent_id": display_name, # friendly name for UI
2209
+ "agent_kid": record.agent_id, # kid hex for joins
2210
+ "event_type": record.event_type,
2211
+ "severity": record.severity,
2212
+ "fields": record.fields,
2213
+ "sim_id": sim_id,
2214
+ "persona": (meta or {}).get("persona", ""),
2215
+ "persona_emoji": (meta or {}).get("persona_emoji", ""),
2216
+ "role": (meta or {}).get("role", ""), # S5
2217
+ }
2218
+ chunk = f"data: {json.dumps(payload, default=str)}\n\n".encode()
2219
+ await send(
2220
+ {
2221
+ "type": "http.response.body",
2222
+ "body": chunk,
2223
+ "more_body": True,
2224
+ }
2225
+ )
2226
+ except (asyncio.CancelledError, GeneratorExit):
2227
+ return
2228
+ except Exception:
2229
+ return
2230
+
2231
+
2232
+ # ---- App factory -----------------------------------------------------------
2233
+
2234
+
2235
+ def simlab_app() -> Callable[[_Scope, _Receive, _Send], Awaitable[None]]:
2236
+ """Build the SimLab ASGI app. Idempotent — safe to call multiple times."""
2237
+ dist = _ui_dist_dir()
2238
+
2239
+ async def app(scope: _Scope, receive: _Receive, send: _Send) -> None:
2240
+ if scope["type"] == "lifespan":
2241
+ while True:
2242
+ msg = await receive()
2243
+ if msg["type"] == "lifespan.startup":
2244
+ # Lazy-start the runner on app startup so the queue
2245
+ # worker is alive before any POST arrives. The
2246
+ # worker's first tick reaps any orphan sims (left
2247
+ # "running"/"queued" by a previous process that died).
2248
+ get_runner().start()
2249
+ from .namespace import ensure_default
2250
+
2251
+ ensure_default()
2252
+ await send({"type": "lifespan.startup.complete"})
2253
+ elif msg["type"] == "lifespan.shutdown":
2254
+ await get_runner().stop()
2255
+ await send({"type": "lifespan.shutdown.complete"})
2256
+ return
2257
+
2258
+ if scope["type"] != "http":
2259
+ return
2260
+
2261
+ method: str = scope.get("method", "GET").upper()
2262
+ path: str = scope.get("path", "/")
2263
+
2264
+ # CORS preflight — accept any origin since this is a localhost app
2265
+ if method == "OPTIONS":
2266
+ await send(
2267
+ {
2268
+ "type": "http.response.start",
2269
+ "status": 204,
2270
+ "headers": [
2271
+ (b"access-control-allow-origin", b"*"),
2272
+ (b"access-control-allow-methods", b"GET, POST, PUT, DELETE, OPTIONS"),
2273
+ (b"access-control-allow-headers", b"content-type"),
2274
+ ],
2275
+ }
2276
+ )
2277
+ await send({"type": "http.response.body", "body": b"", "more_body": False})
2278
+ return
2279
+
2280
+ # Health
2281
+ if method == "GET" and path == "/v1/healthz":
2282
+ import os
2283
+
2284
+ llm_available = bool(os.environ.get("OPENROUTER_API_KEY", "").strip())
2285
+ await _send_json(
2286
+ send,
2287
+ status=200,
2288
+ payload={
2289
+ "ok": True,
2290
+ "version": __version__,
2291
+ "llm_available": llm_available,
2292
+ },
2293
+ )
2294
+ return
2295
+
2296
+ # /v1/modes — deliberation-mode registry (schema-driven SimNew form)
2297
+ if method == "GET" and path == "/v1/modes":
2298
+ await _handle_list_modes(send)
2299
+ return
2300
+
2301
+ # /v1/sims listing
2302
+ if method == "GET" and path == "/v1/sims":
2303
+ await _handle_list_sims(send)
2304
+ return
2305
+
2306
+ # /v1/sims POST (new)
2307
+ if method == "POST" and path == "/v1/sims":
2308
+ body = await _read_body(receive)
2309
+ await _handle_create_sim(body, send)
2310
+ return
2311
+
2312
+ # /v1/knowledge endpoints
2313
+ # /v1/personas/* + /v1/swarms/*
2314
+ if method == "GET" and path == "/v1/personas":
2315
+ await _handle_list_personas(send)
2316
+ return
2317
+ if method == "POST" and path == "/v1/personas":
2318
+ body = await _read_body(receive)
2319
+ await _handle_create_persona(body, send)
2320
+ return
2321
+ if method == "POST" and path == "/v1/personas/generate":
2322
+ body = await _read_body(receive)
2323
+ await _handle_generate_persona(body, send)
2324
+ return
2325
+ if method == "POST" and path == "/v1/personas/batch-generate":
2326
+ body = await _read_body(receive)
2327
+ await _handle_batch_generate_personas(body, send)
2328
+ return
2329
+ if path.startswith("/v1/personas/"):
2330
+ slug = path[len("/v1/personas/") :]
2331
+ # trajectory sub-route must be checked before bare slug GET
2332
+ if method == "GET" and slug.endswith("/trajectory"):
2333
+ persona_slug = slug[: -len("/trajectory")]
2334
+ await _handle_persona_trajectory(persona_slug, send)
2335
+ return
2336
+ if method == "GET":
2337
+ await _handle_get_persona(slug, send)
2338
+ return
2339
+ if method == "PUT":
2340
+ body = await _read_body(receive)
2341
+ await _handle_update_persona(slug, body, send)
2342
+ return
2343
+ if method == "DELETE":
2344
+ await _handle_delete_persona(slug, send)
2345
+ return
2346
+
2347
+ # ---- /v1/namespaces -------------------------------------------------------
2348
+ if path == "/v1/namespaces" and method == "GET":
2349
+ from .namespace import list_namespaces
2350
+
2351
+ await _send_json(send, status=200, payload=[n.to_dict() for n in list_namespaces()])
2352
+ return
2353
+ if path == "/v1/namespaces" and method == "POST":
2354
+ body = await _read_body(receive)
2355
+ await _handle_create_namespace(body, send)
2356
+ return
2357
+ if path.startswith("/v1/namespaces/") and method == "GET":
2358
+ slug = path[len("/v1/namespaces/") :]
2359
+ from .namespace import get_namespace
2360
+
2361
+ ns = get_namespace(slug)
2362
+ if ns is None:
2363
+ await _send_json(
2364
+ send, status=404, payload={"error": f"namespace {slug!r} not found"}
2365
+ )
2366
+ else:
2367
+ await _send_json(send, status=200, payload=ns.to_dict())
2368
+ return
2369
+
2370
+ # ---- Tool library (/v1/tools/*) -----------------------------------
2371
+ if method == "GET" and path == "/v1/tools/catalog":
2372
+ await _handle_list_tool_catalog(send)
2373
+ return
2374
+ if method == "GET" and path == "/v1/tools/activated":
2375
+ await _handle_list_activated_tools(send)
2376
+ return
2377
+ if method == "POST" and path == "/v1/tools/activate":
2378
+ body = await _read_body(receive)
2379
+ await _handle_activate_tool(body, send)
2380
+ return
2381
+ if path.startswith("/v1/tools/"):
2382
+ tool_tail = path[len("/v1/tools/") :]
2383
+ tool_parts = tool_tail.split("/", 1)
2384
+ tool_slug = tool_parts[0]
2385
+ tool_sub = tool_parts[1] if len(tool_parts) > 1 else ""
2386
+ if method == "GET" and tool_sub == "":
2387
+ await _handle_get_tool_def(tool_slug, send)
2388
+ return
2389
+ if method == "POST" and tool_sub == "test":
2390
+ body = await _read_body(receive)
2391
+ await _handle_test_tool(tool_slug, send)
2392
+ return
2393
+ if method == "POST" and tool_sub == "activate":
2394
+ body = await _read_body(receive)
2395
+ await _handle_set_tool_active(tool_slug, send)
2396
+ return
2397
+ if method == "PUT" and tool_sub == "credentials":
2398
+ body = await _read_body(receive)
2399
+ await _handle_update_tool_credentials(tool_slug, body, send)
2400
+ return
2401
+ if method == "DELETE" and tool_sub == "deactivate":
2402
+ await _handle_deactivate_tool(tool_slug, send)
2403
+ return
2404
+
2405
+ if method == "GET" and path == "/v1/swarms":
2406
+ await _handle_list_swarms(send)
2407
+ return
2408
+ if method == "POST" and path == "/v1/swarms":
2409
+ body = await _read_body(receive)
2410
+ await _handle_create_swarm(body, send)
2411
+ return
2412
+ if path.startswith("/v1/swarms/"):
2413
+ slug = path[len("/v1/swarms/") :]
2414
+ if method == "GET":
2415
+ await _handle_get_swarm(slug, send)
2416
+ return
2417
+ if method == "PUT":
2418
+ body = await _read_body(receive)
2419
+ await _handle_update_swarm(slug, body, send)
2420
+ return
2421
+ if method == "DELETE":
2422
+ await _handle_delete_swarm(slug, send)
2423
+ return
2424
+
2425
+ if method == "GET" and path == "/v1/knowledge":
2426
+ await _handle_knowledge_overview(send)
2427
+ return
2428
+ if method == "GET" and path == "/v1/knowledge/claims":
2429
+ await _handle_knowledge_claims(scope.get("query_string", b""), send)
2430
+ return
2431
+ if method == "GET" and path == "/v1/knowledge/agents":
2432
+ await _handle_knowledge_agents(send)
2433
+ return
2434
+ if method == "GET" and path == "/v1/knowledge/topics":
2435
+ await _handle_knowledge_topics(send)
2436
+ return
2437
+ if method == "GET" and path == "/v1/knowledge/ask":
2438
+ await _handle_knowledge_ask(scope.get("query_string", b""), send)
2439
+ return
2440
+ if method == "GET" and path == "/v1/knowledge/scoreboard":
2441
+ await _handle_knowledge_scoreboard(send)
2442
+ return
2443
+ if method == "GET" and path == "/v1/knowledge/synthesis":
2444
+ await _handle_knowledge_synthesis(scope.get("query_string", b""), send)
2445
+ return
2446
+ if method == "GET" and path == "/v1/knowledge/followup":
2447
+ await _handle_knowledge_followup(scope.get("query_string", b""), send)
2448
+ return
2449
+ if method == "POST" and path == "/v1/report":
2450
+ body = await _read_body(receive)
2451
+ await _handle_report(body, send)
2452
+ return
2453
+ if method == "GET" and path == "/v1/report/markdown":
2454
+ await _handle_report(b"", send, force_markdown=True)
2455
+ return
2456
+ if method == "GET" and path == "/v1/lineage":
2457
+ await _handle_lineage(send)
2458
+ return
2459
+
2460
+ if method == "GET" and path == "/v1/stats":
2461
+ await _handle_stats(send)
2462
+ return
2463
+
2464
+ if method == "GET" and path == "/v1/disputes":
2465
+ await _handle_disputes(send)
2466
+ return
2467
+ if method == "GET" and path.startswith("/v1/knowledge/similar/"):
2468
+ kid = path[len("/v1/knowledge/similar/") :]
2469
+ await _handle_knowledge_similar(kid, scope.get("query_string", b""), send)
2470
+ return
2471
+ if method == "GET" and path.startswith("/v1/knowledge/claim/"):
2472
+ kid = path[len("/v1/knowledge/claim/") :]
2473
+ await _handle_knowledge_claim(kid, send)
2474
+ return
2475
+
2476
+ # /v1/sims/related + /v1/sims/adapt — must be before the {id} block
2477
+ if method == "GET" and path == "/v1/sims/related":
2478
+ await _handle_related_sims(scope.get("query_string", b""), send)
2479
+ return
2480
+ if method == "GET" and path == "/v1/sims/adapt":
2481
+ await _handle_adapt_topic(scope.get("query_string", b""), send)
2482
+ return
2483
+
2484
+ # /v1/sims/{id}/resolve (POST) — oracle outcome resolution
2485
+ if path.startswith("/v1/sims/") and method == "POST":
2486
+ tail = path[len("/v1/sims/") :]
2487
+ parts = tail.split("/", 1)
2488
+ sim_id = parts[0]
2489
+ sub = parts[1] if len(parts) > 1 else ""
2490
+ if not _SIM_ID_RE.match(sim_id):
2491
+ await _err(send, 400, f"invalid sim_id: {sim_id!r}")
2492
+ return
2493
+ if sub == "resolve":
2494
+ body = await _read_body(receive)
2495
+ await _handle_resolve_outcomes(sim_id, body, send)
2496
+ return
2497
+
2498
+ # /v1/sims/{id} and friends
2499
+ if path.startswith("/v1/sims/") and method == "GET":
2500
+ tail = path[len("/v1/sims/") :]
2501
+ parts = tail.split("/", 1)
2502
+ sim_id = parts[0]
2503
+ sub = parts[1] if len(parts) > 1 else ""
2504
+ if not _SIM_ID_RE.match(sim_id):
2505
+ await _err(send, 400, f"invalid sim_id: {sim_id!r}")
2506
+ return
2507
+ if sub == "":
2508
+ await _handle_get_trace(sim_id, send)
2509
+ return
2510
+ if sub == "status":
2511
+ await _handle_get_status(sim_id, send)
2512
+ return
2513
+ if sub == "stderr":
2514
+ await _handle_get_stderr(sim_id, send)
2515
+ return
2516
+ if sub == "agents":
2517
+ await _handle_sim_agents(sim_id, send)
2518
+ return
2519
+ if sub == "outcomes":
2520
+ await _handle_get_outcomes(sim_id, send)
2521
+ return
2522
+ if sub == "knowledge-reuse":
2523
+ await _handle_knowledge_reuse(sim_id, send)
2524
+ return
2525
+ if sub == "relata-recall":
2526
+ await _handle_relata_recall(sim_id, qs, send)
2527
+ return
2528
+ if sub == "events/stream":
2529
+ await _handle_sim_events_stream(sim_id, send)
2530
+ return
2531
+
2532
+ # Static SPA — last resort
2533
+ if await _serve_static(scope, send, dist=dist):
2534
+ return
2535
+
2536
+ await _err(send, 404, f"unknown route: {method} {path}")
2537
+
2538
+ return app