memorymaster 3.17.1__tar.gz → 3.19.0__tar.gz

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 (376) hide show
  1. {memorymaster-3.17.1/memorymaster.egg-info → memorymaster-3.19.0}/PKG-INFO +1 -1
  2. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/config.py +61 -0
  3. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/dashboard.py +47 -0
  4. memorymaster-3.19.0/memorymaster/dashboard_auth.py +196 -0
  5. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/jobs/daydream_ingest.py +45 -1
  6. memorymaster-3.19.0/memorymaster/llm_budget.py +190 -0
  7. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/llm_provider.py +26 -0
  8. memorymaster-3.19.0/memorymaster/mcp_path_policy.py +146 -0
  9. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/mcp_server.py +14 -5
  10. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/retrieval.py +45 -7
  11. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/service.py +76 -46
  12. memorymaster-3.19.0/memorymaster/webhook.py +161 -0
  13. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/wiki_engine.py +41 -1
  14. {memorymaster-3.17.1 → memorymaster-3.19.0/memorymaster.egg-info}/PKG-INFO +1 -1
  15. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster.egg-info/SOURCES.txt +8 -0
  16. {memorymaster-3.17.1 → memorymaster-3.19.0}/pyproject.toml +1 -1
  17. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/bench_longmemeval.py +66 -5
  18. memorymaster-3.19.0/tests/test_dashboard_auth.py +290 -0
  19. memorymaster-3.19.0/tests/test_llm_budget.py +232 -0
  20. memorymaster-3.19.0/tests/test_mcp_path_policy.py +206 -0
  21. memorymaster-3.19.0/tests/test_retrieval_profiles.py +142 -0
  22. memorymaster-3.19.0/tests/test_webhook_hmac.py +216 -0
  23. memorymaster-3.17.1/memorymaster/webhook.py +0 -58
  24. {memorymaster-3.17.1 → memorymaster-3.19.0}/LICENSE +0 -0
  25. {memorymaster-3.17.1 → memorymaster-3.19.0}/README.md +0 -0
  26. {memorymaster-3.17.1 → memorymaster-3.19.0}/artifacts/bm25-per-field-eval-harness.py +0 -0
  27. {memorymaster-3.17.1 → memorymaster-3.19.0}/benchmarks/longmemeval_runner.py +0 -0
  28. {memorymaster-3.17.1 → memorymaster-3.19.0}/benchmarks/longmemeval_vector_runner.py +0 -0
  29. {memorymaster-3.17.1 → memorymaster-3.19.0}/benchmarks/perf_smoke.py +0 -0
  30. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/__init__.py +0 -0
  31. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/__main__.py +0 -0
  32. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/_storage_lifecycle.py +0 -0
  33. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/_storage_read.py +0 -0
  34. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/_storage_schema.py +0 -0
  35. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/_storage_shared.py +0 -0
  36. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/_storage_sources.py +0 -0
  37. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/_storage_write_claims.py +0 -0
  38. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/access_control.py +0 -0
  39. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/action_exporters.py +0 -0
  40. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/action_extractor.py +0 -0
  41. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/atlas_claim_extractor.py +0 -0
  42. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/atlas_contract.py +0 -0
  43. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/auto_extractor.py +0 -0
  44. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/auto_resolver.py +0 -0
  45. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/candidate_dedupe.py +0 -0
  46. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/claim_edges.py +0 -0
  47. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/claim_verifier.py +0 -0
  48. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/cli.py +0 -0
  49. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/cli_handlers_basic.py +0 -0
  50. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/cli_handlers_curation.py +0 -0
  51. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/cli_helpers.py +0 -0
  52. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/closets.py +0 -0
  53. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/config_templates/claude-md-append.md +0 -0
  54. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/config_templates/codex-agents-md-append.md +0 -0
  55. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/config_templates/hooks/memorymaster-auto-ingest.py +0 -0
  56. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/config_templates/hooks/memorymaster-classify.py +0 -0
  57. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/config_templates/hooks/memorymaster-dream-sync.py +0 -0
  58. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/config_templates/hooks/memorymaster-precompact.py +0 -0
  59. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/config_templates/hooks/memorymaster-recall.py +0 -0
  60. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/config_templates/hooks/memorymaster-session-start.py +0 -0
  61. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/config_templates/hooks/memorymaster-steward-cycle.py +0 -0
  62. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/config_templates/hooks/memorymaster-validate-wiki.py +0 -0
  63. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/conflict_resolver.py +0 -0
  64. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/connectors/__init__.py +0 -0
  65. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/connectors/whatsapp.py +0 -0
  66. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/context_hook.py +0 -0
  67. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/context_optimizer.py +0 -0
  68. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/daily_notes.py +0 -0
  69. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/db_merge.py +0 -0
  70. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/dream_bridge.py +0 -0
  71. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/embeddings.py +0 -0
  72. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/entity_extractor.py +0 -0
  73. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/entity_graph.py +0 -0
  74. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/entity_registry.py +0 -0
  75. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/federated_graphify.py +0 -0
  76. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/feedback.py +0 -0
  77. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/graph_store.py +0 -0
  78. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/hook_log.py +0 -0
  79. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/jobs/__init__.py +0 -0
  80. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/jobs/calibration.py +0 -0
  81. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/jobs/compact_summaries.py +0 -0
  82. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/jobs/compactor.py +0 -0
  83. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/jobs/decay.py +0 -0
  84. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/jobs/dedup.py +0 -0
  85. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/jobs/deterministic.py +0 -0
  86. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/jobs/entity_graph_export.py +0 -0
  87. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/jobs/extractor.py +0 -0
  88. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/jobs/staleness.py +0 -0
  89. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/jobs/validator.py +0 -0
  90. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/key_rotator.py +0 -0
  91. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/lifecycle.py +0 -0
  92. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/llm_rerank.py +0 -0
  93. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/llm_steward.py +0 -0
  94. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/mcp_usage.py +0 -0
  95. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/media_processing.py +0 -0
  96. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/media_providers.py +0 -0
  97. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/metrics_exporter.py +0 -0
  98. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/models.py +0 -0
  99. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/observability.py +0 -0
  100. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/operator.py +0 -0
  101. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/operator_queue.py +0 -0
  102. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/plugins.py +0 -0
  103. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/policy.py +0 -0
  104. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/postgres_store.py +0 -0
  105. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/qdrant_backend.py +0 -0
  106. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/qdrant_recall_fallback.py +0 -0
  107. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/qmd_bridge.py +0 -0
  108. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/query_classifier.py +0 -0
  109. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/query_expansion.py +0 -0
  110. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/recall_fusion.py +0 -0
  111. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/recall_tokenizer.py +0 -0
  112. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/retry.py +0 -0
  113. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/review.py +0 -0
  114. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/rl_trainer.py +0 -0
  115. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/scheduler.py +0 -0
  116. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/schema.py +0 -0
  117. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/schema.sql +0 -0
  118. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/schema_postgres.sql +0 -0
  119. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/scope_utils.py +0 -0
  120. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/security.py +0 -0
  121. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/session_tracker.py +0 -0
  122. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/setup_hooks.py +0 -0
  123. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/skill_evolver.py +0 -0
  124. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/snapshot.py +0 -0
  125. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/steward.py +0 -0
  126. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/steward_classifier.py +0 -0
  127. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/steward_features.py +0 -0
  128. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/storage.py +0 -0
  129. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/store_factory.py +0 -0
  130. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/transcript_miner.py +0 -0
  131. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/turn_schema.py +0 -0
  132. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/vault_bases.py +0 -0
  133. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/vault_curator.py +0 -0
  134. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/vault_exporter.py +0 -0
  135. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/vault_linter.py +0 -0
  136. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/vault_log.py +0 -0
  137. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/vault_query_capture.py +0 -0
  138. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/vault_synthesis.py +0 -0
  139. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/verbatim_recall.py +0 -0
  140. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/verbatim_store.py +0 -0
  141. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/wiki_freshness.py +0 -0
  142. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/wiki_similarity.py +0 -0
  143. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/wiki_suggest.py +0 -0
  144. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/wiki_validate.py +0 -0
  145. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster.egg-info/dependency_links.txt +0 -0
  146. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster.egg-info/entry_points.txt +0 -0
  147. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster.egg-info/requires.txt +0 -0
  148. {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster.egg-info/top_level.txt +0 -0
  149. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/agg_recall_latency.py +0 -0
  150. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/alert_operator_metrics.py +0 -0
  151. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/audit_dedupe_precision.py +0 -0
  152. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/autoresearch_daemon.py +0 -0
  153. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/backfill_entity_extraction.py +0 -0
  154. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/backfill_graph_store.py +0 -0
  155. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/backfill_stop_hook_citations.py +0 -0
  156. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/backtest_steward_classifier.py +0 -0
  157. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/build_steward_training_set.py +0 -0
  158. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/check_hook_template_drift.py +0 -0
  159. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/claude_to_turns.py +0 -0
  160. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/codex_live_to_turns.py +0 -0
  161. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/compaction_edge_cases.py +0 -0
  162. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/compaction_trace_report.py +0 -0
  163. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/compaction_trace_validate.py +0 -0
  164. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/confusion_matrix_eval.py +0 -0
  165. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/conversation_importer.py +0 -0
  166. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/conversation_to_turns.py +0 -0
  167. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/e2e_operator.py +0 -0
  168. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/email_live_to_turns.py +0 -0
  169. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/eval_bm25_sweep.py +0 -0
  170. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/eval_classify_f1.py +0 -0
  171. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/eval_memorymaster.py +0 -0
  172. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/eval_recall_precision_at_5.py +0 -0
  173. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/eval_recall_quality.py +0 -0
  174. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/eval_steward_pareto.py +0 -0
  175. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/eval_verbatim_recall.py +0 -0
  176. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/expand_recall_eval.py +0 -0
  177. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/generate_drill_signoff.py +0 -0
  178. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/git_to_turns.py +0 -0
  179. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/github_live_to_turns.py +0 -0
  180. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/gitnexus_to_claims.py +0 -0
  181. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/grid_recall_weights.py +0 -0
  182. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/index_claims_to_qdrant.py +0 -0
  183. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/ingest_planning_docs.py +0 -0
  184. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/jira_live_to_turns.py +0 -0
  185. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/label_prompts_with_judge.py +0 -0
  186. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/llm_benchmark.py +0 -0
  187. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/measure_dedupe_thresholds.py +0 -0
  188. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/merge_scope_variants.py +0 -0
  189. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/messages_to_turns.py +0 -0
  190. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/operator_metrics.py +0 -0
  191. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/precompute_candidates.py +0 -0
  192. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/recurring_incident_drill.py +0 -0
  193. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/release_readiness.py +0 -0
  194. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/run_codex_autologger.py +0 -0
  195. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/run_incident_drill.py +0 -0
  196. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/scheduled_ingest.py +0 -0
  197. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/setup-hooks.py +0 -0
  198. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/slack_live_to_turns.py +0 -0
  199. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/sync_hook_templates.py +0 -0
  200. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/tickets_to_turns.py +0 -0
  201. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/train_steward_classifier.py +0 -0
  202. {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/webhook_to_turns.py +0 -0
  203. {memorymaster-3.17.1 → memorymaster-3.19.0}/setup.cfg +0 -0
  204. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/conftest.py +0 -0
  205. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/integration/test_extract_llm_ollama_live.py +0 -0
  206. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_access_control.py +0 -0
  207. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_action_exporters.py +0 -0
  208. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_action_extractor.py +0 -0
  209. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_atlas_claim_extractor.py +0 -0
  210. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_atlas_contract.py +0 -0
  211. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_atlas_source_schema.py +0 -0
  212. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_auto_extractor.py +0 -0
  213. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_auto_ingest_hook_citations.py +0 -0
  214. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_auto_ingest_hook_schema.py +0 -0
  215. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_auto_resolver.py +0 -0
  216. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_auto_validate.py +0 -0
  217. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_bm25_per_field.py +0 -0
  218. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_calibration.py +0 -0
  219. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_calibration_priors_applied.py +0 -0
  220. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_candidate_dedupe.py +0 -0
  221. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_claim_edges.py +0 -0
  222. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_claim_links.py +0 -0
  223. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_claim_type_ranking.py +0 -0
  224. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_classify_hook_f1.py +0 -0
  225. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_classify_hook_latency.py +0 -0
  226. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_claude_to_turns.py +0 -0
  227. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_cli_dry_run.py +0 -0
  228. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_cli_json_flag.py +0 -0
  229. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_cli_ready.py +0 -0
  230. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_cli_review_queue.py +0 -0
  231. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_cli_subcommands.py +0 -0
  232. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_closets.py +0 -0
  233. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_closets_recall_integration.py +0 -0
  234. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_compact_summaries.py +0 -0
  235. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_compact_summaries_sensitivity.py +0 -0
  236. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_compaction_trace.py +0 -0
  237. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_compactor_artifact_order.py +0 -0
  238. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_config.py +0 -0
  239. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_conflict_resolver.py +0 -0
  240. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_confusion_matrix_eval.py +0 -0
  241. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_connection_retry.py +0 -0
  242. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_connectors.py +0 -0
  243. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_context_hook.py +0 -0
  244. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_context_optimizer.py +0 -0
  245. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_context_optimizer_provider.py +0 -0
  246. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_conversation_to_turns.py +0 -0
  247. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_dashboard.py +0 -0
  248. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_dashboard_coverage.py +0 -0
  249. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_dashboard_latency.py +0 -0
  250. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_dashboard_lineage.py +0 -0
  251. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_dashboard_review_queue.py +0 -0
  252. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_daydream_ingest.py +0 -0
  253. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_db_merge_confidence_conflict.py +0 -0
  254. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_db_merge_coverage_v2.py +0 -0
  255. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_decay_coverage.py +0 -0
  256. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_decay_respects_pinned.py +0 -0
  257. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_dedup.py +0 -0
  258. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_dedup_cli.py +0 -0
  259. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_dedup_conflict_disambiguation.py +0 -0
  260. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_deterministic_predicates.py +0 -0
  261. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_dream_bridge_coverage_v2.py +0 -0
  262. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_dream_bridge_sensitivity.py +0 -0
  263. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_embeddings_coverage.py +0 -0
  264. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_entity_extractor.py +0 -0
  265. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_entity_extractor_llm.py +0 -0
  266. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_entity_graph.py +0 -0
  267. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_entity_graph_export.py +0 -0
  268. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_entity_new_kinds.py +0 -0
  269. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_entity_regex_v3.py +0 -0
  270. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_entity_registry.py +0 -0
  271. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_eval_harness.py +0 -0
  272. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_events_schema.py +0 -0
  273. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_extract_llm_ollama.py +0 -0
  274. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_federated_graphify_mcp.py +0 -0
  275. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_federated_query_safety.py +0 -0
  276. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_feedback.py +0 -0
  277. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_fts5_search.py +0 -0
  278. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_graph_distance.py +0 -0
  279. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_graph_store.py +0 -0
  280. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_handler_regressions.py +0 -0
  281. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_hook_env_isolation.py +0 -0
  282. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_human_id.py +0 -0
  283. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_incident_drill_runner.py +0 -0
  284. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_integration_workflows.py +0 -0
  285. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_key_rotator.py +0 -0
  286. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_lifecycle.py +0 -0
  287. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_lifecycle_supersede_invariant.py +0 -0
  288. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_llm_fallback.py +0 -0
  289. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_llm_provider_claude_cli.py +0 -0
  290. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_llm_provider_key_rotation.py +0 -0
  291. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_llm_steward_coverage.py +0 -0
  292. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_llm_steward_key_rotation.py +0 -0
  293. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_mcp_filter_bypass.py +0 -0
  294. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_mcp_helpers.py +0 -0
  295. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_mcp_rate_limit.py +0 -0
  296. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_mcp_server_validation.py +0 -0
  297. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_mcp_usage.py +0 -0
  298. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_media_processing.py +0 -0
  299. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_meta_decisions.py +0 -0
  300. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_metrics_exporter.py +0 -0
  301. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_observability.py +0 -0
  302. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_obsidian_mind_patterns.py +0 -0
  303. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_operator.py +0 -0
  304. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_operator_queue.py +0 -0
  305. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_perf_smoke_config.py +0 -0
  306. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_plugins.py +0 -0
  307. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_policy_coverage.py +0 -0
  308. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_policy_mode_env.py +0 -0
  309. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_postgres_parity.py +0 -0
  310. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_qdrant_backend.py +0 -0
  311. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_qmd_bridge.py +0 -0
  312. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_query_classifier.py +0 -0
  313. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_query_expansion.py +0 -0
  314. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_recall_entity_fanout.py +0 -0
  315. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_recall_fusion.py +0 -0
  316. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_recall_latency.py +0 -0
  317. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_recall_precision_at_5.py +0 -0
  318. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_recall_tokenizer.py +0 -0
  319. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_recall_vector_fallback.py +0 -0
  320. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_reliability_hardening.py +0 -0
  321. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_resolvers_concurrent_supersede.py +0 -0
  322. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_retrieval_profile.py +0 -0
  323. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_retrieval_rrf_tiebreaker.py +0 -0
  324. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_retrieval_weights.py +0 -0
  325. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_review.py +0 -0
  326. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_rl_trainer.py +0 -0
  327. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_rrf_auto_gate.py +0 -0
  328. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_scheduler.py +0 -0
  329. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_schema.py +0 -0
  330. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_scope_boost.py +0 -0
  331. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_scope_utils.py +0 -0
  332. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_security_access.py +0 -0
  333. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_security_patterns.py +0 -0
  334. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_sensitivity_filter_adversarial.py +0 -0
  335. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_sensitivity_filter_adversarial_v2.py +0 -0
  336. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_sensitivity_filter_t07.py +0 -0
  337. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_service_coverage.py +0 -0
  338. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_session_tracker.py +0 -0
  339. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_snapshot.py +0 -0
  340. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_snapshot_roundtrip.py +0 -0
  341. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_sqlite_core.py +0 -0
  342. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_staleness.py +0 -0
  343. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_stealth_mode.py +0 -0
  344. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_steward.py +0 -0
  345. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_steward_classifier.py +0 -0
  346. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_steward_daydream_hook.py +0 -0
  347. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_steward_features.py +0 -0
  348. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_steward_features_v3.py +0 -0
  349. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_steward_resolution_parity.py +0 -0
  350. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_storage_parity.py +0 -0
  351. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_store_factory.py +0 -0
  352. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_tenant_isolation.py +0 -0
  353. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_turn_schema.py +0 -0
  354. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_two_pass_recall.py +0 -0
  355. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_v311_fixes.py +0 -0
  356. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_v313_e2e.py +0 -0
  357. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_v313_run_cycle_dedupe.py +0 -0
  358. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_v390_e2e.py +0 -0
  359. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_v391_strict_warnings.py +0 -0
  360. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_vault_exporter.py +0 -0
  361. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_vault_linter_orphan.py +0 -0
  362. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_vector_search.py +0 -0
  363. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_verbatim_dedup.py +0 -0
  364. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_verbatim_recall.py +0 -0
  365. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_verbatim_store.py +0 -0
  366. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_verbatim_store_qdrant.py +0 -0
  367. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_webhook.py +0 -0
  368. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_whatsapp_importer.py +0 -0
  369. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_wiki_autopromote.py +0 -0
  370. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_wiki_binding.py +0 -0
  371. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_wiki_engine_idempotency.py +0 -0
  372. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_wiki_explored_and_contradictions.py +0 -0
  373. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_wiki_freshness.py +0 -0
  374. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_wiki_similarity_multiscope.py +0 -0
  375. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_wiki_suggest.py +0 -0
  376. {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_wiki_validate_cli.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memorymaster
3
- Version: 3.17.1
3
+ Version: 3.19.0
4
4
  Summary: Production-grade memory reliability system for AI coding agents. Lifecycle-managed claims with citations, conflict detection, steward governance, and MCP integration.
5
5
  Author: wolverin0
6
6
  License: MIT
@@ -67,6 +67,17 @@ MEMORYMASTER_RRF_TIEBREAKER_THRESHOLD
67
67
  Maximum adjacent score gap considered a near tie.
68
68
  Default: ``0.01``
69
69
 
70
+ MEMORYMASTER_RETRIEVAL_PROFILE_<TYPE>
71
+ Per-question-type retrieval weight override. ``<TYPE>`` is the
72
+ upper-snake-case form of the question type (LongMemEval-S labels:
73
+ SINGLE_SESSION_USER, SINGLE_SESSION_ASSISTANT, SINGLE_SESSION_PREFERENCE,
74
+ TEMPORAL_REASONING, KNOWLEDGE_UPDATE, MULTI_SESSION). Value format
75
+ matches ``MEMORYMASTER_RETRIEVAL_WEIGHTS``: comma-separated floats
76
+ ``lex,conf,fresh,vec``. When set, ``Config.retrieval_profile(qtype)``
77
+ returns the profile and ``retrieval._compute_claim_score`` consumes it
78
+ instead of the default weights. Profiles are opt-in; missing types fall
79
+ back to the global ``cfg.retrieval_weights``.
80
+
70
81
  MEMORYMASTER_CONFIG_FILE
71
82
  Path to a JSON config file. Keys match attribute names on ``Config``.
72
83
  """
@@ -218,6 +229,15 @@ class Config:
218
229
  default_factory=lambda: dict(INITIAL_CONFIDENCE_BY_TYPE)
219
230
  )
220
231
 
232
+ # --- Per-question-type retrieval weight profiles (S3, opt-in) ---
233
+ # Maps the canonical question_type slug (lowercase, hyphens preserved,
234
+ # e.g. "single-session-preference") to a (W_LEX, W_CONF, W_FRESH, W_VEC)
235
+ # tuple. Populated by env vars MEMORYMASTER_RETRIEVAL_PROFILE_<TYPE>.
236
+ # Empty by default — retrieval falls back to ``retrieval_weights``.
237
+ retrieval_profiles: Dict[str, tuple[float, float, float, float]] = field(
238
+ default_factory=dict
239
+ )
240
+
221
241
  # --- Derived convenience dicts ---
222
242
 
223
243
  @property
@@ -270,6 +290,20 @@ class Config:
270
290
  self.lexical_weight_prefix,
271
291
  )
272
292
 
293
+ def retrieval_profile(
294
+ self, question_type: str | None
295
+ ) -> tuple[float, float, float, float] | None:
296
+ """Per-question-type weight override, or None to fall back to default.
297
+
298
+ Accepts the canonical bench label (e.g. ``single-session-preference``)
299
+ or the classifier output (e.g. ``preference``) — both lookup forms are
300
+ normalized to lowercase before lookup. Returning None signals the
301
+ caller to use ``retrieval_weights`` as usual.
302
+ """
303
+ if not question_type:
304
+ return None
305
+ return self.retrieval_profiles.get(question_type.strip().lower())
306
+
273
307
 
274
308
  # ---------------------------------------------------------------------------
275
309
  # Module-level singleton
@@ -363,6 +397,7 @@ def load_config(config_path: str | Path | None = None) -> Config:
363
397
  _apply_env_bool(overrides, "MEMORYMASTER_LLM_RERANK", "llm_rerank")
364
398
  _apply_env_bool(overrides, "MEMORYMASTER_RRF_TIEBREAKER", "rrf_tiebreaker_enabled")
365
399
  _apply_env_float(overrides, "MEMORYMASTER_RRF_TIEBREAKER_THRESHOLD", "rrf_tiebreaker_threshold")
400
+ _apply_env_retrieval_profiles(overrides)
366
401
 
367
402
  # Filter to only valid Config fields
368
403
  valid_fields = {f.name for f in Config.__dataclass_fields__.values()}
@@ -404,3 +439,29 @@ def _apply_env_bool(overrides: dict[str, object], env_var: str, key: str) -> Non
404
439
  if not raw:
405
440
  return
406
441
  overrides[key] = raw in {"1", "true", "yes", "on"}
442
+
443
+
444
+ _PROFILE_PREFIX = "MEMORYMASTER_RETRIEVAL_PROFILE_"
445
+
446
+
447
+ def _apply_env_retrieval_profiles(overrides: dict[str, object]) -> None:
448
+ """Collect per-type profile overrides from env into a single dict.
449
+
450
+ Scans every env var starting with ``MEMORYMASTER_RETRIEVAL_PROFILE_``.
451
+ The suffix becomes the question_type slug (lowercased, underscores
452
+ converted to hyphens to match the LongMemEval-S canonical labels like
453
+ ``single-session-preference``).
454
+ """
455
+ profiles: dict[str, tuple[float, float, float, float]] = {}
456
+ for key, raw in os.environ.items():
457
+ if not key.startswith(_PROFILE_PREFIX):
458
+ continue
459
+ raw = raw.strip()
460
+ if not raw:
461
+ continue
462
+ suffix = key[len(_PROFILE_PREFIX):]
463
+ qtype = suffix.lower().replace("_", "-")
464
+ values = _parse_floats(raw, 4)
465
+ profiles[qtype] = (values[0], values[1], values[2], values[3])
466
+ if profiles:
467
+ overrides["retrieval_profiles"] = profiles
@@ -19,6 +19,7 @@ import urllib.error
19
19
  import urllib.request
20
20
  from urllib.parse import parse_qs, urlparse
21
21
 
22
+ from memorymaster import dashboard_auth
22
23
  from memorymaster.config import get_config
23
24
  from memorymaster.review import build_review_queue, queue_to_dicts
24
25
  from memorymaster.security import is_sensitive_claim
@@ -626,6 +627,9 @@ class DashboardHTTPServer(ThreadingHTTPServer):
626
627
  self.db_target = str(db_target) if db_target is not None else "memorymaster.db"
627
628
  self.workspace_root = Path(workspace_root) if workspace_root is not None else Path.cwd()
628
629
  self._operator_proc: subprocess.Popen[str] | None = None
630
+ # Pin the configured host:port string so the CSRF check can compare it
631
+ # against the request's Origin/Referer header in authenticate POST routes.
632
+ self.configured_host_port = f"{server_address[0]}:{server_address[1]}"
629
633
  super().__init__(server_address, DashboardRequestHandler)
630
634
 
631
635
  def operator_status(self) -> dict[str, Any]:
@@ -701,10 +705,45 @@ class DashboardRequestHandler(BaseHTTPRequestHandler):
701
705
  def _server(self) -> DashboardHTTPServer:
702
706
  return self.server # type: ignore[return-value]
703
707
 
708
+ def _enforce_auth(self, *, method: str, route: str) -> bool:
709
+ """Run the v3.19.0-H2 auth + role + CSRF gates. Returns True on pass.
710
+
711
+ On failure, the JSON error response has already been written; the
712
+ caller must return immediately without dispatching the route.
713
+ Health endpoints (/health, /healthz, /readyz) are intentionally
714
+ exempt so external monitors can probe without credentials.
715
+ """
716
+ if route in {"/health", "/healthz", "/readyz"}:
717
+ return True
718
+
719
+ decision = dashboard_auth.authenticate(self.headers)
720
+ decision = dashboard_auth.authorize(decision, method=method, route=route)
721
+ if not decision.ok:
722
+ self._write_json(
723
+ {"ok": False, "error": decision.reason},
724
+ status=decision.status,
725
+ )
726
+ return False
727
+
728
+ if method.upper() == "POST":
729
+ csrf = dashboard_auth.check_csrf(
730
+ self.headers,
731
+ configured_host_port=getattr(self._server, "configured_host_port", None),
732
+ )
733
+ if not csrf.ok:
734
+ self._write_json(
735
+ {"ok": False, "error": csrf.reason},
736
+ status=csrf.status,
737
+ )
738
+ return False
739
+ return True
740
+
704
741
  def do_GET(self) -> None: # noqa: N802
705
742
  parsed = urlparse(self.path)
706
743
  route = parsed.path
707
744
  try:
745
+ if not self._enforce_auth(method="GET", route=route):
746
+ return
708
747
  if _route_get_request(self, route, parsed.query):
709
748
  return
710
749
  self._write_json({"ok": False, "error": "Not found"}, status=HTTPStatus.NOT_FOUND)
@@ -717,6 +756,8 @@ class DashboardRequestHandler(BaseHTTPRequestHandler):
717
756
  parsed = urlparse(self.path)
718
757
  route = parsed.path
719
758
  try:
759
+ if not self._enforce_auth(method="POST", route=route):
760
+ return
720
761
  payload = self._read_json_body()
721
762
  if route == "/api/triage/action":
722
763
  self._handle_triage_action(payload)
@@ -1443,6 +1484,12 @@ def create_dashboard_server(
1443
1484
  port: int = 8765,
1444
1485
  operator_log_jsonl: str | Path = "artifacts/operator/operator_events.jsonl",
1445
1486
  ) -> DashboardHTTPServer:
1487
+ # v3.19.0-H2: refuse non-loopback bind without an auth secret unless the
1488
+ # operator explicitly opts in. Raises dashboard_auth.BindUnsafeError on
1489
+ # an unsafe configuration; callers should let it propagate so the
1490
+ # mistake is visible at startup instead of silently exposed.
1491
+ dashboard_auth.check_bind_safety(host)
1492
+
1446
1493
  if service is None:
1447
1494
  if db_target is None:
1448
1495
  db_target = "memorymaster.db"
@@ -0,0 +1,196 @@
1
+ """HTTP-layer authentication, CSRF, and bind-safety for the dashboard (v3.19.0-H2).
2
+
3
+ The dashboard's prior posture was "trusted local-only" — zero HTTP auth,
4
+ default loopback bind. Anyone who could reach the port had full read +
5
+ operator-control. This module adds opt-in token auth with viewer/operator
6
+ role separation, CSRF for browser POSTs, and refusal to bind non-loopback
7
+ hosts without an explicit secret.
8
+
9
+ Backwards compatibility: when no auth secrets are configured (both
10
+ ``MEMORYMASTER_DASHBOARD_TOKEN_VIEWER`` and ``MEMORYMASTER_DASHBOARD_TOKEN_OPERATOR``
11
+ are empty), the dashboard runs in legacy mode — no auth enforced. Bind
12
+ safety still applies: legacy mode refuses non-loopback bind unless the
13
+ operator explicitly opts in with ``MEMORYMASTER_DASHBOARD_UNSAFE_BIND=1``.
14
+
15
+ Env vars:
16
+ MEMORYMASTER_DASHBOARD_TOKEN_VIEWER — bearer token granting read-only access
17
+ MEMORYMASTER_DASHBOARD_TOKEN_OPERATOR — bearer token granting full mutating access
18
+ MEMORYMASTER_DASHBOARD_UNSAFE_BIND — set to 1 to allow non-loopback bind
19
+ without an auth secret (logs WARNING)
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import hmac
24
+ import logging
25
+ import os
26
+ from dataclasses import dataclass
27
+ from enum import Enum
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ class DashboardRole(str, Enum):
33
+ VIEWER = "viewer"
34
+ OPERATOR = "operator"
35
+
36
+
37
+ # Routes that require operator role regardless of HTTP method. POST routes
38
+ # always require operator role separately. This list captures GET routes that
39
+ # are mutating-adjacent (start/stop operator control, stream that may carry
40
+ # sensitive operator events).
41
+ OPERATOR_ONLY_GET_ROUTES: frozenset[str] = frozenset({
42
+ "/api/operator/control", # POST in practice but pin it here for completeness
43
+ "/api/operator/stream", # SSE — operator-only since it streams operator events
44
+ })
45
+
46
+ # POST routes — all require operator.
47
+ POST_OPERATOR_ROUTES: frozenset[str] = frozenset({
48
+ "/api/triage/action",
49
+ "/api/operator/control",
50
+ "/api/action-proposals/status",
51
+ })
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class AuthDecision:
56
+ """Outcome of an authentication or authorization check."""
57
+
58
+ ok: bool
59
+ role: DashboardRole | None = None
60
+ reason: str = ""
61
+ status: int = 200
62
+
63
+
64
+ class BindUnsafeError(RuntimeError):
65
+ """Raised when the dashboard refuses to bind a non-loopback host without auth."""
66
+
67
+
68
+ def _env_token(name: str) -> str:
69
+ return os.environ.get(name, "").strip()
70
+
71
+
72
+ def legacy_mode() -> bool:
73
+ """True when no auth secrets are configured (back-compat path).
74
+
75
+ Legacy mode preserves the pre-v3.19 behaviour for loopback bind — no
76
+ auth check, no CSRF. Bind safety still applies regardless.
77
+ """
78
+ return not (
79
+ _env_token("MEMORYMASTER_DASHBOARD_TOKEN_VIEWER")
80
+ or _env_token("MEMORYMASTER_DASHBOARD_TOKEN_OPERATOR")
81
+ )
82
+
83
+
84
+ def _extract_bearer(headers) -> str:
85
+ raw = headers.get("Authorization", "") if headers else ""
86
+ if not raw or not raw.lower().startswith("bearer "):
87
+ return ""
88
+ return raw[7:].strip()
89
+
90
+
91
+ def authenticate(headers) -> AuthDecision:
92
+ """Map an incoming request's Authorization header to a role.
93
+
94
+ Behaviour:
95
+ - Legacy mode (no tokens set): return ok with role=operator (no enforcement).
96
+ - Missing token: 401 (``missing_token``).
97
+ - Token matches operator: ok with role=operator.
98
+ - Token matches viewer: ok with role=viewer.
99
+ - Token unrecognized: 401 (``invalid_token``).
100
+
101
+ Constant-time comparison (``hmac.compare_digest``) prevents timing attacks.
102
+ """
103
+ if legacy_mode():
104
+ return AuthDecision(True, role=DashboardRole.OPERATOR)
105
+
106
+ token = _extract_bearer(headers)
107
+ if not token:
108
+ return AuthDecision(False, reason="missing_token", status=401)
109
+
110
+ op_token = _env_token("MEMORYMASTER_DASHBOARD_TOKEN_OPERATOR")
111
+ vw_token = _env_token("MEMORYMASTER_DASHBOARD_TOKEN_VIEWER")
112
+
113
+ if op_token and hmac.compare_digest(token, op_token):
114
+ return AuthDecision(True, role=DashboardRole.OPERATOR)
115
+ if vw_token and hmac.compare_digest(token, vw_token):
116
+ return AuthDecision(True, role=DashboardRole.VIEWER)
117
+ return AuthDecision(False, reason="invalid_token", status=401)
118
+
119
+
120
+ def authorize(decision: AuthDecision, *, method: str, route: str) -> AuthDecision:
121
+ """Refine an authentication decision against the role-vs-route policy.
122
+
123
+ Returns 403 (``role_required_operator``) for routes where viewer is
124
+ insufficient. Passes ``decision`` through unchanged on success.
125
+ """
126
+ if not decision.ok:
127
+ return decision
128
+
129
+ method_u = method.upper()
130
+ operator_required = (
131
+ method_u == "POST"
132
+ or route in OPERATOR_ONLY_GET_ROUTES
133
+ or route in POST_OPERATOR_ROUTES
134
+ )
135
+ if operator_required and decision.role != DashboardRole.OPERATOR:
136
+ return AuthDecision(
137
+ False,
138
+ role=decision.role,
139
+ reason="role_required_operator",
140
+ status=403,
141
+ )
142
+ return decision
143
+
144
+
145
+ def check_csrf(headers, *, configured_host_port: str | None) -> AuthDecision:
146
+ """Validate Origin/Referer for browser-originated POSTs.
147
+
148
+ Non-browser clients (curl, scripts, MCP-style integrations) typically
149
+ omit ``Origin`` — those requests pass through unchallenged. Browsers
150
+ always set ``Origin``; when present, it must contain the configured
151
+ host:port string. Returns 403 (``csrf_origin_mismatch``) on mismatch.
152
+
153
+ Legacy mode skips CSRF entirely.
154
+ """
155
+ if legacy_mode():
156
+ return AuthDecision(True)
157
+
158
+ origin = ""
159
+ if headers:
160
+ origin = headers.get("Origin", "") or headers.get("Referer", "") or ""
161
+ if not origin:
162
+ return AuthDecision(True) # non-browser caller
163
+ if configured_host_port and configured_host_port not in origin:
164
+ return AuthDecision(False, reason="csrf_origin_mismatch", status=403)
165
+ return AuthDecision(True)
166
+
167
+
168
+ def check_bind_safety(host: str) -> None:
169
+ """Refuse non-loopback bind without an auth secret or explicit opt-in.
170
+
171
+ Raises ``BindUnsafeError`` if all of: (a) host is non-loopback,
172
+ (b) no auth tokens configured, (c) ``MEMORYMASTER_DASHBOARD_UNSAFE_BIND``
173
+ not set. Otherwise returns silently. Logs a WARNING for the unsafe opt-in
174
+ case so operators see they're running exposed.
175
+ """
176
+ loopback_hosts = {"127.0.0.1", "::1", "localhost", ""}
177
+ if host in loopback_hosts:
178
+ return
179
+ if not legacy_mode():
180
+ return # token-based auth is enforced; non-loopback bind is acceptable
181
+
182
+ unsafe_raw = os.environ.get("MEMORYMASTER_DASHBOARD_UNSAFE_BIND", "").strip().lower()
183
+ if unsafe_raw in {"1", "true", "yes", "on"}:
184
+ logger.warning(
185
+ "dashboard binding to non-loopback host '%s' with no auth secret "
186
+ "and MEMORYMASTER_DASHBOARD_UNSAFE_BIND=1 — running exposed",
187
+ host,
188
+ )
189
+ return
190
+
191
+ raise BindUnsafeError(
192
+ f"Refusing to bind dashboard to non-loopback host '{host}' without auth secret. "
193
+ f"Set MEMORYMASTER_DASHBOARD_TOKEN_OPERATOR (and optionally "
194
+ f"MEMORYMASTER_DASHBOARD_TOKEN_VIEWER), or explicitly opt-in with "
195
+ f"MEMORYMASTER_DASHBOARD_UNSAFE_BIND=1."
196
+ )
@@ -33,7 +33,51 @@ def ingest_insights(
33
33
  scope: str = "user",
34
34
  dry_run: bool = False,
35
35
  ) -> dict:
36
- """Ingest accepted daydream insights as candidate hypothesis claims."""
36
+ """Ingest accepted daydream insights as candidate hypothesis claims.
37
+
38
+ Honours per-cycle LLM budget caps from ``llm_budget``. Today this path
39
+ does not itself call ``call_llm`` (insights arrive pre-scored), but the
40
+ wrapper is in place so downstream changes (re-scoring, paraphrase
41
+ detection, etc.) inherit the same abort semantics as ``service.run_cycle``
42
+ and ``wiki_engine.absorb``. If a parent scope is already open, defers
43
+ to it instead of opening a nested one.
44
+ """
45
+ from memorymaster import llm_budget
46
+
47
+ if llm_budget.get_current() is not None:
48
+ return _ingest_insights_impl(
49
+ service, insights_dir, min_score=min_score, scope=scope, dry_run=dry_run
50
+ )
51
+
52
+ with llm_budget.cycle_scope() as budget:
53
+ try:
54
+ result = _ingest_insights_impl(
55
+ service, insights_dir, min_score=min_score, scope=scope, dry_run=dry_run
56
+ )
57
+ except llm_budget.LLMBudgetExceeded as exc:
58
+ result = {
59
+ "ingested": 0,
60
+ "skipped": 0,
61
+ "errors": [
62
+ f"daydream ingest aborted by llm budget: reason={exc.reason}"
63
+ ],
64
+ "aborted": True,
65
+ "aborted_reason": exc.reason,
66
+ "aborted_provider": exc.provider,
67
+ }
68
+ result["budget"] = budget.snapshot()
69
+ return result
70
+
71
+
72
+ def _ingest_insights_impl(
73
+ service: MemoryService,
74
+ insights_dir: Path,
75
+ *,
76
+ min_score: float = 7.0,
77
+ scope: str = "user",
78
+ dry_run: bool = False,
79
+ ) -> dict:
80
+ """Original ingest_insights implementation, called inside a budget scope."""
37
81
  root = Path(insights_dir)
38
82
  result: dict[str, Any] = {"ingested": 0, "skipped": 0, "errors": []}
39
83
  if not root.exists() or not root.is_dir():
@@ -0,0 +1,190 @@
1
+ """Per-cycle LLM budget caps with reason-coded hard stops.
2
+
3
+ A cycle scope tracks LLM call count, estimated tokens, and per-provider
4
+ failures. When any cap is exceeded, the next ``llm_provider.call_llm``
5
+ raises ``LLMBudgetExceeded`` with a reason code so callers can record
6
+ the abort visibly instead of silently overspending.
7
+
8
+ Caps are read from env vars at scope-entry time (any change requires a
9
+ new scope to take effect):
10
+
11
+ - ``MEMORYMASTER_MAX_LLM_CALLS_PER_CYCLE`` — hard cap on total calls
12
+ - ``MEMORYMASTER_MAX_TOKENS_PER_CYCLE`` — hard cap on summed estimated tokens
13
+ - ``MEMORYMASTER_MAX_PROVIDER_FAILURES_PER_CYCLE`` — per-provider failure ceiling
14
+ (also acts as circuit breaker)
15
+
16
+ A value of ``0`` (default) means "unlimited" for that axis — preserves
17
+ backwards compatibility when env vars are unset.
18
+
19
+ Usage::
20
+
21
+ from memorymaster import llm_budget
22
+
23
+ with llm_budget.cycle_scope() as budget:
24
+ ...
25
+ try:
26
+ response = llm_provider.call_llm(prompt, text)
27
+ except llm_budget.LLMBudgetExceeded as exc:
28
+ # exc.reason in {"calls_exhausted", "tokens_exhausted",
29
+ # "provider_failures_exhausted"}
30
+ # exc.provider is set only for provider-failures reason.
31
+ ...
32
+ ...
33
+ snapshot = budget.snapshot() # totals after the scope
34
+ """
35
+ from __future__ import annotations
36
+
37
+ import os
38
+ from contextlib import contextmanager
39
+ from contextvars import ContextVar
40
+ from dataclasses import dataclass, field
41
+ from typing import Iterator
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Exception + dataclass
46
+ # ---------------------------------------------------------------------------
47
+
48
+
49
+ class LLMBudgetExceeded(Exception):
50
+ """Raised by ``call_llm`` when a per-cycle budget cap is hit.
51
+
52
+ Attributes:
53
+ reason: one of ``"calls_exhausted"``, ``"tokens_exhausted"``,
54
+ ``"provider_failures_exhausted"``.
55
+ provider: provider name (set only for the failures reason).
56
+ """
57
+
58
+ def __init__(self, reason: str, provider: str | None = None) -> None:
59
+ self.reason = reason
60
+ self.provider = provider
61
+ suffix = f" provider={provider}" if provider else ""
62
+ super().__init__(f"llm budget exceeded: reason={reason}{suffix}")
63
+
64
+
65
+ @dataclass
66
+ class CycleBudget:
67
+ """Per-cycle counters and limits. Lives for the duration of one scope.
68
+
69
+ Limits of 0 mean unlimited. ``aborted_reason`` is set the first time
70
+ a cap is hit (subsequent overruns don't overwrite the original reason).
71
+ """
72
+
73
+ max_calls: int = 0
74
+ max_tokens: int = 0
75
+ max_provider_failures: int = 0
76
+ calls: int = 0
77
+ tokens: int = 0
78
+ provider_failures: dict[str, int] = field(default_factory=dict)
79
+ aborted_reason: str | None = None
80
+ aborted_provider: str | None = None
81
+
82
+ def snapshot(self) -> dict[str, object]:
83
+ return {
84
+ "max_calls": self.max_calls,
85
+ "max_tokens": self.max_tokens,
86
+ "max_provider_failures": self.max_provider_failures,
87
+ "calls": self.calls,
88
+ "tokens": self.tokens,
89
+ "provider_failures": dict(self.provider_failures),
90
+ "aborted_reason": self.aborted_reason,
91
+ "aborted_provider": self.aborted_provider,
92
+ }
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Context variable
97
+ # ---------------------------------------------------------------------------
98
+
99
+
100
+ _current: ContextVar[CycleBudget | None] = ContextVar(
101
+ "memorymaster_llm_cycle_budget", default=None
102
+ )
103
+
104
+
105
+ def _read_int_env(name: str) -> int:
106
+ raw = os.environ.get(name, "").strip()
107
+ if not raw:
108
+ return 0
109
+ try:
110
+ return max(0, int(raw))
111
+ except ValueError:
112
+ return 0
113
+
114
+
115
+ def _new_from_env() -> CycleBudget:
116
+ return CycleBudget(
117
+ max_calls=_read_int_env("MEMORYMASTER_MAX_LLM_CALLS_PER_CYCLE"),
118
+ max_tokens=_read_int_env("MEMORYMASTER_MAX_TOKENS_PER_CYCLE"),
119
+ max_provider_failures=_read_int_env("MEMORYMASTER_MAX_PROVIDER_FAILURES_PER_CYCLE"),
120
+ )
121
+
122
+
123
+ @contextmanager
124
+ def cycle_scope() -> Iterator[CycleBudget]:
125
+ """Open a new per-cycle budget scope. Yields the tracker; auto-cleans on exit."""
126
+ budget = _new_from_env()
127
+ token = _current.set(budget)
128
+ try:
129
+ yield budget
130
+ finally:
131
+ _current.reset(token)
132
+
133
+
134
+ def get_current() -> CycleBudget | None:
135
+ """Return the active cycle budget, or None if no scope is open."""
136
+ return _current.get()
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Enforcement helpers — invoked from llm_provider.call_llm
141
+ # ---------------------------------------------------------------------------
142
+
143
+
144
+ def _abort(budget: CycleBudget, reason: str, provider: str | None = None) -> None:
145
+ """Record the first abort reason and raise."""
146
+ if budget.aborted_reason is None:
147
+ budget.aborted_reason = reason
148
+ budget.aborted_provider = provider
149
+ raise LLMBudgetExceeded(reason, provider)
150
+
151
+
152
+ def estimate_tokens(*parts: str) -> int:
153
+ """Rough char/4 estimator. Sufficient for cap accounting; not for billing."""
154
+ total = sum(len(p) for p in parts if p)
155
+ return (total + 3) // 4
156
+
157
+
158
+ def check_before_call(provider: str) -> None:
159
+ """Raise if the calls cap is already at its ceiling, or this provider's
160
+ failure breaker is open. Called before contacting the provider."""
161
+ budget = get_current()
162
+ if budget is None:
163
+ return
164
+ if budget.max_calls and budget.calls >= budget.max_calls:
165
+ _abort(budget, "calls_exhausted")
166
+ if budget.max_provider_failures:
167
+ if budget.provider_failures.get(provider, 0) >= budget.max_provider_failures:
168
+ _abort(budget, "provider_failures_exhausted", provider)
169
+
170
+
171
+ def record_call(provider: str, *, tokens: int = 0) -> None:
172
+ """Record a successful (or attempted) call. Raises if tokens cap is hit."""
173
+ budget = get_current()
174
+ if budget is None:
175
+ return
176
+ budget.calls += 1
177
+ budget.tokens += max(0, tokens)
178
+ if budget.max_tokens and budget.tokens >= budget.max_tokens:
179
+ _abort(budget, "tokens_exhausted")
180
+
181
+
182
+ def record_failure(provider: str) -> None:
183
+ """Increment per-provider failure counter. Raises if breaker hits limit."""
184
+ budget = get_current()
185
+ if budget is None:
186
+ return
187
+ new_count = budget.provider_failures.get(provider, 0) + 1
188
+ budget.provider_failures[provider] = new_count
189
+ if budget.max_provider_failures and new_count >= budget.max_provider_failures:
190
+ _abort(budget, "provider_failures_exhausted", provider)
@@ -17,6 +17,8 @@ import urllib.request
17
17
  import urllib.error
18
18
  from typing import Any
19
19
 
20
+ from memorymaster import llm_budget
21
+
20
22
 
21
23
  def _env(key: str, default: str = "") -> str:
22
24
  return os.environ.get(key, default)
@@ -489,13 +491,28 @@ def call_llm(prompt: str, text: str) -> str:
489
491
  if not primary_fn:
490
492
  return ""
491
493
 
494
+ # Per-cycle budget gate. If a cycle_scope() is active and the calls cap
495
+ # is already hit (or this provider's failure breaker is open), raise
496
+ # LLMBudgetExceeded so the caller can record the abort visibly instead
497
+ # of silently overspending. When no scope is active, this is a no-op
498
+ # — preserves backwards-compat for callers outside run_cycle/wiki/daydream.
499
+ llm_budget.check_before_call(primary_name)
500
+
492
501
  primary_response = primary_fn(prompt, text)
502
+ llm_budget.record_call(
503
+ primary_name,
504
+ tokens=llm_budget.estimate_tokens(prompt, text, primary_response),
505
+ )
493
506
 
494
507
  # Happy path: primary returned something that doesn't look like a quota error.
495
508
  if primary_response and not _looks_like_quota_error(primary_response):
496
509
  _FALLBACK_STATS["primary_ok"] += 1
497
510
  return primary_response
498
511
 
512
+ # Treat empty or quota-shaped response as a provider failure for breaker purposes.
513
+ # record_failure may raise if the per-provider failure cap is hit.
514
+ llm_budget.record_failure(primary_name)
515
+
499
516
  fallback_name = _env("MEMORYMASTER_LLM_FALLBACK_PROVIDER", "").lower()
500
517
  if not fallback_name:
501
518
  return primary_response
@@ -512,6 +529,9 @@ def call_llm(prompt: str, text: str) -> str:
512
529
  log.info("llm_fallback_fired primary=%s reason=%s", primary_name, reason)
513
530
  _FALLBACK_STATS["fired"] += 1
514
531
 
532
+ # Budget gate for the fallback provider too.
533
+ llm_budget.check_before_call(fallback_name)
534
+
515
535
  # Swap MEMORYMASTER_LLM_MODEL to fallback model for the duration of the call.
516
536
  fallback_model = _env("MEMORYMASTER_LLM_FALLBACK_MODEL", "")
517
537
  saved_model = os.environ.get("MEMORYMASTER_LLM_MODEL")
@@ -523,6 +543,10 @@ def call_llm(prompt: str, text: str) -> str:
523
543
  # own default, not the primary's model (which may be Gemini-specific).
524
544
  del os.environ["MEMORYMASTER_LLM_MODEL"]
525
545
  fallback_response = fallback_fn(prompt, text)
546
+ llm_budget.record_call(
547
+ fallback_name,
548
+ tokens=llm_budget.estimate_tokens(prompt, text, fallback_response),
549
+ )
526
550
  finally:
527
551
  if saved_model is None:
528
552
  os.environ.pop("MEMORYMASTER_LLM_MODEL", None)
@@ -533,6 +557,8 @@ def call_llm(prompt: str, text: str) -> str:
533
557
  return fallback_response
534
558
 
535
559
  # Both failed — match legacy contract, return primary's (possibly empty) response.
560
+ # Record fallback failure for breaker purposes; may raise if cap is hit.
561
+ llm_budget.record_failure(fallback_name)
536
562
  return primary_response
537
563
 
538
564