minima-cli 0.4.9__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 (161) hide show
  1. minima/__init__.py +5 -0
  2. minima/api/__init__.py +1 -0
  3. minima/api/auth.py +39 -0
  4. minima/api/errors.py +40 -0
  5. minima/api/routers/__init__.py +1 -0
  6. minima/api/routers/calibration.py +50 -0
  7. minima/api/routers/feedback.py +279 -0
  8. minima/api/routers/health.py +50 -0
  9. minima/api/routers/models.py +42 -0
  10. minima/api/routers/recommend.py +66 -0
  11. minima/api/routers/savings.py +55 -0
  12. minima/api/routers/strategies.py +33 -0
  13. minima/catalog/__init__.py +1 -0
  14. minima/catalog/data/capability_priors.json +210 -0
  15. minima/catalog/data/model_aliases.json +12 -0
  16. minima/catalog/merge.py +69 -0
  17. minima/catalog/refresh.py +54 -0
  18. minima/catalog/sources/__init__.py +1 -0
  19. minima/catalog/sources/litellm.py +19 -0
  20. minima/catalog/sources/openrouter.py +25 -0
  21. minima/catalog/store.py +86 -0
  22. minima/config.py +288 -0
  23. minima/deps.py +35 -0
  24. minima/llm/__init__.py +1 -0
  25. minima/llm/anthropic.py +106 -0
  26. minima/llm/base.py +196 -0
  27. minima/llm/gemini.py +124 -0
  28. minima/llm/registry.py +54 -0
  29. minima/logging.py +28 -0
  30. minima/main.py +109 -0
  31. minima/memory/__init__.py +1 -0
  32. minima/memory/adapter.py +572 -0
  33. minima/memory/keys.py +83 -0
  34. minima/memory/records.py +190 -0
  35. minima/memory/threadpool.py +41 -0
  36. minima/metrics/__init__.py +1 -0
  37. minima/metrics/calibration.py +415 -0
  38. minima/metrics/report.py +116 -0
  39. minima/metrics/savings.py +98 -0
  40. minima/recommender/__init__.py +1 -0
  41. minima/recommender/_pg_pool.py +38 -0
  42. minima/recommender/_redis_client.py +32 -0
  43. minima/recommender/aggregate.py +157 -0
  44. minima/recommender/classify.py +165 -0
  45. minima/recommender/decisionlog.py +505 -0
  46. minima/recommender/durablerefs.py +312 -0
  47. minima/recommender/engine.py +997 -0
  48. minima/recommender/escalation.py +83 -0
  49. minima/recommender/propensity.py +189 -0
  50. minima/recommender/recstore.py +368 -0
  51. minima/recommender/score.py +318 -0
  52. minima/recommender/types.py +166 -0
  53. minima/schemas/__init__.py +1 -0
  54. minima/schemas/common.py +73 -0
  55. minima/schemas/feedback.py +34 -0
  56. minima/schemas/models_catalog.py +36 -0
  57. minima/schemas/recommend.py +104 -0
  58. minima/schemas/savings.py +39 -0
  59. minima/schemas/strategies.py +57 -0
  60. minima/schemas/workflow.py +43 -0
  61. minima/seeding/__init__.py +1 -0
  62. minima/seeding/items.py +42 -0
  63. minima/seeding/llmrouterbench.py +232 -0
  64. minima/seeding/routerbench.py +141 -0
  65. minima/seeding/run_seed.py +56 -0
  66. minima/seeding/synthetic.py +70 -0
  67. minima/tenancy/__init__.py +8 -0
  68. minima/tenancy/context.py +37 -0
  69. minima/tenancy/passthrough.py +110 -0
  70. minima/version.py +3 -0
  71. minima_cli-0.4.9.dist-info/METADATA +275 -0
  72. minima_cli-0.4.9.dist-info/RECORD +161 -0
  73. minima_cli-0.4.9.dist-info/WHEEL +4 -0
  74. minima_cli-0.4.9.dist-info/entry_points.txt +5 -0
  75. minima_cli-0.4.9.dist-info/licenses/LICENSE +295 -0
  76. minima_client/__init__.py +19 -0
  77. minima_client/autocapture.py +101 -0
  78. minima_client/client.py +301 -0
  79. minima_client/errors.py +23 -0
  80. minima_harness/LICENSE_PI +32 -0
  81. minima_harness/__init__.py +16 -0
  82. minima_harness/agent/__init__.py +72 -0
  83. minima_harness/agent/agent.py +276 -0
  84. minima_harness/agent/events.py +124 -0
  85. minima_harness/agent/loop.py +311 -0
  86. minima_harness/agent/state.py +79 -0
  87. minima_harness/agent/tools.py +97 -0
  88. minima_harness/ai/__init__.py +66 -0
  89. minima_harness/ai/compat.py +71 -0
  90. minima_harness/ai/errors.py +96 -0
  91. minima_harness/ai/events.py +117 -0
  92. minima_harness/ai/openrouter_catalog.py +153 -0
  93. minima_harness/ai/provider_catalog.py +299 -0
  94. minima_harness/ai/provider_quirks.py +37 -0
  95. minima_harness/ai/providers/__init__.py +75 -0
  96. minima_harness/ai/providers/_common.py +48 -0
  97. minima_harness/ai/providers/anthropic.py +290 -0
  98. minima_harness/ai/providers/base.py +65 -0
  99. minima_harness/ai/providers/faux.py +173 -0
  100. minima_harness/ai/providers/google.py +221 -0
  101. minima_harness/ai/providers/openai_compat.py +278 -0
  102. minima_harness/ai/registry.py +184 -0
  103. minima_harness/ai/stream.py +82 -0
  104. minima_harness/ai/tools.py +51 -0
  105. minima_harness/ai/types.py +204 -0
  106. minima_harness/ai/usage.py +41 -0
  107. minima_harness/minima/__init__.py +40 -0
  108. minima_harness/minima/cache.py +102 -0
  109. minima_harness/minima/config.py +85 -0
  110. minima_harness/minima/goals.py +226 -0
  111. minima_harness/minima/judge.py +144 -0
  112. minima_harness/minima/mapping.py +147 -0
  113. minima_harness/minima/meter.py +143 -0
  114. minima_harness/minima/router.py +220 -0
  115. minima_harness/minima/runtime.py +544 -0
  116. minima_harness/minima/signals.py +195 -0
  117. minima_harness/session/__init__.py +14 -0
  118. minima_harness/session/format.py +35 -0
  119. minima_harness/session/store.py +236 -0
  120. minima_harness/tasks/__init__.py +17 -0
  121. minima_harness/tasks/task_set.py +78 -0
  122. minima_harness/tools/__init__.py +7 -0
  123. minima_harness/tools/_io.py +34 -0
  124. minima_harness/tools/bash.py +70 -0
  125. minima_harness/tools/builtin.py +23 -0
  126. minima_harness/tools/edit.py +50 -0
  127. minima_harness/tools/find.py +38 -0
  128. minima_harness/tools/grep.py +73 -0
  129. minima_harness/tools/ls.py +35 -0
  130. minima_harness/tools/read.py +38 -0
  131. minima_harness/tools/tasks.py +75 -0
  132. minima_harness/tools/write.py +36 -0
  133. minima_harness/tui/__init__.py +3 -0
  134. minima_harness/tui/analytics.py +111 -0
  135. minima_harness/tui/app.py +1927 -0
  136. minima_harness/tui/bridge.py +103 -0
  137. minima_harness/tui/cli.py +227 -0
  138. minima_harness/tui/clipboard.py +60 -0
  139. minima_harness/tui/commands.py +49 -0
  140. minima_harness/tui/compaction.py +17 -0
  141. minima_harness/tui/config_cli.py +141 -0
  142. minima_harness/tui/config_store.py +237 -0
  143. minima_harness/tui/context.py +93 -0
  144. minima_harness/tui/customize.py +95 -0
  145. minima_harness/tui/diff.py +53 -0
  146. minima_harness/tui/editor.py +43 -0
  147. minima_harness/tui/extensions.py +84 -0
  148. minima_harness/tui/extra_models.py +52 -0
  149. minima_harness/tui/history.py +71 -0
  150. minima_harness/tui/mubit.py +295 -0
  151. minima_harness/tui/overlays.py +593 -0
  152. minima_harness/tui/packages.py +59 -0
  153. minima_harness/tui/run_modes.py +66 -0
  154. minima_harness/tui/theme.py +77 -0
  155. minima_harness/tui/welcome.py +83 -0
  156. minima_harness/tui/widgets/__init__.py +3 -0
  157. minima_harness/tui/widgets/banner.py +38 -0
  158. minima_harness/tui/widgets/editor.py +83 -0
  159. minima_harness/tui/widgets/footer.py +73 -0
  160. minima_harness/tui/widgets/messages.py +151 -0
  161. minima_harness/tui/widgets/status.py +57 -0
