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,254 @@
1
+ """Namespace-level policy primitives.
2
+
3
+ Each namespace has a single root-signed ``NamespacePolicy`` that
4
+ declares the federation rules every member MUST honour. v0.7 adds
5
+ ``min_signature_alg`` — the explicit pin that lets a namespace REFUSE
6
+ classical-only Ed25519 statements once the federation is ready to be
7
+ "PQ-only".
8
+
9
+ Existing pq-related controls:
10
+
11
+ * ``pq_required`` (per-statement, in the protected header) is the
12
+ per-message gate that says "this statement was signed with a PQ-hybrid
13
+ key". Verifiers MAY require it on individual records.
14
+ * ``min_signature_alg`` (this module) is the per-namespace floor; a
15
+ statement whose ``alg`` does not meet the floor is rejected with
16
+ ``rule_id="HM-MIN-ALG-001"`` regardless of its ``pq_required`` flag.
17
+
18
+ Forward-compatibility: future PQ-only algorithms (e.g. SLH-DSA-only
19
+ profiles) extend ``MIN_ALG_ORDERING`` without breaking existing pins.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from dataclasses import dataclass
25
+ from typing import Any, Literal
26
+
27
+ from hypermind.crypto.signing import ALG_ED25519, ALG_MLDSA65_ED25519
28
+ from hypermind.errors import BlockReason
29
+ from hypermind.rule_ids import MIN_ALG, SPEC_REFS
30
+
31
+ #: Allowed `by` kinds for ACL entries (spec §25.4).
32
+ ACLPrincipalKind = Literal["agent_id", "org_did", "room_id", "topic_glob"]
33
+ _VALID_ACL_PRINCIPAL_KINDS: frozenset[str] = frozenset(
34
+ {
35
+ "agent_id",
36
+ "org_did",
37
+ "room_id",
38
+ "topic_glob",
39
+ }
40
+ )
41
+
42
+ #: String ids the policy uses (rather than COSE ints) so the policy is
43
+ #: human-readable when serialized to YAML / JSON.
44
+ ALG_NAME_ED25519 = "ed25519"
45
+ ALG_NAME_COMPOSITE = "composite-mldsa65-ed25519"
46
+
47
+ #: Map between policy-string ids and COSE-int ids.
48
+ ALG_NAME_TO_COSE: dict[str, int] = {
49
+ ALG_NAME_ED25519: ALG_ED25519,
50
+ ALG_NAME_COMPOSITE: ALG_MLDSA65_ED25519,
51
+ }
52
+ COSE_TO_ALG_NAME: dict[int, str] = {v: k for k, v in ALG_NAME_TO_COSE.items()}
53
+
54
+ #: Total ordering of supported sig algs from weakest to strongest. Larger
55
+ #: index ⇒ stricter (composite ≥ ed25519). New PQ-only algs are appended.
56
+ MIN_ALG_ORDERING: tuple[str, ...] = (ALG_NAME_ED25519, ALG_NAME_COMPOSITE)
57
+
58
+
59
+ def _alg_rank(name: str) -> int:
60
+ try:
61
+ return MIN_ALG_ORDERING.index(name)
62
+ except ValueError as e:
63
+ raise ValueError(
64
+ f"unknown signature alg id {name!r}; supported: {MIN_ALG_ORDERING!r}"
65
+ ) from e
66
+
67
+
68
+ @dataclass(frozen=True, slots=True)
69
+ class ACLEntry:
70
+ """A single allow/deny ACL entry (spec §25.4).
71
+
72
+ ``by`` selects the principal-kind matcher: ``agent_id`` (lowercase
73
+ hex kid), ``org_did`` (string DID), ``room_id`` (lowercase hex), or
74
+ ``topic_glob`` (path-style glob with ``*`` and ``**``; spec §25.4
75
+ grammar).
76
+ """
77
+
78
+ by: ACLPrincipalKind
79
+ value: str
80
+
81
+ def __post_init__(self) -> None:
82
+ if self.by not in _VALID_ACL_PRINCIPAL_KINDS:
83
+ raise ValueError(
84
+ f"ACLEntry.by must be one of {sorted(_VALID_ACL_PRINCIPAL_KINDS)!r}; "
85
+ f"got {self.by!r}"
86
+ )
87
+ if not isinstance(self.value, str):
88
+ raise ValueError("ACLEntry.value must be a string")
89
+
90
+
91
+ def _glob_match(glob: str, path: str) -> bool:
92
+ """Spec §25.4 path-style glob: only ``*`` (single segment) and ``**``
93
+ (zero or more segments) are permitted. Case-sensitive. Empty glob
94
+ matches nothing; ``**`` matches everything.
95
+ """
96
+ if glob == "":
97
+ return False
98
+ if glob == "**":
99
+ return True
100
+ g_parts = glob.split("/")
101
+ p_parts = path.split("/")
102
+ return _glob_match_parts(g_parts, p_parts)
103
+
104
+
105
+ def _glob_match_parts(g: list[str], p: list[str]) -> bool:
106
+ # Recursive descent. Forbid backslash escapes and any metachar other
107
+ # than * / **.
108
+ if not g:
109
+ return not p
110
+ head = g[0]
111
+ if head == "**":
112
+ # Match zero or more segments.
113
+ if len(g) == 1:
114
+ return True
115
+ for i in range(0, len(p) + 1):
116
+ if _glob_match_parts(g[1:], p[i:]):
117
+ return True
118
+ return False
119
+ if not p:
120
+ return False
121
+ if head == "*":
122
+ return _glob_match_parts(g[1:], p[1:])
123
+ # Literal segment — backslash escapes are forbidden; any other use
124
+ # of `*` inside a segment (e.g. `foo*`) is also forbidden by §25.4.
125
+ if "\\" in head or "*" in head:
126
+ return False
127
+ if head != p[0]:
128
+ return False
129
+ return _glob_match_parts(g[1:], p[1:])
130
+
131
+
132
+ @dataclass(frozen=True, slots=True)
133
+ class NamespacePolicy:
134
+ """Root-signed policy for a namespace.
135
+
136
+ For v0.7, only the ``min_signature_alg`` field is normative. The
137
+ other fields stub out the surface that ROADMAP §0.7 will populate
138
+ (panel-quorum sizes, ramp lengths, witness cohort, etc.); they
139
+ default to None so existing tests don't need to set them.
140
+ """
141
+
142
+ namespace_id: bytes
143
+ #: One of `MIN_ALG_ORDERING`. Statements whose alg is below this
144
+ #: floor are rejected with rule_id `HM-MIN-ALG-001`.
145
+ min_signature_alg: str = ALG_NAME_ED25519
146
+ #: Per-namespace pq_required flag — when True, every statement must
147
+ #: also carry `pq_required=True` in its protected header. (Already
148
+ #: partially supported via the wire-level pq_required bit; pinning
149
+ #: the alg floor here gives operators an upgrade path.)
150
+ pq_required: bool = False
151
+ #: Optional human-readable name (debug/UX only).
152
+ label: str | None = None
153
+ #: ACL allow-entries (spec §25.4). Empty tuple = no explicit allow rules
154
+ #: (closed-by-default still applies).
155
+ allow: tuple[ACLEntry, ...] = ()
156
+ #: ACL deny-entries (spec §25.4). Deny precedence applies.
157
+ deny: tuple[ACLEntry, ...] = ()
158
+
159
+ def __post_init__(self) -> None:
160
+ if not isinstance(self.namespace_id, (bytes, bytearray)) or len(self.namespace_id) != 16:
161
+ raise ValueError("NamespacePolicy.namespace_id must be 16 bytes")
162
+ if self.min_signature_alg not in MIN_ALG_ORDERING:
163
+ raise ValueError(
164
+ f"min_signature_alg must be one of {MIN_ALG_ORDERING!r}; "
165
+ f"got {self.min_signature_alg!r}"
166
+ )
167
+ # Coerce list ⇒ tuple so frozen dataclass equality works.
168
+ if isinstance(self.allow, list):
169
+ object.__setattr__(self, "allow", tuple(self.allow))
170
+ if isinstance(self.deny, list):
171
+ object.__setattr__(self, "deny", tuple(self.deny))
172
+ for e in (*self.allow, *self.deny):
173
+ if not isinstance(e, ACLEntry):
174
+ raise ValueError("NamespacePolicy.allow/deny entries must be ACLEntry instances")
175
+
176
+ # -- ACL evaluation (spec §25.4) ---------------------------------- #
177
+
178
+ def evaluate(self, action_ctx: dict[str, Any]) -> Literal["allow", "deny"]:
179
+ """Evaluate ACL against an action context (spec §25.4).
180
+
181
+ ``action_ctx`` is a dict that may contain any of:
182
+
183
+ - ``agent_id``: lowercase hex of the operating kid.
184
+ - ``org_did``: org_did string for the operating kid.
185
+ - ``room_id``: lowercase hex of the operation's Room.
186
+ - ``topic``: topic identifier (matched against ``topic_glob`` rules).
187
+
188
+ Returns ``"deny"`` whenever a deny rule matches (deny precedence),
189
+ ``"allow"`` if an allow rule matches and no deny matched, and
190
+ ``"deny"`` by default if neither matched (closed-by-default).
191
+ """
192
+ # Deny precedence: evaluate deny first.
193
+ for entry in self.deny:
194
+ if self._matches(entry, action_ctx):
195
+ return "deny"
196
+ for entry in self.allow:
197
+ if self._matches(entry, action_ctx):
198
+ return "allow"
199
+ return "deny"
200
+
201
+ @staticmethod
202
+ def _matches(entry: ACLEntry, ctx: dict[str, Any]) -> bool:
203
+ if entry.by == "agent_id":
204
+ v = ctx.get("agent_id")
205
+ return isinstance(v, str) and v.lower() == entry.value.lower()
206
+ if entry.by == "org_did":
207
+ v = ctx.get("org_did")
208
+ return isinstance(v, str) and v == entry.value
209
+ if entry.by == "room_id":
210
+ v = ctx.get("room_id")
211
+ return isinstance(v, str) and v.lower() == entry.value.lower()
212
+ if entry.by == "topic_glob":
213
+ v = ctx.get("topic")
214
+ if not isinstance(v, str):
215
+ return False
216
+ return _glob_match(entry.value, v)
217
+ return False
218
+
219
+ # -- gating ------------------------------------------------------- #
220
+
221
+ def alg_meets_floor(self, alg_cose: int) -> bool:
222
+ """True iff the COSE alg id meets `min_signature_alg`."""
223
+ name = COSE_TO_ALG_NAME.get(alg_cose)
224
+ if name is None:
225
+ # Unknown alg cannot meet a known floor.
226
+ return False
227
+ return _alg_rank(name) >= _alg_rank(self.min_signature_alg)
228
+
229
+ def block_reason_for_alg(self, alg_cose: int) -> BlockReason:
230
+ """Build the canonical BlockReason for a sub-floor signature."""
231
+ observed = COSE_TO_ALG_NAME.get(alg_cose, f"alg={alg_cose}")
232
+ return BlockReason(
233
+ rule_id=MIN_ALG,
234
+ spec_ref=SPEC_REFS[MIN_ALG],
235
+ hint=(
236
+ f"namespace pins min_signature_alg={self.min_signature_alg!r} "
237
+ f"but the statement is signed with {observed!r}; "
238
+ "rotate the issuer to a key that meets the floor."
239
+ ),
240
+ current_value=observed,
241
+ required_value=self.min_signature_alg,
242
+ )
243
+
244
+
245
+ __all__ = [
246
+ "ALG_NAME_COMPOSITE",
247
+ "ALG_NAME_ED25519",
248
+ "ALG_NAME_TO_COSE",
249
+ "COSE_TO_ALG_NAME",
250
+ "MIN_ALG_ORDERING",
251
+ "ACLEntry",
252
+ "ACLPrincipalKind",
253
+ "NamespacePolicy",
254
+ ]
@@ -0,0 +1,25 @@
1
+ """Observability primitives.
2
+
3
+ The SDK emits structured logs in the shape mandated by spec/13-observability.md.
4
+ OpenTelemetry tracing and Prometheus metrics are optional; the
5
+ in-process recorder always works for tests.
6
+ """
7
+
8
+ from hypermind.observability.cost import (
9
+ PRICE_TABLE,
10
+ PRICE_TABLE_VERSION,
11
+ TokenCostTracker,
12
+ cost_usd,
13
+ )
14
+ from hypermind.observability.otel_export import OtelExporter
15
+ from hypermind.observability.recorder import Recorder, get_recorder
16
+
17
+ __all__ = [
18
+ "PRICE_TABLE",
19
+ "PRICE_TABLE_VERSION",
20
+ "OtelExporter",
21
+ "Recorder",
22
+ "TokenCostTracker",
23
+ "cost_usd",
24
+ "get_recorder",
25
+ ]
@@ -0,0 +1,214 @@
1
+ """Minimal ASGI 3 app exposing `/healthz` and `/metrics`.
2
+
3
+ Designed to be zero-dependency: the SDK already exposes the bounded
4
+ `Recorder` and the `wire_version` / `protocol_version` constants, so
5
+ production operators can mount this app under any ASGI server
6
+ (uvicorn, hypercorn, daphne) and get health + Prometheus metrics
7
+ without pulling in `prometheus-client`.
8
+
9
+ Usage::
10
+
11
+ from hypermind.observability.asgi import asgi_app
12
+
13
+ app = asgi_app() # uses the global Recorder
14
+ # uvicorn.run(app, host="0.0.0.0", port=9100)
15
+
16
+ # Or with a scoped Recorder:
17
+ rec = Recorder.scoped("did:web:my-namespace")
18
+ app = asgi_app(recorder=rec)
19
+
20
+ The exposition format follows the Prometheus text 0.0.4 spec
21
+ (https://prometheus.io/docs/instrumenting/exposition_formats/) — one
22
+ line per labelled counter, plus `# HELP` / `# TYPE` headers.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import re
29
+ from collections.abc import Awaitable, Callable
30
+ from typing import Any
31
+
32
+ from hypermind.observability.recorder import Recorder, get_recorder
33
+
34
+ # ASGI-3 callable type
35
+ _Send = Callable[[dict[str, Any]], Awaitable[None]]
36
+ _Receive = Callable[[], Awaitable[dict[str, Any]]]
37
+ _Scope = dict[str, Any]
38
+
39
+
40
+ # Prometheus metric names: [a-zA-Z_:][a-zA-Z0-9_:]*
41
+ _METRIC_NAME_RE = re.compile(r"^[a-zA-Z_:][a-zA-Z0-9_:]*$")
42
+ _LABEL_RE = re.compile(r"^([^{}=,]+)\{(.*)\}$")
43
+
44
+
45
+ def _parse_metric_key(key: str) -> tuple[str, dict[str, str]]:
46
+ """Split ``"name{label=value,...}"`` → ``("name", {"label": "value"})``."""
47
+ m = _LABEL_RE.match(key)
48
+ if not m:
49
+ return key, {}
50
+ name = m.group(1)
51
+ body = m.group(2)
52
+ labels: dict[str, str] = {}
53
+ for part in body.split(","):
54
+ if "=" not in part:
55
+ continue
56
+ k, v = part.split("=", 1)
57
+ labels[k.strip()] = v.strip()
58
+ return name, labels
59
+
60
+
61
+ def _escape_label_value(v: str) -> str:
62
+ return v.replace("\\", "\\\\").replace("\n", "\\n").replace('"', '\\"')
63
+
64
+
65
+ def _render_prometheus(recorder: Recorder) -> bytes:
66
+ """Render the Recorder's counters in Prometheus text 0.0.4 format."""
67
+ snapshot = recorder.get_metrics()
68
+ counters: dict[str, float] = snapshot["counters"]
69
+
70
+ # Group by metric name so we can emit one HELP/TYPE per family.
71
+ grouped: dict[str, list[tuple[dict[str, str], float]]] = {}
72
+ for key, value in counters.items():
73
+ name, labels = _parse_metric_key(key)
74
+ if not _METRIC_NAME_RE.match(name):
75
+ # Skip names that would corrupt the exposition format.
76
+ continue
77
+ grouped.setdefault(name, []).append((labels, value))
78
+
79
+ lines: list[str] = []
80
+ for name in sorted(grouped):
81
+ lines.append(f"# HELP {name} HyperMind counter (auto-generated)")
82
+ lines.append(f"# TYPE {name} counter")
83
+ for labels, value in grouped[name]:
84
+ if labels:
85
+ label_str = ",".join(
86
+ f'{k}="{_escape_label_value(v)}"' for k, v in sorted(labels.items())
87
+ )
88
+ lines.append(f"{name}{{{label_str}}} {value}")
89
+ else:
90
+ lines.append(f"{name} {value}")
91
+
92
+ # Recorder-internal gauges — useful for ops to detect log-buffer
93
+ # saturation in long-running processes.
94
+ lines.append("# HELP hypermind_records_total Records currently in the bounded ring buffer")
95
+ lines.append("# TYPE hypermind_records_total gauge")
96
+ lines.append(f"hypermind_records_total {snapshot['records_total']}")
97
+ lines.append(
98
+ "# HELP hypermind_records_dropped_total Records dropped from the bounded ring buffer"
99
+ )
100
+ lines.append("# TYPE hypermind_records_dropped_total counter")
101
+ lines.append(f"hypermind_records_dropped_total {snapshot['records_dropped_total']}")
102
+
103
+ body = "\n".join(lines) + "\n"
104
+ return body.encode("utf-8")
105
+
106
+
107
+ def _healthz_body() -> bytes:
108
+ # Late import — avoids a hard cycle if wire.py imports observability.
109
+ from hypermind.wire import PROTOCOL_VERSION, WIRE_VERSION
110
+
111
+ payload = {
112
+ "status": "ok",
113
+ "wire_version": WIRE_VERSION,
114
+ "protocol_version": PROTOCOL_VERSION,
115
+ }
116
+ return json.dumps(payload).encode("utf-8")
117
+
118
+
119
+ async def _send_response(
120
+ send: _Send,
121
+ *,
122
+ status: int,
123
+ body: bytes,
124
+ content_type: str,
125
+ ) -> None:
126
+ await send(
127
+ {
128
+ "type": "http.response.start",
129
+ "status": status,
130
+ "headers": [
131
+ (b"content-type", content_type.encode("ascii")),
132
+ (b"content-length", str(len(body)).encode("ascii")),
133
+ ],
134
+ }
135
+ )
136
+ await send({"type": "http.response.body", "body": body, "more_body": False})
137
+
138
+
139
+ def asgi_app(
140
+ recorder: Recorder | None = None,
141
+ ) -> Callable[[_Scope, _Receive, _Send], Awaitable[None]]:
142
+ """Return an ASGI 3 application exposing `/healthz` and `/metrics`.
143
+
144
+ Both endpoints accept GET and HEAD; everything else gets 405.
145
+ Unknown paths get 404.
146
+ """
147
+
148
+ rec = recorder if recorder is not None else get_recorder()
149
+
150
+ async def app(scope: _Scope, receive: _Receive, send: _Send) -> None:
151
+ if scope["type"] == "lifespan":
152
+ # Drain the lifespan loop so uvicorn's startup completes.
153
+ while True:
154
+ message = await receive()
155
+ if message["type"] == "lifespan.startup":
156
+ await send({"type": "lifespan.startup.complete"})
157
+ elif message["type"] == "lifespan.shutdown":
158
+ await send({"type": "lifespan.shutdown.complete"})
159
+ return
160
+ # unreachable — `return` above ends the lifespan task
161
+
162
+ if scope["type"] != "http":
163
+ return
164
+
165
+ method: str = scope.get("method", "GET").upper()
166
+ path: str = scope.get("path", "/")
167
+
168
+ if path == "/healthz":
169
+ if method not in ("GET", "HEAD"):
170
+ await _send_response(
171
+ send,
172
+ status=405,
173
+ body=b"method not allowed",
174
+ content_type="text/plain; charset=utf-8",
175
+ )
176
+ return
177
+ body = _healthz_body() if method == "GET" else b""
178
+ await _send_response(
179
+ send,
180
+ status=200,
181
+ body=body,
182
+ content_type="application/json",
183
+ )
184
+ return
185
+
186
+ if path == "/metrics":
187
+ if method not in ("GET", "HEAD"):
188
+ await _send_response(
189
+ send,
190
+ status=405,
191
+ body=b"method not allowed",
192
+ content_type="text/plain; charset=utf-8",
193
+ )
194
+ return
195
+ body = _render_prometheus(rec) if method == "GET" else b""
196
+ await _send_response(
197
+ send,
198
+ status=200,
199
+ body=body,
200
+ content_type="text/plain; version=0.0.4; charset=utf-8",
201
+ )
202
+ return
203
+
204
+ await _send_response(
205
+ send,
206
+ status=404,
207
+ body=b"not found",
208
+ content_type="text/plain; charset=utf-8",
209
+ )
210
+
211
+ return app
212
+
213
+
214
+ __all__ = ["asgi_app"]