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.
- {memorymaster-3.17.1/memorymaster.egg-info → memorymaster-3.19.0}/PKG-INFO +1 -1
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/config.py +61 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/dashboard.py +47 -0
- memorymaster-3.19.0/memorymaster/dashboard_auth.py +196 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/jobs/daydream_ingest.py +45 -1
- memorymaster-3.19.0/memorymaster/llm_budget.py +190 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/llm_provider.py +26 -0
- memorymaster-3.19.0/memorymaster/mcp_path_policy.py +146 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/mcp_server.py +14 -5
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/retrieval.py +45 -7
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/service.py +76 -46
- memorymaster-3.19.0/memorymaster/webhook.py +161 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/wiki_engine.py +41 -1
- {memorymaster-3.17.1 → memorymaster-3.19.0/memorymaster.egg-info}/PKG-INFO +1 -1
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster.egg-info/SOURCES.txt +8 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/pyproject.toml +1 -1
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/bench_longmemeval.py +66 -5
- memorymaster-3.19.0/tests/test_dashboard_auth.py +290 -0
- memorymaster-3.19.0/tests/test_llm_budget.py +232 -0
- memorymaster-3.19.0/tests/test_mcp_path_policy.py +206 -0
- memorymaster-3.19.0/tests/test_retrieval_profiles.py +142 -0
- memorymaster-3.19.0/tests/test_webhook_hmac.py +216 -0
- memorymaster-3.17.1/memorymaster/webhook.py +0 -58
- {memorymaster-3.17.1 → memorymaster-3.19.0}/LICENSE +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/README.md +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/artifacts/bm25-per-field-eval-harness.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/benchmarks/longmemeval_runner.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/benchmarks/longmemeval_vector_runner.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/benchmarks/perf_smoke.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/__init__.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/__main__.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/_storage_lifecycle.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/_storage_read.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/_storage_schema.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/_storage_shared.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/_storage_sources.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/_storage_write_claims.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/access_control.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/action_exporters.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/action_extractor.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/atlas_claim_extractor.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/atlas_contract.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/auto_extractor.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/auto_resolver.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/candidate_dedupe.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/claim_edges.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/claim_verifier.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/cli.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/cli_handlers_basic.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/cli_handlers_curation.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/cli_helpers.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/closets.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/config_templates/claude-md-append.md +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/config_templates/codex-agents-md-append.md +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/config_templates/hooks/memorymaster-auto-ingest.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/config_templates/hooks/memorymaster-classify.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/config_templates/hooks/memorymaster-dream-sync.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/config_templates/hooks/memorymaster-precompact.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/config_templates/hooks/memorymaster-recall.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/config_templates/hooks/memorymaster-session-start.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/config_templates/hooks/memorymaster-steward-cycle.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/config_templates/hooks/memorymaster-validate-wiki.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/conflict_resolver.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/connectors/__init__.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/connectors/whatsapp.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/context_hook.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/context_optimizer.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/daily_notes.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/db_merge.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/dream_bridge.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/embeddings.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/entity_extractor.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/entity_graph.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/entity_registry.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/federated_graphify.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/feedback.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/graph_store.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/hook_log.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/jobs/__init__.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/jobs/calibration.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/jobs/compact_summaries.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/jobs/compactor.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/jobs/decay.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/jobs/dedup.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/jobs/deterministic.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/jobs/entity_graph_export.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/jobs/extractor.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/jobs/staleness.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/jobs/validator.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/key_rotator.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/lifecycle.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/llm_rerank.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/llm_steward.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/mcp_usage.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/media_processing.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/media_providers.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/metrics_exporter.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/models.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/observability.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/operator.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/operator_queue.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/plugins.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/policy.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/postgres_store.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/qdrant_backend.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/qdrant_recall_fallback.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/qmd_bridge.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/query_classifier.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/query_expansion.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/recall_fusion.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/recall_tokenizer.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/retry.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/review.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/rl_trainer.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/scheduler.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/schema.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/schema.sql +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/schema_postgres.sql +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/scope_utils.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/security.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/session_tracker.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/setup_hooks.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/skill_evolver.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/snapshot.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/steward.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/steward_classifier.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/steward_features.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/storage.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/store_factory.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/transcript_miner.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/turn_schema.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/vault_bases.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/vault_curator.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/vault_exporter.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/vault_linter.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/vault_log.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/vault_query_capture.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/vault_synthesis.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/verbatim_recall.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/verbatim_store.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/wiki_freshness.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/wiki_similarity.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/wiki_suggest.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster/wiki_validate.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster.egg-info/dependency_links.txt +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster.egg-info/entry_points.txt +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster.egg-info/requires.txt +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/memorymaster.egg-info/top_level.txt +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/agg_recall_latency.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/alert_operator_metrics.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/audit_dedupe_precision.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/autoresearch_daemon.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/backfill_entity_extraction.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/backfill_graph_store.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/backfill_stop_hook_citations.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/backtest_steward_classifier.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/build_steward_training_set.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/check_hook_template_drift.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/claude_to_turns.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/codex_live_to_turns.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/compaction_edge_cases.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/compaction_trace_report.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/compaction_trace_validate.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/confusion_matrix_eval.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/conversation_importer.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/conversation_to_turns.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/e2e_operator.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/email_live_to_turns.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/eval_bm25_sweep.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/eval_classify_f1.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/eval_memorymaster.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/eval_recall_precision_at_5.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/eval_recall_quality.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/eval_steward_pareto.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/eval_verbatim_recall.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/expand_recall_eval.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/generate_drill_signoff.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/git_to_turns.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/github_live_to_turns.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/gitnexus_to_claims.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/grid_recall_weights.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/index_claims_to_qdrant.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/ingest_planning_docs.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/jira_live_to_turns.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/label_prompts_with_judge.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/llm_benchmark.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/measure_dedupe_thresholds.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/merge_scope_variants.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/messages_to_turns.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/operator_metrics.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/precompute_candidates.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/recurring_incident_drill.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/release_readiness.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/run_codex_autologger.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/run_incident_drill.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/scheduled_ingest.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/setup-hooks.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/slack_live_to_turns.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/sync_hook_templates.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/tickets_to_turns.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/train_steward_classifier.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/scripts/webhook_to_turns.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/setup.cfg +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/conftest.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/integration/test_extract_llm_ollama_live.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_access_control.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_action_exporters.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_action_extractor.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_atlas_claim_extractor.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_atlas_contract.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_atlas_source_schema.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_auto_extractor.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_auto_ingest_hook_citations.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_auto_ingest_hook_schema.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_auto_resolver.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_auto_validate.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_bm25_per_field.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_calibration.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_calibration_priors_applied.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_candidate_dedupe.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_claim_edges.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_claim_links.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_claim_type_ranking.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_classify_hook_f1.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_classify_hook_latency.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_claude_to_turns.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_cli_dry_run.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_cli_json_flag.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_cli_ready.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_cli_review_queue.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_cli_subcommands.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_closets.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_closets_recall_integration.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_compact_summaries.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_compact_summaries_sensitivity.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_compaction_trace.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_compactor_artifact_order.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_config.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_conflict_resolver.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_confusion_matrix_eval.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_connection_retry.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_connectors.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_context_hook.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_context_optimizer.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_context_optimizer_provider.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_conversation_to_turns.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_dashboard.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_dashboard_coverage.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_dashboard_latency.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_dashboard_lineage.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_dashboard_review_queue.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_daydream_ingest.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_db_merge_confidence_conflict.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_db_merge_coverage_v2.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_decay_coverage.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_decay_respects_pinned.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_dedup.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_dedup_cli.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_dedup_conflict_disambiguation.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_deterministic_predicates.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_dream_bridge_coverage_v2.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_dream_bridge_sensitivity.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_embeddings_coverage.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_entity_extractor.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_entity_extractor_llm.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_entity_graph.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_entity_graph_export.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_entity_new_kinds.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_entity_regex_v3.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_entity_registry.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_eval_harness.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_events_schema.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_extract_llm_ollama.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_federated_graphify_mcp.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_federated_query_safety.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_feedback.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_fts5_search.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_graph_distance.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_graph_store.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_handler_regressions.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_hook_env_isolation.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_human_id.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_incident_drill_runner.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_integration_workflows.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_key_rotator.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_lifecycle.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_lifecycle_supersede_invariant.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_llm_fallback.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_llm_provider_claude_cli.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_llm_provider_key_rotation.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_llm_steward_coverage.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_llm_steward_key_rotation.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_mcp_filter_bypass.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_mcp_helpers.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_mcp_rate_limit.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_mcp_server_validation.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_mcp_usage.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_media_processing.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_meta_decisions.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_metrics_exporter.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_observability.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_obsidian_mind_patterns.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_operator.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_operator_queue.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_perf_smoke_config.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_plugins.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_policy_coverage.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_policy_mode_env.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_postgres_parity.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_qdrant_backend.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_qmd_bridge.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_query_classifier.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_query_expansion.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_recall_entity_fanout.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_recall_fusion.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_recall_latency.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_recall_precision_at_5.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_recall_tokenizer.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_recall_vector_fallback.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_reliability_hardening.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_resolvers_concurrent_supersede.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_retrieval_profile.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_retrieval_rrf_tiebreaker.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_retrieval_weights.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_review.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_rl_trainer.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_rrf_auto_gate.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_scheduler.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_schema.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_scope_boost.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_scope_utils.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_security_access.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_security_patterns.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_sensitivity_filter_adversarial.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_sensitivity_filter_adversarial_v2.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_sensitivity_filter_t07.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_service_coverage.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_session_tracker.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_snapshot.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_snapshot_roundtrip.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_sqlite_core.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_staleness.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_stealth_mode.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_steward.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_steward_classifier.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_steward_daydream_hook.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_steward_features.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_steward_features_v3.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_steward_resolution_parity.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_storage_parity.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_store_factory.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_tenant_isolation.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_turn_schema.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_two_pass_recall.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_v311_fixes.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_v313_e2e.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_v313_run_cycle_dedupe.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_v390_e2e.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_v391_strict_warnings.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_vault_exporter.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_vault_linter_orphan.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_vector_search.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_verbatim_dedup.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_verbatim_recall.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_verbatim_store.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_verbatim_store_qdrant.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_webhook.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_whatsapp_importer.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_wiki_autopromote.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_wiki_binding.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_wiki_engine_idempotency.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_wiki_explored_and_contradictions.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_wiki_freshness.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_wiki_similarity_multiscope.py +0 -0
- {memorymaster-3.17.1 → memorymaster-3.19.0}/tests/test_wiki_suggest.py +0 -0
- {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.
|
|
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
|
|