@@ -0,0 +1,572 @@
1
+ """The single integration point with the Mubit SDK.
2
+
3
+ Everything Minima knows about Mubit lives here. The recommender depends only on the
4
+ ``Memory`` protocol, so tests can swap in a fake. All SDK calls are synchronous and
5
+ run in a worker thread; the recall path is latency-bounded.
6
+
7
+ Mubit run scoping: Minima uses the memory *lane* string as the run_id, so a namespace
8
+ maps to one stable run. That keeps ``upsert_key`` (scoped to run_id + user_id) stable
9
+ across requests, and recall over the same run finds the accumulated outcomes.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import time
15
+ from collections.abc import Mapping, Sequence
16
+ from typing import Any, Protocol, runtime_checkable
17
+
18
+ import anyio
19
+ import httpx
20
+ from mubit import Client, TransportError
21
+
22
+ from minima.config import Settings
23
+ from minima.logging import get_logger
24
+ from minima.memory import threadpool
25
+ from minima.memory.records import OutcomeRecord, RecalledEvidence, RecallResult
26
+
27
+ log = get_logger("minima.memory")
28
+
29
+ # Lowercase LTM entry-type tags for Mubit's query filter. Recall deliberately does
30
+ # NOT filter by type (seeds land as "fact", feedback as "observation"); Minima outcomes
31
+ # are selected by metadata kind instead. Used by get_context only.
32
+ CONTEXT_ENTRY_TYPES = ["observation", "lesson", "fact"]
33
+
34
+
35
+ def _f(value: object, default: float = 0.0) -> float:
36
+ try:
37
+ return float(value) # type: ignore[arg-type]
38
+ except (TypeError, ValueError):
39
+ return default
40
+
41
+
42
+ def _parse_evidence(ev: Mapping[str, Any]) -> RecalledEvidence:
43
+ return RecalledEvidence(
44
+ entry_id=str(ev.get("id", "")),
45
+ reference_id=(ev.get("reference_id") or None),
46
+ score=_f(ev.get("score")),
47
+ knowledge_confidence=_f(ev.get("knowledge_confidence")),
48
+ is_stale=bool(ev.get("is_stale", False)),
49
+ content=str(ev.get("content", "")),
50
+ record=OutcomeRecord.from_metadata(ev.get("metadata_json")),
51
+ referenceable=bool(ev.get("referenceable", False)),
52
+ entry_type=str(ev.get("entry_type", "")),
53
+ )
54
+
55
+
56
+ def _parse_lookup_record(item: Mapping[str, Any]) -> RecalledEvidence | None:
57
+ """Parse a single LookupResponse item (from POST /v2/core/lookup) into RecalledEvidence.
58
+
59
+ The lookup response shape differs from recall: no ANN scores, metadata is a dict
60
+ (not a JSON string), and id is a numeric node ID. Score and knowledge_confidence are
61
+ set to 1.0 — an exact keyed match is maximally certain.
62
+ """
63
+ raw_id = item.get("id")
64
+ if raw_id is None:
65
+ return None
66
+ record = OutcomeRecord.from_metadata(item.get("metadata"))
67
+ if record is None:
68
+ return None
69
+ entry_id = str(raw_id)
70
+ return RecalledEvidence(
71
+ entry_id=entry_id,
72
+ reference_id=entry_id,
73
+ score=1.0,
74
+ knowledge_confidence=1.0,
75
+ is_stale=False,
76
+ content="",
77
+ record=record,
78
+ referenceable=True,
79
+ )
80
+
81
+
82
+ def _log_explain(lane: str, raw: object) -> None:
83
+ """Diagnostic: per-evidence score components (server-side ExplainInfo)."""
84
+ if not isinstance(raw, Mapping):
85
+ return
86
+ components = []
87
+ for ev in raw.get("evidence") or []:
88
+ if not isinstance(ev, Mapping):
89
+ continue
90
+ info = ev.get("explain_info")
91
+ if isinstance(info, Mapping):
92
+ components.append(
93
+ {
94
+ "id": str(ev.get("id", ""))[:12],
95
+ "semantic": _f(info.get("semantic_score")),
96
+ "lexical": _f(info.get("lexical_score")),
97
+ "recency": _f(info.get("recency_score")),
98
+ "decay": _f(info.get("temporal_decay_factor"), 1.0),
99
+ }
100
+ )
101
+ if components:
102
+ log.info(
103
+ "recall_explain",
104
+ lane=lane,
105
+ rank_by_mode=raw.get("rank_by_mode"),
106
+ n=len(components),
107
+ components=components,
108
+ )
109
+
110
+
111
+ @runtime_checkable
112
+ class Memory(Protocol):
113
+ async def recall(
114
+ self,
115
+ *,
116
+ query: str,
117
+ lane: str,
118
+ user_id: str | None = None,
119
+ limit: int = 25,
120
+ entry_types: Sequence[str] | None = None,
121
+ env_tags: Sequence[str] | None = None,
122
+ timeout_ms: int | None = None,
123
+ ) -> RecallResult: ...
124
+
125
+ async def remember_outcome(
126
+ self,
127
+ *,
128
+ content: str,
129
+ record: OutcomeRecord,
130
+ lane: str,
131
+ upsert_key: str,
132
+ idempotency_key: str,
133
+ user_id: str | None = None,
134
+ env_tags: Sequence[str] | None = None,
135
+ importance: str = "medium",
136
+ source: str = "human",
137
+ ) -> str | None: ...
138
+
139
+ async def record_outcome(
140
+ self,
141
+ *,
142
+ lane: str,
143
+ reference_id: str,
144
+ outcome: str,
145
+ signal: float,
146
+ entry_ids: Sequence[str] | None = None,
147
+ user_id: str | None = None,
148
+ verified_in_production: bool = False,
149
+ idempotency_key: str | None = None,
150
+ rationale: str = "",
151
+ ) -> dict: ...
152
+
153
+ async def remember_lesson(
154
+ self,
155
+ *,
156
+ content: str,
157
+ lane: str,
158
+ upsert_key: str,
159
+ user_id: str | None = None,
160
+ lesson_type: str = "success",
161
+ lesson_scope: str = "session",
162
+ importance: str = "high",
163
+ env_tags: Sequence[str] | None = None,
164
+ metadata: Mapping[str, Any] | None = None,
165
+ idempotency_key: str | None = None,
166
+ ) -> str | None: ...
167
+
168
+ async def batch_insert(
169
+ self, *, run_id: str, items: list[dict], deduplicate: bool = True
170
+ ) -> dict: ...
171
+
172
+ async def lookup(
173
+ self,
174
+ *,
175
+ lane: str,
176
+ match: list[dict],
177
+ limit: int = 256,
178
+ ) -> list[RecalledEvidence]: ...
179
+
180
+ async def dereference(
181
+ self, *, lane: str, reference_id: str
182
+ ) -> RecalledEvidence | None: ...
183
+
184
+ async def get_context(
185
+ self,
186
+ *,
187
+ query: str,
188
+ lane: str,
189
+ user_id: str | None = None,
190
+ entry_types: Sequence[str] | None = None,
191
+ max_token_budget: int = 1500,
192
+ ) -> str: ...
193
+
194
+ async def reflect(
195
+ self, *, lane: str, user_id: str | None = None, include_linked_runs: bool = False
196
+ ) -> dict: ...
197
+
198
+ async def surface_strategies(
199
+ self, *, lane: str, lesson_types: Sequence[str] | None = None, max_strategies: int = 5
200
+ ) -> dict: ...
201
+
202
+ async def health(self) -> dict: ...
203
+
204
+
205
+ class MubitMemory:
206
+ """Concrete ``Memory`` backed by the Mubit SDK Client."""
207
+
208
+ def __init__(
209
+ self,
210
+ settings: Settings,
211
+ *,
212
+ endpoint: str | None = None,
213
+ api_key: str | None = None,
214
+ transport: str | None = None,
215
+ ):
216
+ # endpoint/api_key/transport override settings so one process can hold a distinct
217
+ # client per org (multi-tenancy). They default to the single-tenant env config.
218
+ self._settings = settings
219
+ self._endpoint = endpoint or settings.mubit_endpoint
220
+ self._transport = transport or settings.mubit_transport
221
+ resolved_key = api_key if api_key is not None else settings.mubit_api_key
222
+ self._api_key = resolved_key
223
+ kwargs: dict[str, Any] = {
224
+ "endpoint": self._endpoint,
225
+ "timeout_ms": settings.mubit_timeout_ms,
226
+ }
227
+ if resolved_key:
228
+ kwargs["api_key"] = resolved_key
229
+ if self._transport:
230
+ kwargs["transport"] = self._transport
231
+ self._client = Client(**kwargs)
232
+
233
+ # ---- reads -----------------------------------------------------------------
234
+
235
+ async def recall(
236
+ self,
237
+ *,
238
+ query: str,
239
+ lane: str,
240
+ user_id: str | None = None,
241
+ limit: int = 25,
242
+ entry_types: Sequence[str] | None = None,
243
+ env_tags: Sequence[str] | None = None,
244
+ timeout_ms: int | None = None,
245
+ ) -> RecallResult:
246
+ settings = self._settings
247
+ budget_ms = (
248
+ timeout_ms if timeout_ms is not None else settings.minima_memory_recall_timeout_ms
249
+ )
250
+ # Low-level control query (the typed recall() wrapper drops rank_by / timestamps /
251
+ # budget / explain). Default entry_types covers both intake paths: seed records
252
+ # (batch_insert) land as "fact", feedback records (remember intent=observation)
253
+ # as "observation"; Minima outcomes are still authoritatively selected by metadata
254
+ # kind. prefer_current_run keeps everything in this lane's run while skipping
255
+ # cross-run global-lesson overlays from other actors.
256
+ resolved_types = (
257
+ list(entry_types)
258
+ if entry_types
259
+ else [t.strip() for t in settings.minima_recall_entry_types.split(",") if t.strip()]
260
+ )
261
+ payload: dict[str, Any] = {
262
+ "run_id": lane,
263
+ "query": query,
264
+ "mode": settings.minima_recall_mode,
265
+ "limit": limit,
266
+ "include_working_memory": False,
267
+ "prefer_current_run": True,
268
+ "lane_filter": lane,
269
+ }
270
+ if user_id:
271
+ payload["user_id"] = user_id
272
+ if resolved_types:
273
+ payload["entry_types"] = resolved_types
274
+ if env_tags:
275
+ payload["env_tags"] = list(env_tags)
276
+ if settings.minima_recall_rank_by:
277
+ payload["rank_by"] = settings.minima_recall_rank_by
278
+ if settings.minima_recall_budget:
279
+ payload["budget"] = settings.minima_recall_budget
280
+ if settings.minima_recall_max_age_days > 0:
281
+ payload["min_timestamp"] = int(
282
+ time.time() - settings.minima_recall_max_age_days * 86_400
283
+ )
284
+ if settings.minima_recall_explain:
285
+ payload["explain"] = True
286
+ try:
287
+ with anyio.move_on_after(budget_ms / 1000.0) as scope:
288
+ raw = await threadpool.run_cancellable(self._client._control.query, payload)
289
+ if scope.cancelled_caught:
290
+ log.warning("recall_timeout", lane=lane, budget_ms=budget_ms)
291
+ return RecallResult(evidence=[], degraded=True, timed_out=True)
292
+ result = self._parse_recall(raw)
293
+ if settings.minima_recall_explain:
294
+ _log_explain(lane, raw)
295
+ return result
296
+ except TransportError as exc:
297
+ log.warning("recall_transport_error", lane=lane, code=exc.args[0] if exc.args else "")
298
+ return RecallResult(evidence=[], degraded=True, error=str(exc))
299
+ except Exception as exc: # noqa: BLE001 — recall must never break a recommendation
300
+ log.warning("recall_error", lane=lane, error=str(exc))
301
+ return RecallResult(evidence=[], degraded=True, error=str(exc))
302
+
303
+ def _parse_recall(self, raw: object) -> RecallResult:
304
+ data: Mapping[str, Any] = raw if isinstance(raw, Mapping) else {}
305
+ evidence: list[RecalledEvidence] = []
306
+ for ev in data.get("evidence") or []:
307
+ if not isinstance(ev, Mapping):
308
+ continue
309
+ evidence.append(_parse_evidence(ev))
310
+ return RecallResult(
311
+ evidence=evidence,
312
+ degraded=bool(data.get("degraded", False)),
313
+ raw_confidence=_f(data.get("confidence")),
314
+ )
315
+
316
+ async def lookup(
317
+ self,
318
+ *,
319
+ lane: str,
320
+ match: list[dict],
321
+ limit: int = 256,
322
+ ) -> list[RecalledEvidence]:
323
+ """Deterministic keyed lookup via POST /v2/core/lookup.
324
+
325
+ Returns all non-deleted outcome records for the given (lane, match) filters
326
+ without touching ANN — results are stable across identical calls. Use for
327
+ (cluster, model) keyed reads where recall flicker is unacceptable.
328
+ """
329
+ try:
330
+ raw = await threadpool.run(
331
+ self._client.lookup,
332
+ session_id=lane,
333
+ match=match,
334
+ limit=limit,
335
+ )
336
+ except Exception as exc: # noqa: BLE001 — lookup is additive; must not block a recommend
337
+ log.warning("lookup_error", lane=lane, error=str(exc))
338
+ return []
339
+ if not isinstance(raw, list):
340
+ return []
341
+ results: list[RecalledEvidence] = []
342
+ for item in raw:
343
+ if not isinstance(item, dict):
344
+ continue
345
+ parsed = _parse_lookup_record(item)
346
+ if parsed is not None:
347
+ results.append(parsed)
348
+ return results
349
+
350
+ async def dereference(self, *, lane: str, reference_id: str) -> RecalledEvidence | None:
351
+ """Exact re-read of a known durable record (the (cluster, model) outcome upsert).
352
+
353
+ Returns None on any failure — the fast path is strictly additive to ANN recall.
354
+ """
355
+ try:
356
+ raw = await threadpool.run(
357
+ self._client.dereference, reference_id=reference_id, session_id=lane
358
+ )
359
+ except Exception as exc: # noqa: BLE001 — fast path must never break a recommendation
360
+ log.warning("dereference_error", lane=lane, error=str(exc))
361
+ return None
362
+ if not isinstance(raw, Mapping) or raw.get("found") is False:
363
+ return None
364
+ ev = raw.get("evidence")
365
+ if not isinstance(ev, Mapping) or not ev:
366
+ return None
367
+ parsed = _parse_evidence(ev)
368
+ # Exact identity fetch: similarity is 1.0 by construction (same cluster key).
369
+ parsed.score = 1.0
370
+ if not parsed.reference_id:
371
+ parsed.reference_id = reference_id
372
+ return parsed
373
+
374
+ async def get_context(
375
+ self,
376
+ *,
377
+ query: str,
378
+ lane: str,
379
+ user_id: str | None = None,
380
+ entry_types: Sequence[str] | None = None,
381
+ max_token_budget: int = 1500,
382
+ ) -> str:
383
+ try:
384
+ raw = await threadpool.run(
385
+ self._client.get_context,
386
+ query=query,
387
+ session_id=lane,
388
+ user_id=user_id,
389
+ entry_types=list(entry_types or CONTEXT_ENTRY_TYPES),
390
+ include_working_memory=False,
391
+ max_token_budget=max_token_budget,
392
+ format="structured",
393
+ mode="full",
394
+ )
395
+ except Exception as exc: # noqa: BLE001
396
+ log.warning("get_context_error", lane=lane, error=str(exc))
397
+ return ""
398
+ if isinstance(raw, Mapping):
399
+ return str(raw.get("context_block", ""))
400
+ return ""
401
+
402
+ # ---- writes ----------------------------------------------------------------
403
+
404
+ async def remember_outcome(
405
+ self,
406
+ *,
407
+ content: str,
408
+ record: OutcomeRecord,
409
+ lane: str,
410
+ upsert_key: str,
411
+ idempotency_key: str,
412
+ user_id: str | None = None,
413
+ env_tags: Sequence[str] | None = None,
414
+ importance: str = "medium",
415
+ source: str = "human",
416
+ ) -> str | None:
417
+ raw = await threadpool.run(
418
+ self._client.remember,
419
+ content=content,
420
+ session_id=lane,
421
+ agent_id="minima",
422
+ intent="observation",
423
+ metadata=record.to_metadata(),
424
+ user_id=user_id,
425
+ upsert_key=upsert_key,
426
+ importance=importance,
427
+ source=source,
428
+ lane=lane,
429
+ idempotency_key=idempotency_key,
430
+ env_tags=list(env_tags) if env_tags else None,
431
+ wait=True,
432
+ )
433
+ return _extract_record_id(raw)
434
+
435
+ async def record_outcome(
436
+ self,
437
+ *,
438
+ lane: str,
439
+ reference_id: str,
440
+ outcome: str,
441
+ signal: float,
442
+ entry_ids: Sequence[str] | None = None,
443
+ user_id: str | None = None,
444
+ verified_in_production: bool = False,
445
+ idempotency_key: str | None = None,
446
+ rationale: str = "",
447
+ ) -> dict:
448
+ # Low-level control op so we can pass idempotency_key (the typed
449
+ # client.record_outcome wrapper drops it).
450
+ payload = {
451
+ "run_id": lane,
452
+ "reference_id": reference_id,
453
+ "outcome": outcome,
454
+ "signal": signal,
455
+ "rationale": rationale,
456
+ "user_id": user_id,
457
+ "verified_in_production": verified_in_production or None,
458
+ "entry_ids": list(entry_ids) if entry_ids else None,
459
+ "idempotency_key": idempotency_key,
460
+ }
461
+ payload = {k: v for k, v in payload.items() if v is not None}
462
+ raw = await threadpool.run(self._client._control.record_outcome, payload)
463
+ return raw if isinstance(raw, dict) else {}
464
+
465
+ async def remember_lesson(
466
+ self,
467
+ *,
468
+ content: str,
469
+ lane: str,
470
+ upsert_key: str,
471
+ user_id: str | None = None,
472
+ lesson_type: str = "success",
473
+ lesson_scope: str = "session",
474
+ importance: str = "high",
475
+ env_tags: Sequence[str] | None = None,
476
+ metadata: Mapping[str, Any] | None = None,
477
+ idempotency_key: str | None = None,
478
+ ) -> str | None:
479
+ # A Lesson entry (intent="lesson") goes through the server's validation gate and
480
+ # feeds reflect()/surface_strategies rule promotion. upsert_key keeps one durable
481
+ # lesson per (cluster, model) so repeated wins reinforce rather than flood LTM.
482
+ raw = await threadpool.run(
483
+ self._client.remember,
484
+ content=content,
485
+ session_id=lane,
486
+ agent_id="minima",
487
+ intent="lesson",
488
+ lesson_type=lesson_type,
489
+ lesson_scope=lesson_scope,
490
+ lesson_importance=importance,
491
+ metadata=dict(metadata) if metadata else None,
492
+ user_id=user_id,
493
+ upsert_key=upsert_key,
494
+ importance=importance,
495
+ source="human",
496
+ lane=lane,
497
+ idempotency_key=idempotency_key,
498
+ env_tags=list(env_tags) if env_tags else None,
499
+ wait=True,
500
+ )
501
+ return _extract_record_id(raw)
502
+
503
+ async def batch_insert(
504
+ self, *, run_id: str, items: list[dict], deduplicate: bool = True
505
+ ) -> dict:
506
+ # Control batch_insert (/v2/control/batch_insert) is run-scoped and takes
507
+ # {run_id, deduplicate, items}. The core route expects a bare array.
508
+ raw = await threadpool.run(
509
+ self._client._control.batch_insert,
510
+ {"run_id": run_id, "deduplicate": deduplicate, "items": items},
511
+ )
512
+ return raw if isinstance(raw, dict) else {}
513
+
514
+ async def reflect(
515
+ self, *, lane: str, user_id: str | None = None, include_linked_runs: bool = False
516
+ ) -> dict:
517
+ raw = await threadpool.run(
518
+ self._client.reflect,
519
+ session_id=lane,
520
+ user_id=user_id,
521
+ include_linked_runs=include_linked_runs,
522
+ )
523
+ return raw if isinstance(raw, dict) else {}
524
+
525
+ async def surface_strategies(
526
+ self, *, lane: str, lesson_types: Sequence[str] | None = None, max_strategies: int = 5
527
+ ) -> dict:
528
+ raw = await threadpool.run(
529
+ self._client.surface_strategies,
530
+ session_id=lane,
531
+ lesson_types=list(lesson_types) if lesson_types else None,
532
+ max_strategies=max_strategies,
533
+ )
534
+ return raw if isinstance(raw, dict) else {}
535
+
536
+ @property
537
+ def endpoint(self) -> str:
538
+ return self._endpoint
539
+
540
+ async def health(self) -> dict:
541
+ """Liveness probe via Mubit's core health route (no embedding, fast)."""
542
+ base = self._endpoint.rstrip("/")
543
+ url = f"{base}/v2/core/health"
544
+ headers = {"Authorization": f"Bearer {self._api_key}"} if self._api_key else {}
545
+ try:
546
+ headers = {"Authorization": f"Bearer {self._api_key}"} if self._api_key else {}
547
+ async with httpx.AsyncClient(timeout=3.0) as http:
548
+ resp = await http.get(url, headers=headers)
549
+ return {
550
+ "reachable": resp.status_code == 200,
551
+ "transport": self._transport,
552
+ "status_code": resp.status_code,
553
+ }
554
+ except Exception as exc: # noqa: BLE001
555
+ return {
556
+ "reachable": False,
557
+ "transport": self._transport,
558
+ "error": str(exc),
559
+ }
560
+
561
+
562
+ def _extract_record_id(raw: object) -> str | None:
563
+ if not isinstance(raw, Mapping):
564
+ return None
565
+ traces = raw.get("traces") or []
566
+ if not traces or not isinstance(traces[0], Mapping):
567
+ return None
568
+ writes = traces[0].get("writes") or []
569
+ if not writes or not isinstance(writes[0], Mapping):
570
+ return None
571
+ record_id = writes[0].get("record_id")
572
+ return str(record_id) if record_id else None
minima/memory/keys.py ADDED
@@ -0,0 +1,83 @@
1
+ """Deterministic builders for Mubit keys, lanes, fingerprints, and content gists."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+
8
+
9
+ def normalize_task_text(text: str, max_chars: int = 512) -> str:
10
+ """Collapse whitespace and truncate, so paraphrases embed similarly."""
11
+ collapsed = " ".join(text.split())
12
+ return collapsed[:max_chars]
13
+
14
+
15
+ def task_fingerprint(text: str) -> str:
16
+ """Stable hash of the normalized task text (non-cryptographic use)."""
17
+ norm = " ".join(text.lower().split())
18
+ return hashlib.sha1(norm.encode("utf-8")).hexdigest() # noqa: S324
19
+
20
+
21
+ # Common low-signal tokens dropped before building a fine-cluster signature, so the
22
+ # bucket reflects the task's salient nouns/verbs rather than filler.
23
+ _STOPWORDS = frozenset(
24
+ """
25
+ a an and are as at be by can could do does for from given has have how i if in
26
+ into is it its me my of on or please that the their then there these this to
27
+ use using want was we what when where which who why will with would you your
28
+ """.split()
29
+ )
30
+ _WORD = re.compile(r"[a-z0-9]+")
31
+
32
+
33
+ def salient_signature(text: str, max_tokens: int = 4) -> str:
34
+ """A short, stable bucket id derived from a task's most salient tokens.
35
+
36
+ Lowercases, drops stopwords and very short tokens, keeps the longest distinct
37
+ tokens (longer words carry more topic signal), sorts for order-independence, and
38
+ hashes. Paraphrases that share salient vocabulary land in the same bucket; this
39
+ is a deterministic, embedding-free approximation of a topic cluster.
40
+ """
41
+ tokens = [t for t in _WORD.findall(text.lower()) if len(t) >= 4 and t not in _STOPWORDS]
42
+ if not tokens:
43
+ return "general"
44
+ # Distinct, longest-first, then alphabetical for a stable top-k selection.
45
+ ranked = sorted(set(tokens), key=lambda t: (-len(t), t))[:max_tokens]
46
+ key = " ".join(sorted(ranked))
47
+ return hashlib.sha1(key.encode("utf-8")).hexdigest()[:8] # noqa: S324
48
+
49
+
50
+ def task_cluster(task_type: str, difficulty: str, signature: str | None = None) -> str:
51
+ """Cluster used as the upsert grouping key, e.g. ``code:hard`` (coarse) or
52
+ ``code:hard:1a2b3c4d`` (fine, when a keyword signature is supplied)."""
53
+ base = f"{task_type}:{difficulty}"
54
+ return f"{base}:{signature}" if signature else base
55
+
56
+
57
+ def build_content(task_type: str, difficulty: str, text: str, max_chars: int = 512) -> str:
58
+ """The text Mubit embeds: a task gist prefixed with type/difficulty tags."""
59
+ return f"[{task_type}/{difficulty}] {normalize_task_text(text, max_chars)}"
60
+
61
+
62
+ def outcome_upsert_key(cluster: str, model_id: str) -> str:
63
+ """One durable outcome record per (task-cluster, model)."""
64
+ return f"minima:om:{cluster}:{model_id}"
65
+
66
+
67
+ def outcome_idempotency_key(recommendation_id: str, model_id: str) -> str:
68
+ raw = f"{recommendation_id}:{model_id}"
69
+ return "oc:" + hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16] # noqa: S324
70
+
71
+
72
+ def lesson_upsert_key(cluster: str, model_id: str) -> str:
73
+ """One durable lesson per (task-cluster, model) so repeated verified-prod wins
74
+ reinforce a single lesson rather than flooding LTM."""
75
+ return f"minima:lesson:{cluster}:{model_id}"
76
+
77
+
78
+ def build_lesson_content(cluster: str, model_id: str, quality: float) -> str:
79
+ """A compact NL lesson gist, embedded so reflect()/surface_strategies can cluster it."""
80
+ return (
81
+ f"For {cluster} tasks, {model_id} is a reliable, cost-effective choice "
82
+ f"(verified in production at ~{quality:.0%} quality)."
83
+ )