superlocalmemory 4.1.13 → 4.1.14
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.
- package/.claude-plugin/marketplace.json +2 -2
- package/CHANGELOG.md +48 -0
- package/README.md +3 -3
- package/package.json +1 -8
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/agents/slm-memory-advisor.md +1 -1
- package/plugin-src/agents/slm-optimize-advisor.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-governance/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-loop/SKILL.md +1 -1
- package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
- package/plugin-src/skills/slm-profile/SKILL.md +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-scope/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/scripts/postinstall.js +71 -2
- package/pyproject.toml +0 -250
- package/src/superlocalmemory/__init__.py +0 -82
- package/src/superlocalmemory/access/__init__.py +0 -3
- package/src/superlocalmemory/access/rbac.py +0 -575
- package/src/superlocalmemory/attribution/__init__.py +0 -9
- package/src/superlocalmemory/attribution/signer.py +0 -173
- package/src/superlocalmemory/attribution/watermark.py +0 -189
- package/src/superlocalmemory/brain/__init__.py +0 -5
- package/src/superlocalmemory/brain/truth.py +0 -418
- package/src/superlocalmemory/cli/__init__.py +0 -5
- package/src/superlocalmemory/cli/__main__.py +0 -17
- package/src/superlocalmemory/cli/_lazy_init.py +0 -115
- package/src/superlocalmemory/cli/cache_cmd.py +0 -198
- package/src/superlocalmemory/cli/commands.py +0 -4710
- package/src/superlocalmemory/cli/compress_cmd.py +0 -151
- package/src/superlocalmemory/cli/context_commands.py +0 -193
- package/src/superlocalmemory/cli/daemon.py +0 -909
- package/src/superlocalmemory/cli/db_migrate.py +0 -150
- package/src/superlocalmemory/cli/diagnostics_cmd.py +0 -101
- package/src/superlocalmemory/cli/escape_hatch.py +0 -220
- package/src/superlocalmemory/cli/evidence_cmd.py +0 -103
- package/src/superlocalmemory/cli/gdpr_cmd.py +0 -792
- package/src/superlocalmemory/cli/gdpr_io.py +0 -109
- package/src/superlocalmemory/cli/help_cmd.py +0 -197
- package/src/superlocalmemory/cli/host_upgrades.py +0 -189
- package/src/superlocalmemory/cli/ingest_cmd.py +0 -327
- package/src/superlocalmemory/cli/json_output.py +0 -81
- package/src/superlocalmemory/cli/loop_cmd.py +0 -187
- package/src/superlocalmemory/cli/main.py +0 -1145
- package/src/superlocalmemory/cli/mesh_cmd.py +0 -38
- package/src/superlocalmemory/cli/migrate_cmd.py +0 -55
- package/src/superlocalmemory/cli/ops_cmd.py +0 -281
- package/src/superlocalmemory/cli/optimize_cmd.py +0 -179
- package/src/superlocalmemory/cli/optimize_constants.py +0 -31
- package/src/superlocalmemory/cli/pending_store.py +0 -296
- package/src/superlocalmemory/cli/proxy_cmd.py +0 -108
- package/src/superlocalmemory/cli/scale_engine_cmd.py +0 -56
- package/src/superlocalmemory/cli/service_installer.py +0 -373
- package/src/superlocalmemory/cli/setup_wizard.py +0 -1162
- package/src/superlocalmemory/cli/summary_cmd.py +0 -215
- package/src/superlocalmemory/cli/version_banner.py +0 -202
- package/src/superlocalmemory/cli/wizard_v3426_options.py +0 -129
- package/src/superlocalmemory/code_graph/__init__.py +0 -46
- package/src/superlocalmemory/code_graph/blast_radius.py +0 -177
- package/src/superlocalmemory/code_graph/bridge/__init__.py +0 -36
- package/src/superlocalmemory/code_graph/bridge/entity_resolver.py +0 -490
- package/src/superlocalmemory/code_graph/bridge/event_listeners.py +0 -206
- package/src/superlocalmemory/code_graph/bridge/fact_enricher.py +0 -159
- package/src/superlocalmemory/code_graph/bridge/hebbian_linker.py +0 -170
- package/src/superlocalmemory/code_graph/bridge/maintenance.py +0 -220
- package/src/superlocalmemory/code_graph/bridge/temporal_checker.py +0 -152
- package/src/superlocalmemory/code_graph/changes.py +0 -363
- package/src/superlocalmemory/code_graph/communities.py +0 -299
- package/src/superlocalmemory/code_graph/config.py +0 -154
- package/src/superlocalmemory/code_graph/database.py +0 -526
- package/src/superlocalmemory/code_graph/extractors/__init__.py +0 -95
- package/src/superlocalmemory/code_graph/extractors/python.py +0 -413
- package/src/superlocalmemory/code_graph/extractors/typescript.py +0 -556
- package/src/superlocalmemory/code_graph/flows.py +0 -350
- package/src/superlocalmemory/code_graph/git_hooks.py +0 -226
- package/src/superlocalmemory/code_graph/graph_engine.py +0 -295
- package/src/superlocalmemory/code_graph/graph_store.py +0 -335
- package/src/superlocalmemory/code_graph/incremental.py +0 -200
- package/src/superlocalmemory/code_graph/models.py +0 -130
- package/src/superlocalmemory/code_graph/parser.py +0 -687
- package/src/superlocalmemory/code_graph/resolver.py +0 -321
- package/src/superlocalmemory/code_graph/search.py +0 -460
- package/src/superlocalmemory/code_graph/service.py +0 -95
- package/src/superlocalmemory/code_graph/watcher.py +0 -207
- package/src/superlocalmemory/compliance/__init__.py +0 -0
- package/src/superlocalmemory/compliance/abac.py +0 -204
- package/src/superlocalmemory/compliance/audit.py +0 -385
- package/src/superlocalmemory/compliance/eu_ai_act.py +0 -101
- package/src/superlocalmemory/compliance/gdpr.py +0 -1479
- package/src/superlocalmemory/compliance/lifecycle.py +0 -158
- package/src/superlocalmemory/compliance/retention.py +0 -415
- package/src/superlocalmemory/compliance/scheduler.py +0 -217
- package/src/superlocalmemory/contracts/__init__.py +0 -1
- package/src/superlocalmemory/contracts/schemas/agent-experience-v1.schema.json +0 -92
- package/src/superlocalmemory/contracts/schemas/agent-integration-contract-v2.schema.json +0 -46
- package/src/superlocalmemory/contracts/schemas/cognitive-turn-receipt-v1.schema.json +0 -59
- package/src/superlocalmemory/contracts/v402.py +0 -62
- package/src/superlocalmemory/core/__init__.py +0 -0
- package/src/superlocalmemory/core/actor_context.py +0 -166
- package/src/superlocalmemory/core/admission.py +0 -769
- package/src/superlocalmemory/core/backend_orchestrator.py +0 -637
- package/src/superlocalmemory/core/block_hygiene.py +0 -147
- package/src/superlocalmemory/core/community_summary.py +0 -267
- package/src/superlocalmemory/core/component_healer.py +0 -144
- package/src/superlocalmemory/core/component_registry.py +0 -514
- package/src/superlocalmemory/core/config.py +0 -2204
- package/src/superlocalmemory/core/consolidation_engine.py +0 -983
- package/src/superlocalmemory/core/context_cache.py +0 -574
- package/src/superlocalmemory/core/derivation_lineage.py +0 -246
- package/src/superlocalmemory/core/embedding_worker.py +0 -208
- package/src/superlocalmemory/core/embeddings.py +0 -1052
- package/src/superlocalmemory/core/engine.py +0 -1395
- package/src/superlocalmemory/core/engine_capabilities.py +0 -24
- package/src/superlocalmemory/core/engine_ingestion.py +0 -983
- package/src/superlocalmemory/core/engine_lock.py +0 -75
- package/src/superlocalmemory/core/engine_wiring.py +0 -776
- package/src/superlocalmemory/core/entity_community.py +0 -178
- package/src/superlocalmemory/core/error_envelope.py +0 -60
- package/src/superlocalmemory/core/evidence_bundle.py +0 -528
- package/src/superlocalmemory/core/fact_consolidator.py +0 -812
- package/src/superlocalmemory/core/file_lock.py +0 -92
- package/src/superlocalmemory/core/graph_analyzer.py +0 -456
- package/src/superlocalmemory/core/graph_metrics.py +0 -597
- package/src/superlocalmemory/core/graph_pruner.py +0 -939
- package/src/superlocalmemory/core/health_monitor.py +0 -338
- package/src/superlocalmemory/core/hooks.py +0 -65
- package/src/superlocalmemory/core/ingest_gate.py +0 -133
- package/src/superlocalmemory/core/ingest_policy.py +0 -38
- package/src/superlocalmemory/core/ingestion_command.py +0 -1042
- package/src/superlocalmemory/core/injection.py +0 -434
- package/src/superlocalmemory/core/install_detector.py +0 -131
- package/src/superlocalmemory/core/key_expander.py +0 -138
- package/src/superlocalmemory/core/lifecycle_state.py +0 -153
- package/src/superlocalmemory/core/maintenance.py +0 -777
- package/src/superlocalmemory/core/maintenance_scheduler.py +0 -507
- package/src/superlocalmemory/core/materialization_control.py +0 -20
- package/src/superlocalmemory/core/mcp_embedder_proxy.py +0 -89
- package/src/superlocalmemory/core/memory_health.py +0 -266
- package/src/superlocalmemory/core/mode_capability.py +0 -111
- package/src/superlocalmemory/core/modes.py +0 -168
- package/src/superlocalmemory/core/mutations.py +0 -688
- package/src/superlocalmemory/core/ollama_embedder.py +0 -266
- package/src/superlocalmemory/core/ollama_validator.py +0 -315
- package/src/superlocalmemory/core/operation_policy.py +0 -92
- package/src/superlocalmemory/core/operation_policy_registry.py +0 -542
- package/src/superlocalmemory/core/operation_request.py +0 -127
- package/src/superlocalmemory/core/ops_remediation.py +0 -542
- package/src/superlocalmemory/core/pii.py +0 -105
- package/src/superlocalmemory/core/platform_utils.py +0 -138
- package/src/superlocalmemory/core/profiles.py +0 -234
- package/src/superlocalmemory/core/progressive_abstraction.py +0 -208
- package/src/superlocalmemory/core/projection_drain.py +0 -380
- package/src/superlocalmemory/core/queue_consumer.py +0 -168
- package/src/superlocalmemory/core/ram_lock.py +0 -160
- package/src/superlocalmemory/core/rate_limit.py +0 -151
- package/src/superlocalmemory/core/recall_gate.py +0 -95
- package/src/superlocalmemory/core/recall_pipeline.py +0 -1337
- package/src/superlocalmemory/core/recall_queue.py +0 -377
- package/src/superlocalmemory/core/recall_worker.py +0 -414
- package/src/superlocalmemory/core/registry.py +0 -121
- package/src/superlocalmemory/core/remember_admission.py +0 -161
- package/src/superlocalmemory/core/remember_runtime.py +0 -1190
- package/src/superlocalmemory/core/remote_mode.py +0 -214
- package/src/superlocalmemory/core/reranker_worker.py +0 -338
- package/src/superlocalmemory/core/safe_fs.py +0 -108
- package/src/superlocalmemory/core/scale_autopromote.py +0 -196
- package/src/superlocalmemory/core/scale_engine.py +0 -915
- package/src/superlocalmemory/core/score_contract.py +0 -82
- package/src/superlocalmemory/core/security_primitives.py +0 -672
- package/src/superlocalmemory/core/session_identity.py +0 -98
- package/src/superlocalmemory/core/shadow_router.py +0 -319
- package/src/superlocalmemory/core/slm_disabled.py +0 -85
- package/src/superlocalmemory/core/status_contract.py +0 -108
- package/src/superlocalmemory/core/store_pipeline.py +0 -1404
- package/src/superlocalmemory/core/summarizer.py +0 -200
- package/src/superlocalmemory/core/tier_manager.py +0 -461
- package/src/superlocalmemory/core/topic_signature.py +0 -156
- package/src/superlocalmemory/core/transactions/__init__.py +0 -78
- package/src/superlocalmemory/core/transactions/concrete_owners.py +0 -604
- package/src/superlocalmemory/core/transactions/erasure.py +0 -825
- package/src/superlocalmemory/core/transactions/manifest.py +0 -255
- package/src/superlocalmemory/core/transactions/manifest_key.py +0 -155
- package/src/superlocalmemory/core/transactions/obligations.py +0 -272
- package/src/superlocalmemory/core/transactions/owners.py +0 -114
- package/src/superlocalmemory/core/transactions/reconciler.py +0 -285
- package/src/superlocalmemory/core/transactions/service.py +0 -330
- package/src/superlocalmemory/core/worker_pool.py +0 -377
- package/src/superlocalmemory/core/working_memory.py +0 -288
- package/src/superlocalmemory/dynamics/__init__.py +0 -0
- package/src/superlocalmemory/dynamics/activation_guided_quantization.py +0 -374
- package/src/superlocalmemory/dynamics/eap_scheduler.py +0 -294
- package/src/superlocalmemory/dynamics/ebbinghaus_langevin_coupling.py +0 -171
- package/src/superlocalmemory/dynamics/fisher_langevin_coupling.py +0 -227
- package/src/superlocalmemory/encoding/__init__.py +0 -0
- package/src/superlocalmemory/encoding/auto_linker.py +0 -308
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +0 -899
- package/src/superlocalmemory/encoding/consolidator.py +0 -472
- package/src/superlocalmemory/encoding/context_generator.py +0 -175
- package/src/superlocalmemory/encoding/emotional.py +0 -189
- package/src/superlocalmemory/encoding/entity_reflexion.py +0 -200
- package/src/superlocalmemory/encoding/entity_resolver.py +0 -687
- package/src/superlocalmemory/encoding/entropy_gate.py +0 -101
- package/src/superlocalmemory/encoding/fact_extractor.py +0 -877
- package/src/superlocalmemory/encoding/foresight.py +0 -93
- package/src/superlocalmemory/encoding/graph_builder.py +0 -346
- package/src/superlocalmemory/encoding/observation_builder.py +0 -177
- package/src/superlocalmemory/encoding/prospective_markers.py +0 -262
- package/src/superlocalmemory/encoding/scene_builder.py +0 -410
- package/src/superlocalmemory/encoding/signal_inference.py +0 -90
- package/src/superlocalmemory/encoding/temporal_parser.py +0 -432
- package/src/superlocalmemory/encoding/temporal_validator.py +0 -572
- package/src/superlocalmemory/encoding/type_router.py +0 -237
- package/src/superlocalmemory/evaluation/__init__.py +0 -13
- package/src/superlocalmemory/evaluation/calibration.py +0 -308
- package/src/superlocalmemory/evolution/__init__.py +0 -29
- package/src/superlocalmemory/evolution/blind_verifier.py +0 -122
- package/src/superlocalmemory/evolution/budget.py +0 -356
- package/src/superlocalmemory/evolution/evolution_store.py +0 -619
- package/src/superlocalmemory/evolution/llm_dispatch.py +0 -559
- package/src/superlocalmemory/evolution/model_selection.py +0 -175
- package/src/superlocalmemory/evolution/mutation_generator.py +0 -226
- package/src/superlocalmemory/evolution/skill_activator.py +0 -270
- package/src/superlocalmemory/evolution/skill_evolver.py +0 -928
- package/src/superlocalmemory/evolution/triggers.py +0 -376
- package/src/superlocalmemory/evolution/types.py +0 -114
- package/src/superlocalmemory/graph/__init__.py +0 -9
- package/src/superlocalmemory/graph/cozo_adjacency.py +0 -122
- package/src/superlocalmemory/graph/cozo_backend.py +0 -751
- package/src/superlocalmemory/hooks/__init__.py +0 -3
- package/src/superlocalmemory/hooks/_outcome_common.py +0 -523
- package/src/superlocalmemory/hooks/adapter_base.py +0 -347
- package/src/superlocalmemory/hooks/antigravity_adapter.py +0 -171
- package/src/superlocalmemory/hooks/auto_capture.py +0 -133
- package/src/superlocalmemory/hooks/auto_invoker.py +0 -521
- package/src/superlocalmemory/hooks/auto_parameterize.py +0 -147
- package/src/superlocalmemory/hooks/auto_recall.py +0 -191
- package/src/superlocalmemory/hooks/auto_recall_hook.py +0 -251
- package/src/superlocalmemory/hooks/before_web_hook.py +0 -131
- package/src/superlocalmemory/hooks/claude_code_hooks.py +0 -637
- package/src/superlocalmemory/hooks/codex_assets.py +0 -251
- package/src/superlocalmemory/hooks/codex_hooks.py +0 -186
- package/src/superlocalmemory/hooks/context_payload.py +0 -311
- package/src/superlocalmemory/hooks/copilot_adapter.py +0 -208
- package/src/superlocalmemory/hooks/cross_platform_connector.py +0 -90
- package/src/superlocalmemory/hooks/cursor_adapter.py +0 -173
- package/src/superlocalmemory/hooks/hook_daemon.py +0 -295
- package/src/superlocalmemory/hooks/hook_handlers.py +0 -822
- package/src/superlocalmemory/hooks/ide_connector.py +0 -246
- package/src/superlocalmemory/hooks/memory_protocol.py +0 -158
- package/src/superlocalmemory/hooks/portable_kit.py +0 -755
- package/src/superlocalmemory/hooks/post_tool_async_hook.py +0 -183
- package/src/superlocalmemory/hooks/post_tool_outcome_hook.py +0 -351
- package/src/superlocalmemory/hooks/prewarm_auth.py +0 -187
- package/src/superlocalmemory/hooks/rules_engine.py +0 -99
- package/src/superlocalmemory/hooks/session_registry.py +0 -330
- package/src/superlocalmemory/hooks/stop_outcome_hook.py +0 -138
- package/src/superlocalmemory/hooks/sync_loop.py +0 -114
- package/src/superlocalmemory/hooks/topic_shift_hook.py +0 -302
- package/src/superlocalmemory/hooks/user_prompt_hook.py +0 -131
- package/src/superlocalmemory/hooks/user_prompt_rehash_hook.py +0 -210
- package/src/superlocalmemory/infra/__init__.py +0 -3
- package/src/superlocalmemory/infra/auth_middleware.py +0 -145
- package/src/superlocalmemory/infra/backup.py +0 -974
- package/src/superlocalmemory/infra/backup_obligations.py +0 -423
- package/src/superlocalmemory/infra/cache_manager.py +0 -267
- package/src/superlocalmemory/infra/cloud_backup.py +0 -788
- package/src/superlocalmemory/infra/daemon_identity.py +0 -300
- package/src/superlocalmemory/infra/data_root.py +0 -238
- package/src/superlocalmemory/infra/event_bus.py +0 -637
- package/src/superlocalmemory/infra/local_diagnostics.py +0 -327
- package/src/superlocalmemory/infra/pid_manager.py +0 -193
- package/src/superlocalmemory/infra/process_identity.py +0 -180
- package/src/superlocalmemory/infra/process_reaper.py +0 -624
- package/src/superlocalmemory/infra/rate_limiter.py +0 -228
- package/src/superlocalmemory/infra/self_heal.py +0 -401
- package/src/superlocalmemory/infra/version_integrity.py +0 -229
- package/src/superlocalmemory/ingestion/__init__.py +0 -13
- package/src/superlocalmemory/ingestion/adapter_manager.py +0 -255
- package/src/superlocalmemory/ingestion/base_adapter.py +0 -171
- package/src/superlocalmemory/ingestion/calendar_adapter.py +0 -349
- package/src/superlocalmemory/ingestion/credentials.py +0 -125
- package/src/superlocalmemory/ingestion/gmail_adapter.py +0 -378
- package/src/superlocalmemory/ingestion/parsers.py +0 -100
- package/src/superlocalmemory/ingestion/transcript_adapter.py +0 -161
- package/src/superlocalmemory/integrations/__init__.py +0 -1
- package/src/superlocalmemory/integrations/bounded_loops_mcp.py +0 -431
- package/src/superlocalmemory/integrations/bounded_loops_v051.py +0 -236
- package/src/superlocalmemory/learning/__init__.py +0 -0
- package/src/superlocalmemory/learning/adaptive.py +0 -172
- package/src/superlocalmemory/learning/arm_catalog.py +0 -97
- package/src/superlocalmemory/learning/assertion_miner.py +0 -403
- package/src/superlocalmemory/learning/bandit.py +0 -654
- package/src/superlocalmemory/learning/bandit_cache.py +0 -131
- package/src/superlocalmemory/learning/behavioral.py +0 -542
- package/src/superlocalmemory/learning/bootstrap.py +0 -298
- package/src/superlocalmemory/learning/consolidation_cycle.py +0 -398
- package/src/superlocalmemory/learning/consolidation_quantization_worker.py +0 -115
- package/src/superlocalmemory/learning/consolidation_worker.py +0 -261
- package/src/superlocalmemory/learning/cross_project.py +0 -408
- package/src/superlocalmemory/learning/database.py +0 -698
- package/src/superlocalmemory/learning/dedup_hnsw.py +0 -413
- package/src/superlocalmemory/learning/engagement.py +0 -487
- package/src/superlocalmemory/learning/engagement_features.py +0 -279
- package/src/superlocalmemory/learning/ensemble.py +0 -309
- package/src/superlocalmemory/learning/entity_compiler.py +0 -356
- package/src/superlocalmemory/learning/fact_outcome_joins.py +0 -207
- package/src/superlocalmemory/learning/features.py +0 -138
- package/src/superlocalmemory/learning/feedback.py +0 -724
- package/src/superlocalmemory/learning/forgetting_scheduler.py +0 -375
- package/src/superlocalmemory/learning/hnsw_dedup.py +0 -69
- package/src/superlocalmemory/learning/labeler.py +0 -85
- package/src/superlocalmemory/learning/legacy_migration.py +0 -316
- package/src/superlocalmemory/learning/lightgbm_subprocess.py +0 -236
- package/src/superlocalmemory/learning/memory_merge.py +0 -175
- package/src/superlocalmemory/learning/model_cache.py +0 -267
- package/src/superlocalmemory/learning/model_rollback.py +0 -281
- package/src/superlocalmemory/learning/outcome_queue.py +0 -306
- package/src/superlocalmemory/learning/outcomes.py +0 -286
- package/src/superlocalmemory/learning/pattern_miner.py +0 -465
- package/src/superlocalmemory/learning/pattern_miner_constants.py +0 -90
- package/src/superlocalmemory/learning/pcos.py +0 -291
- package/src/superlocalmemory/learning/project_context.py +0 -366
- package/src/superlocalmemory/learning/propensity.py +0 -131
- package/src/superlocalmemory/learning/ranker.py +0 -300
- package/src/superlocalmemory/learning/ranker_common.py +0 -163
- package/src/superlocalmemory/learning/ranker_retrain_legacy.py +0 -210
- package/src/superlocalmemory/learning/ranker_retrain_online.py +0 -423
- package/src/superlocalmemory/learning/reward.py +0 -888
- package/src/superlocalmemory/learning/reward_archive.py +0 -223
- package/src/superlocalmemory/learning/reward_boost.py +0 -211
- package/src/superlocalmemory/learning/reward_from_outcomes.py +0 -365
- package/src/superlocalmemory/learning/reward_model.py +0 -144
- package/src/superlocalmemory/learning/reward_proxy.py +0 -578
- package/src/superlocalmemory/learning/shadow_test.py +0 -524
- package/src/superlocalmemory/learning/signal_kinds.py +0 -79
- package/src/superlocalmemory/learning/signal_worker.py +0 -268
- package/src/superlocalmemory/learning/signals.py +0 -646
- package/src/superlocalmemory/learning/skill_performance_miner.py +0 -422
- package/src/superlocalmemory/learning/source_quality.py +0 -828
- package/src/superlocalmemory/learning/trigram_index.py +0 -548
- package/src/superlocalmemory/learning/workflows.py +0 -309
- package/src/superlocalmemory/llm/__init__.py +0 -0
- package/src/superlocalmemory/llm/backbone.py +0 -364
- package/src/superlocalmemory/loops/__init__.py +0 -58
- package/src/superlocalmemory/loops/budget.py +0 -58
- package/src/superlocalmemory/loops/engine.py +0 -174
- package/src/superlocalmemory/loops/ledger.py +0 -298
- package/src/superlocalmemory/loops/models.py +0 -152
- package/src/superlocalmemory/loops/rules.py +0 -52
- package/src/superlocalmemory/math/__init__.py +0 -0
- package/src/superlocalmemory/math/ebbinghaus.py +0 -352
- package/src/superlocalmemory/math/fisher.py +0 -356
- package/src/superlocalmemory/math/fisher_quantized.py +0 -255
- package/src/superlocalmemory/math/hopfield.py +0 -282
- package/src/superlocalmemory/math/langevin.py +0 -411
- package/src/superlocalmemory/math/polar_quant.py +0 -414
- package/src/superlocalmemory/math/qjl.py +0 -115
- package/src/superlocalmemory/math/sheaf.py +0 -261
- package/src/superlocalmemory/math/turbo_quant.py +0 -318
- package/src/superlocalmemory/mcp/__init__.py +0 -0
- package/src/superlocalmemory/mcp/_daemon_proxy.py +0 -203
- package/src/superlocalmemory/mcp/_pool_adapter.py +0 -181
- package/src/superlocalmemory/mcp/_stdin_guard.py +0 -60
- package/src/superlocalmemory/mcp/agent_context.py +0 -115
- package/src/superlocalmemory/mcp/cli_fallback.py +0 -602
- package/src/superlocalmemory/mcp/http_transport.py +0 -85
- package/src/superlocalmemory/mcp/profiles.py +0 -154
- package/src/superlocalmemory/mcp/resources.py +0 -281
- package/src/superlocalmemory/mcp/server.py +0 -480
- package/src/superlocalmemory/mcp/session_binding.py +0 -98
- package/src/superlocalmemory/mcp/shared.py +0 -112
- package/src/superlocalmemory/mcp/tools.py +0 -18
- package/src/superlocalmemory/mcp/tools_active.py +0 -958
- package/src/superlocalmemory/mcp/tools_brain.py +0 -298
- package/src/superlocalmemory/mcp/tools_code_graph.py +0 -1717
- package/src/superlocalmemory/mcp/tools_context.py +0 -239
- package/src/superlocalmemory/mcp/tools_core.py +0 -1112
- package/src/superlocalmemory/mcp/tools_evolution.py +0 -343
- package/src/superlocalmemory/mcp/tools_learning.py +0 -393
- package/src/superlocalmemory/mcp/tools_loops.py +0 -345
- package/src/superlocalmemory/mcp/tools_mesh.py +0 -429
- package/src/superlocalmemory/mcp/tools_ops.py +0 -115
- package/src/superlocalmemory/mcp/tools_optimize.py +0 -322
- package/src/superlocalmemory/mcp/tools_summaries.py +0 -147
- package/src/superlocalmemory/mcp/tools_v28.py +0 -292
- package/src/superlocalmemory/mcp/tools_v3.py +0 -398
- package/src/superlocalmemory/mcp/tools_v33.py +0 -507
- package/src/superlocalmemory/mesh/__init__.py +0 -12
- package/src/superlocalmemory/mesh/broker.py +0 -812
- package/src/superlocalmemory/mesh/broker_security.py +0 -470
- package/src/superlocalmemory/mesh/discovery.py +0 -365
- package/src/superlocalmemory/mesh/lock_protocol.py +0 -313
- package/src/superlocalmemory/mesh/node_identity.py +0 -97
- package/src/superlocalmemory/mesh/outbox_remote.py +0 -429
- package/src/superlocalmemory/mesh/remote_sync.py +0 -829
- package/src/superlocalmemory/mesh/state_sync.py +0 -286
- package/src/superlocalmemory/migrations/__init__.py +0 -5
- package/src/superlocalmemory/migrations/v3_4_25_to_v3_4_26.py +0 -144
- package/src/superlocalmemory/optimize/NOTICE +0 -6
- package/src/superlocalmemory/optimize/__init__.py +0 -0
- package/src/superlocalmemory/optimize/adapters/__init__.py +0 -68
- package/src/superlocalmemory/optimize/adapters/_agent_registry.py +0 -120
- package/src/superlocalmemory/optimize/adapters/anthropic_adapter.py +0 -112
- package/src/superlocalmemory/optimize/adapters/openai_adapter.py +0 -122
- package/src/superlocalmemory/optimize/adapters/wrap.py +0 -228
- package/src/superlocalmemory/optimize/cache/__init__.py +0 -31
- package/src/superlocalmemory/optimize/cache/boundary_store.py +0 -488
- package/src/superlocalmemory/optimize/cache/centroid_store.py +0 -199
- package/src/superlocalmemory/optimize/cache/context_key.py +0 -67
- package/src/superlocalmemory/optimize/cache/exact.py +0 -88
- package/src/superlocalmemory/optimize/cache/invalidation.py +0 -36
- package/src/superlocalmemory/optimize/cache/key_builder.py +0 -111
- package/src/superlocalmemory/optimize/cache/manager.py +0 -737
- package/src/superlocalmemory/optimize/cache/semantic.py +0 -637
- package/src/superlocalmemory/optimize/cache/stampede.py +0 -50
- package/src/superlocalmemory/optimize/compress/__init__.py +0 -17
- package/src/superlocalmemory/optimize/compress/align.py +0 -159
- package/src/superlocalmemory/optimize/compress/ccr.py +0 -116
- package/src/superlocalmemory/optimize/compress/prose_llmlingua.py +0 -71
- package/src/superlocalmemory/optimize/compress/router.py +0 -667
- package/src/superlocalmemory/optimize/config/__init__.py +0 -56
- package/src/superlocalmemory/optimize/config/defaults.py +0 -43
- package/src/superlocalmemory/optimize/config/schema.py +0 -327
- package/src/superlocalmemory/optimize/config/store.py +0 -270
- package/src/superlocalmemory/optimize/metrics/__init__.py +0 -8
- package/src/superlocalmemory/optimize/metrics/counters.py +0 -155
- package/src/superlocalmemory/optimize/metrics/estimator.py +0 -87
- package/src/superlocalmemory/optimize/metrics/exporters.py +0 -77
- package/src/superlocalmemory/optimize/metrics/persistence.py +0 -115
- package/src/superlocalmemory/optimize/proxy/__init__.py +0 -28
- package/src/superlocalmemory/optimize/proxy/_helpers.py +0 -730
- package/src/superlocalmemory/optimize/proxy/anthropic_surface.py +0 -375
- package/src/superlocalmemory/optimize/proxy/capture.py +0 -550
- package/src/superlocalmemory/optimize/proxy/gemini_surface.py +0 -528
- package/src/superlocalmemory/optimize/proxy/lifecycle.py +0 -126
- package/src/superlocalmemory/optimize/proxy/openai_surface.py +0 -465
- package/src/superlocalmemory/optimize/proxy/server.py +0 -199
- package/src/superlocalmemory/optimize/proxy/vertex_surface.py +0 -246
- package/src/superlocalmemory/optimize/storage/__init__.py +0 -0
- package/src/superlocalmemory/optimize/storage/db.py +0 -1185
- package/src/superlocalmemory/optimize/storage/schema.py +0 -205
- package/src/superlocalmemory/parameterization/__init__.py +0 -47
- package/src/superlocalmemory/parameterization/cross_project.py +0 -12
- package/src/superlocalmemory/parameterization/pattern_extractor.py +0 -584
- package/src/superlocalmemory/parameterization/pii_filter.py +0 -106
- package/src/superlocalmemory/parameterization/prompt_injector.py +0 -219
- package/src/superlocalmemory/parameterization/prompt_lifecycle.py +0 -281
- package/src/superlocalmemory/parameterization/soft_prompt_generator.py +0 -542
- package/src/superlocalmemory/parameterization/workflow_miner.py +0 -17
- package/src/superlocalmemory/reliability/__init__.py +0 -45
- package/src/superlocalmemory/reliability/join_liveness.py +0 -301
- package/src/superlocalmemory/reliability/prior_distance.py +0 -243
- package/src/superlocalmemory/retrieval/__init__.py +0 -0
- package/src/superlocalmemory/retrieval/agentic.py +0 -367
- package/src/superlocalmemory/retrieval/ann_index.py +0 -235
- package/src/superlocalmemory/retrieval/bm25_channel.py +0 -451
- package/src/superlocalmemory/retrieval/bridge_discovery.py +0 -253
- package/src/superlocalmemory/retrieval/channel_registry.py +0 -154
- package/src/superlocalmemory/retrieval/channel_status.py +0 -117
- package/src/superlocalmemory/retrieval/engine.py +0 -1615
- package/src/superlocalmemory/retrieval/entity_channel.py +0 -994
- package/src/superlocalmemory/retrieval/forgetting_filter.py +0 -160
- package/src/superlocalmemory/retrieval/fusion.py +0 -81
- package/src/superlocalmemory/retrieval/graph_adjacency.py +0 -219
- package/src/superlocalmemory/retrieval/hopfield_channel.py +0 -465
- package/src/superlocalmemory/retrieval/profile_channel.py +0 -105
- package/src/superlocalmemory/retrieval/quantization_aware_search.py +0 -147
- package/src/superlocalmemory/retrieval/remote_reranker.py +0 -758
- package/src/superlocalmemory/retrieval/reranker.py +0 -674
- package/src/superlocalmemory/retrieval/scope_policy.py +0 -126
- package/src/superlocalmemory/retrieval/semantic_channel.py +0 -638
- package/src/superlocalmemory/retrieval/spreading.py +0 -288
- package/src/superlocalmemory/retrieval/spreading_activation.py +0 -616
- package/src/superlocalmemory/retrieval/strategy.py +0 -248
- package/src/superlocalmemory/retrieval/temporal_channel.py +0 -433
- package/src/superlocalmemory/retrieval/temporal_frame.py +0 -102
- package/src/superlocalmemory/retrieval/temporal_utils.py +0 -122
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +0 -499
- package/src/superlocalmemory/retrieval/time_window.py +0 -181
- package/src/superlocalmemory/retrieval/vector_store.py +0 -863
- package/src/superlocalmemory/server/__init__.py +0 -1
- package/src/superlocalmemory/server/api.py +0 -310
- package/src/superlocalmemory/server/asset_versions.py +0 -171
- package/src/superlocalmemory/server/bandit_loops.py +0 -158
- package/src/superlocalmemory/server/config_file.py +0 -90
- package/src/superlocalmemory/server/consolidation_runner.py +0 -140
- package/src/superlocalmemory/server/egress_policy.py +0 -258
- package/src/superlocalmemory/server/loopback.py +0 -85
- package/src/superlocalmemory/server/middleware/__init__.py +0 -11
- package/src/superlocalmemory/server/middleware/security_headers.py +0 -144
- package/src/superlocalmemory/server/origin.py +0 -55
- package/src/superlocalmemory/server/profile_runtime.py +0 -515
- package/src/superlocalmemory/server/rbac_enforce.py +0 -194
- package/src/superlocalmemory/server/recall_health.py +0 -343
- package/src/superlocalmemory/server/recall_serializer.py +0 -320
- package/src/superlocalmemory/server/route_mutations.py +0 -104
- package/src/superlocalmemory/server/routes/__init__.py +0 -4
- package/src/superlocalmemory/server/routes/abstraction.py +0 -314
- package/src/superlocalmemory/server/routes/adapters.py +0 -63
- package/src/superlocalmemory/server/routes/agents.py +0 -303
- package/src/superlocalmemory/server/routes/backup.py +0 -869
- package/src/superlocalmemory/server/routes/behavioral.py +0 -659
- package/src/superlocalmemory/server/routes/brain.py +0 -1892
- package/src/superlocalmemory/server/routes/chat.py +0 -393
- package/src/superlocalmemory/server/routes/compliance.py +0 -533
- package/src/superlocalmemory/server/routes/config_api.py +0 -703
- package/src/superlocalmemory/server/routes/data_io.py +0 -329
- package/src/superlocalmemory/server/routes/entity.py +0 -237
- package/src/superlocalmemory/server/routes/events.py +0 -214
- package/src/superlocalmemory/server/routes/evolution.py +0 -510
- package/src/superlocalmemory/server/routes/helpers.py +0 -499
- package/src/superlocalmemory/server/routes/ingest.py +0 -137
- package/src/superlocalmemory/server/routes/insights.py +0 -366
- package/src/superlocalmemory/server/routes/learning.py +0 -834
- package/src/superlocalmemory/server/routes/learning_telemetry.py +0 -154
- package/src/superlocalmemory/server/routes/lifecycle.py +0 -184
- package/src/superlocalmemory/server/routes/memories.py +0 -1661
- package/src/superlocalmemory/server/routes/mesh.py +0 -517
- package/src/superlocalmemory/server/routes/mesh_lock.py +0 -54
- package/src/superlocalmemory/server/routes/mesh_state.py +0 -63
- package/src/superlocalmemory/server/routes/optimize.py +0 -197
- package/src/superlocalmemory/server/routes/prewarm.py +0 -173
- package/src/superlocalmemory/server/routes/profiles.py +0 -292
- package/src/superlocalmemory/server/routes/ratelimit.py +0 -132
- package/src/superlocalmemory/server/routes/rbac.py +0 -366
- package/src/superlocalmemory/server/routes/stats.py +0 -385
- package/src/superlocalmemory/server/routes/tiers.py +0 -222
- package/src/superlocalmemory/server/routes/timeline.py +0 -258
- package/src/superlocalmemory/server/routes/token.py +0 -90
- package/src/superlocalmemory/server/routes/v3_api.py +0 -3023
- package/src/superlocalmemory/server/routes/ws.py +0 -171
- package/src/superlocalmemory/server/security_middleware.py +0 -89
- package/src/superlocalmemory/server/ui.py +0 -354
- package/src/superlocalmemory/server/unified_daemon.py +0 -6326
- package/src/superlocalmemory/server/write_identity.py +0 -195
- package/src/superlocalmemory/storage/__init__.py +0 -0
- package/src/superlocalmemory/storage/_migration_internals.py +0 -638
- package/src/superlocalmemory/storage/_schema_version.py +0 -174
- package/src/superlocalmemory/storage/access_log.py +0 -170
- package/src/superlocalmemory/storage/admission_codec.py +0 -129
- package/src/superlocalmemory/storage/admission_journal.py +0 -843
- package/src/superlocalmemory/storage/agent_experience.py +0 -546
- package/src/superlocalmemory/storage/backup.py +0 -531
- package/src/superlocalmemory/storage/correction_cases.py +0 -670
- package/src/superlocalmemory/storage/database.py +0 -3180
- package/src/superlocalmemory/storage/deferred_writes.py +0 -209
- package/src/superlocalmemory/storage/embedding_codec.py +0 -200
- package/src/superlocalmemory/storage/embedding_migrator.py +0 -672
- package/src/superlocalmemory/storage/erasure_fence.py +0 -45
- package/src/superlocalmemory/storage/execution_learning.py +0 -285
- package/src/superlocalmemory/storage/external_evidence.py +0 -359
- package/src/superlocalmemory/storage/generation_fence.py +0 -63
- package/src/superlocalmemory/storage/lineage_retention.py +0 -236
- package/src/superlocalmemory/storage/logical_edges.py +0 -86
- package/src/superlocalmemory/storage/memory_write.py +0 -115
- package/src/superlocalmemory/storage/migration_runner.py +0 -895
- package/src/superlocalmemory/storage/migration_v33.py +0 -140
- package/src/superlocalmemory/storage/migrations/M001_add_signal_features_columns.py +0 -67
- package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +0 -107
- package/src/superlocalmemory/storage/migrations/M003_migration_log.py +0 -38
- package/src/superlocalmemory/storage/migrations/M004_cross_platform_sync_log.py +0 -46
- package/src/superlocalmemory/storage/migrations/M005_bandit_tables.py +0 -75
- package/src/superlocalmemory/storage/migrations/M006_action_outcomes_reward.py +0 -75
- package/src/superlocalmemory/storage/migrations/M007_pending_outcomes.py +0 -63
- package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +0 -94
- package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +0 -80
- package/src/superlocalmemory/storage/migrations/M011_archive_and_merge.py +0 -87
- package/src/superlocalmemory/storage/migrations/M012_shadow_observations.py +0 -72
- package/src/superlocalmemory/storage/migrations/M013_bi_temporal_columns.py +0 -55
- package/src/superlocalmemory/storage/migrations/M014_v345_scale_ready.py +0 -45
- package/src/superlocalmemory/storage/migrations/M015_add_pinned_column.py +0 -58
- package/src/superlocalmemory/storage/migrations/M016_add_scope_support.py +0 -120
- package/src/superlocalmemory/storage/migrations/M017_ccq_scope_column.py +0 -79
- package/src/superlocalmemory/storage/migrations/M018_ingestion_operations.py +0 -120
- package/src/superlocalmemory/storage/migrations/M019_derivation_lineage.py +0 -54
- package/src/superlocalmemory/storage/migrations/M020_model_state_integrity.py +0 -52
- package/src/superlocalmemory/storage/migrations/M021_ingestion_log_profile.py +0 -108
- package/src/superlocalmemory/storage/migrations/M022_entity_aliases_profile.py +0 -86
- package/src/superlocalmemory/storage/migrations/M023_mesh_profile_isolation.py +0 -194
- package/src/superlocalmemory/storage/migrations/M024_rbac_users_roles.py +0 -87
- package/src/superlocalmemory/storage/migrations/M025_perf_indexes.py +0 -90
- package/src/superlocalmemory/storage/migrations/M026_rbac_memberships_fk.py +0 -136
- package/src/superlocalmemory/storage/migrations/M027_transferable_patterns_profile.py +0 -163
- package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +0 -305
- package/src/superlocalmemory/storage/migrations/M029_behavioral_history_indexes.py +0 -137
- package/src/superlocalmemory/storage/migrations/M030_entity_explorer_indexes.py +0 -93
- package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +0 -80
- package/src/superlocalmemory/storage/migrations/M032_write_coordinator_admission.py +0 -188
- package/src/superlocalmemory/storage/migrations/M033_projection_transactions.py +0 -148
- package/src/superlocalmemory/storage/migrations/M034_obligation_integrity.py +0 -58
- package/src/superlocalmemory/storage/migrations/M035_erasure_receipts.py +0 -113
- package/src/superlocalmemory/storage/migrations/M036_vector_row_map.py +0 -107
- package/src/superlocalmemory/storage/migrations/M037_manifest_hmac_version.py +0 -162
- package/src/superlocalmemory/storage/migrations/M038_learning_feedback_channel.py +0 -77
- package/src/superlocalmemory/storage/migrations/M039_scene_fact_members.py +0 -137
- package/src/superlocalmemory/storage/migrations/M040_agent_experience_receipts.py +0 -254
- package/src/superlocalmemory/storage/migrations/M041_external_evidence_receipts.py +0 -189
- package/src/superlocalmemory/storage/migrations/M042_correction_case_ledger.py +0 -245
- package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +0 -512
- package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +0 -127
- package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +0 -158
- package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +0 -620
- package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +0 -306
- package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +0 -229
- package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +0 -201
- package/src/superlocalmemory/storage/migrations/M050_execution_learning_v2.py +0 -70
- package/src/superlocalmemory/storage/migrations/__init__.py +0 -103
- package/src/superlocalmemory/storage/migrations.py +0 -333
- package/src/superlocalmemory/storage/models.py +0 -500
- package/src/superlocalmemory/storage/projection_outbox.py +0 -346
- package/src/superlocalmemory/storage/quantized_store.py +0 -280
- package/src/superlocalmemory/storage/read_connection.py +0 -115
- package/src/superlocalmemory/storage/retention_policy.py +0 -860
- package/src/superlocalmemory/storage/schema.py +0 -1108
- package/src/superlocalmemory/storage/schema_code_graph.py +0 -282
- package/src/superlocalmemory/storage/schema_v32.py +0 -382
- package/src/superlocalmemory/storage/schema_v3410.py +0 -159
- package/src/superlocalmemory/storage/schema_v3411.py +0 -149
- package/src/superlocalmemory/storage/schema_v343.py +0 -315
- package/src/superlocalmemory/storage/schema_v345.py +0 -109
- package/src/superlocalmemory/storage/schema_v347.py +0 -140
- package/src/superlocalmemory/storage/sqlite_vectors.py +0 -169
- package/src/superlocalmemory/storage/v2_migrator.py +0 -466
- package/src/superlocalmemory/storage/write_coordinator.py +0 -949
- package/src/superlocalmemory/storage/write_lock.py +0 -88
- package/src/superlocalmemory/summaries/__init__.py +0 -37
- package/src/superlocalmemory/summaries/base.py +0 -267
- package/src/superlocalmemory/summaries/daily_reflection.py +0 -340
- package/src/superlocalmemory/summaries/non_answer.py +0 -223
- package/src/superlocalmemory/summaries/project_work_log.py +0 -440
- package/src/superlocalmemory/summaries/session_summary.py +0 -311
- package/src/superlocalmemory/trust/__init__.py +0 -0
- package/src/superlocalmemory/trust/gate.py +0 -171
- package/src/superlocalmemory/trust/provenance.py +0 -124
- package/src/superlocalmemory/trust/scorer.py +0 -413
- package/src/superlocalmemory/trust/signals.py +0 -153
- package/src/superlocalmemory/ui/assets/slm-icon-white.svg +0 -64
- package/src/superlocalmemory/ui/assets/slm-icon.svg +0 -36
- package/src/superlocalmemory/ui/css/brain.css +0 -409
- package/src/superlocalmemory/ui/css/design-system.css +0 -696
- package/src/superlocalmemory/ui/css/legacy-dashboard.css +0 -663
- package/src/superlocalmemory/ui/css/neural-glass.css +0 -1599
- package/src/superlocalmemory/ui/css/od-bridge.css +0 -158
- package/src/superlocalmemory/ui/favicon.svg +0 -36
- package/src/superlocalmemory/ui/index.html +0 -1638
- package/src/superlocalmemory/ui/js/agents.js +0 -192
- package/src/superlocalmemory/ui/js/auto-settings.js +0 -624
- package/src/superlocalmemory/ui/js/brain.js +0 -1400
- package/src/superlocalmemory/ui/js/clusters.js +0 -326
- package/src/superlocalmemory/ui/js/compliance.js +0 -307
- package/src/superlocalmemory/ui/js/core.js +0 -566
- package/src/superlocalmemory/ui/js/dashboard.js +0 -503
- package/src/superlocalmemory/ui/js/event-delegation.js +0 -113
- package/src/superlocalmemory/ui/js/events.js +0 -178
- package/src/superlocalmemory/ui/js/fact-detail.js +0 -142
- package/src/superlocalmemory/ui/js/feedback.js +0 -339
- package/src/superlocalmemory/ui/js/graph-event-bus.js +0 -83
- package/src/superlocalmemory/ui/js/graph-filters.js +0 -220
- package/src/superlocalmemory/ui/js/graph-ui.js +0 -214
- package/src/superlocalmemory/ui/js/ide-status.js +0 -115
- package/src/superlocalmemory/ui/js/init.js +0 -54
- package/src/superlocalmemory/ui/js/knowledge-graph.js +0 -945
- package/src/superlocalmemory/ui/js/lifecycle.js +0 -387
- package/src/superlocalmemory/ui/js/math-health.js +0 -114
- package/src/superlocalmemory/ui/js/memories.js +0 -394
- package/src/superlocalmemory/ui/js/memory-chat.js +0 -371
- package/src/superlocalmemory/ui/js/memory-timeline.js +0 -265
- package/src/superlocalmemory/ui/js/modal.js +0 -733
- package/src/superlocalmemory/ui/js/ng-entities.js +0 -298
- package/src/superlocalmemory/ui/js/ng-health.js +0 -208
- package/src/superlocalmemory/ui/js/ng-ingestion.js +0 -203
- package/src/superlocalmemory/ui/js/ng-mesh.js +0 -374
- package/src/superlocalmemory/ui/js/ng-shell.js +0 -524
- package/src/superlocalmemory/ui/js/ng-skills.js +0 -663
- package/src/superlocalmemory/ui/js/od-agents.js +0 -588
- package/src/superlocalmemory/ui/js/od-auth-gate.js +0 -257
- package/src/superlocalmemory/ui/js/od-backup.js +0 -878
- package/src/superlocalmemory/ui/js/od-boundedloops.js +0 -324
- package/src/superlocalmemory/ui/js/od-brain.js +0 -1095
- package/src/superlocalmemory/ui/js/od-compliance-ext.js +0 -301
- package/src/superlocalmemory/ui/js/od-components.js +0 -147
- package/src/superlocalmemory/ui/js/od-entities.js +0 -622
- package/src/superlocalmemory/ui/js/od-graph.js +0 -776
- package/src/superlocalmemory/ui/js/od-health.js +0 -579
- package/src/superlocalmemory/ui/js/od-mcp.js +0 -508
- package/src/superlocalmemory/ui/js/od-memories.js +0 -1499
- package/src/superlocalmemory/ui/js/od-mesh.js +0 -645
- package/src/superlocalmemory/ui/js/od-operations.js +0 -1268
- package/src/superlocalmemory/ui/js/od-ops-health.js +0 -417
- package/src/superlocalmemory/ui/js/od-optimize.js +0 -828
- package/src/superlocalmemory/ui/js/od-settings.js +0 -1275
- package/src/superlocalmemory/ui/js/od-shell.js +0 -819
- package/src/superlocalmemory/ui/js/od-skills.js +0 -600
- package/src/superlocalmemory/ui/js/od-team.js +0 -265
- package/src/superlocalmemory/ui/js/optimize.js +0 -191
- package/src/superlocalmemory/ui/js/profiles.js +0 -362
- package/src/superlocalmemory/ui/js/quick-actions.js +0 -334
- package/src/superlocalmemory/ui/js/recall-lab.js +0 -373
- package/src/superlocalmemory/ui/js/search.js +0 -86
- package/src/superlocalmemory/ui/js/settings.js +0 -556
- package/src/superlocalmemory/ui/js/timeline.js +0 -62
- package/src/superlocalmemory/ui/js/trust-dashboard.js +0 -225
- package/src/superlocalmemory/ui/vendor/bootstrap-icons/bootstrap-icons.css +0 -2018
- package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
- package/src/superlocalmemory/ui/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
- package/src/superlocalmemory/ui/vendor/bootstrap.bundle.min.js +0 -7
- package/src/superlocalmemory/ui/vendor/bootstrap.min.css +0 -6
- package/src/superlocalmemory/ui/vendor/d3.v7.min.js +0 -2
- package/src/superlocalmemory/ui/vendor/graphology-library.min.js +0 -2
- package/src/superlocalmemory/ui/vendor/graphology.umd.min.js +0 -2
- package/src/superlocalmemory/ui/vendor/inter-ui/inter-variable.min.css +0 -8
- package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable-Italic.woff2 +0 -0
- package/src/superlocalmemory/ui/vendor/inter-ui/variable/InterVariable.woff2 +0 -0
- package/src/superlocalmemory/ui/vendor/sigma.min.js +0 -1
- package/src/superlocalmemory/vector/__init__.py +0 -9
- package/src/superlocalmemory/vector/lancedb_backend.py +0 -366
|
@@ -1,3023 +0,0 @@
|
|
|
1
|
-
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
-
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
-
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
-
|
|
5
|
-
"""V3 API endpoints for the SuperLocalMemory dashboard."""
|
|
6
|
-
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
import json
|
|
10
|
-
import logging
|
|
11
|
-
from pathlib import Path
|
|
12
|
-
import os
|
|
13
|
-
from fastapi import APIRouter, HTTPException, Request
|
|
14
|
-
from fastapi.responses import JSONResponse
|
|
15
|
-
from superlocalmemory.core.session_identity import synthetic_session_id
|
|
16
|
-
from superlocalmemory.core.config import CANONICAL_RECALL_LIMIT
|
|
17
|
-
from superlocalmemory.core.status_contract import (
|
|
18
|
-
COUNT_QUERIES,
|
|
19
|
-
counts_from_sqlite,
|
|
20
|
-
projection_queue_depth,
|
|
21
|
-
store_size_mb,
|
|
22
|
-
)
|
|
23
|
-
from superlocalmemory.server.routes.helpers import SLM_VERSION, get_read_connection
|
|
24
|
-
from superlocalmemory.server.route_mutations import authorize_route_mutation
|
|
25
|
-
|
|
26
|
-
logger = logging.getLogger(__name__)
|
|
27
|
-
|
|
28
|
-
def _signal_session_id() -> str:
|
|
29
|
-
"""A name for the caller, for matching an outcome back to this recall.
|
|
30
|
-
|
|
31
|
-
The agent id the request arrived under, when it arrived under one. Falls
|
|
32
|
-
back to the workspace, which keeps dashboard and scripted traffic separable
|
|
33
|
-
from an agent's. Never empty: an unnamed recall leaves no record.
|
|
34
|
-
"""
|
|
35
|
-
try:
|
|
36
|
-
from superlocalmemory.mcp.agent_context import get_current_agent_id
|
|
37
|
-
|
|
38
|
-
agent = str(get_current_agent_id() or "").strip()
|
|
39
|
-
if agent:
|
|
40
|
-
return synthetic_session_id("agent", agent)
|
|
41
|
-
except Exception: # noqa: BLE001 -- naming the caller must never fail a read
|
|
42
|
-
pass
|
|
43
|
-
try:
|
|
44
|
-
from superlocalmemory.server.routes.helpers import get_active_profile
|
|
45
|
-
|
|
46
|
-
return synthetic_session_id("api", str(get_active_profile()))
|
|
47
|
-
except Exception: # noqa: BLE001
|
|
48
|
-
return synthetic_session_id("api", "default")
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
router = APIRouter(prefix="/api/v3", tags=["v3"])
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def _internal_error() -> JSONResponse:
|
|
55
|
-
"""SEC-H-02: log the full traceback server-side, return a generic message.
|
|
56
|
-
|
|
57
|
-
Returning ``str(e)`` to the client leaked DB schema (column/constraint
|
|
58
|
-
names), the data-directory filesystem path, and — for config routes — could
|
|
59
|
-
surface LLM config internals. Call this only from inside an ``except`` block
|
|
60
|
-
so ``logger.exception`` captures the active traceback.
|
|
61
|
-
"""
|
|
62
|
-
logger.exception("v3_api request failed")
|
|
63
|
-
return JSONResponse({"error": "Internal server error"}, status_code=500)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
async def _apply_runtime_config(request: Request, config, *, mode_change: bool) -> None:
|
|
67
|
-
"""Persist and hot-swap config only after the daemon transition succeeds."""
|
|
68
|
-
import asyncio
|
|
69
|
-
|
|
70
|
-
authorization = authorize_route_mutation(
|
|
71
|
-
request,
|
|
72
|
-
operation="update",
|
|
73
|
-
source_agent_id="dashboard-config",
|
|
74
|
-
profile_id=getattr(config, "active_profile", "default"),
|
|
75
|
-
)
|
|
76
|
-
from superlocalmemory.server.profile_runtime import reconfigure_daemon_engine
|
|
77
|
-
|
|
78
|
-
await asyncio.to_thread(
|
|
79
|
-
reconfigure_daemon_engine,
|
|
80
|
-
request.app.state,
|
|
81
|
-
config,
|
|
82
|
-
mode_change=mode_change,
|
|
83
|
-
)
|
|
84
|
-
authorization.complete()
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def _require_manage(request: Request) -> None:
|
|
88
|
-
"""Use one explicit authorization boundary for dashboard configuration."""
|
|
89
|
-
from superlocalmemory.server.rbac_enforce import require_manage
|
|
90
|
-
|
|
91
|
-
require_manage(request)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def _resolve_profile(request: Request, requested: str = "") -> str:
|
|
95
|
-
"""Resolve a profile override and enforce READ on its actual target."""
|
|
96
|
-
from superlocalmemory.access.rbac import Permission
|
|
97
|
-
from superlocalmemory.server.rbac_enforce import require_permission
|
|
98
|
-
from superlocalmemory.server.routes.helpers import get_active_profile
|
|
99
|
-
|
|
100
|
-
if requested and not isinstance(requested, str):
|
|
101
|
-
raise HTTPException(status_code=422, detail="profile must be a string")
|
|
102
|
-
profile = requested.strip() if requested else get_active_profile()
|
|
103
|
-
require_permission(request, Permission.READ, profile=profile)
|
|
104
|
-
return profile
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
def _resolve_mutation_profile(requested: object) -> str:
|
|
108
|
-
"""Resolve a body-supplied profile without silently coercing bad input."""
|
|
109
|
-
from superlocalmemory.server.routes.helpers import get_active_profile
|
|
110
|
-
|
|
111
|
-
if requested is not None and not isinstance(requested, str):
|
|
112
|
-
raise HTTPException(status_code=422, detail="profile must be a string")
|
|
113
|
-
return requested.strip() if requested and requested.strip() else get_active_profile()
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
def _require_manage_for_profile(request: Request, profile: str) -> None:
|
|
117
|
-
"""Enforce MANAGE on the resolved mutation target before any side effect."""
|
|
118
|
-
from superlocalmemory.server.rbac_enforce import require_manage
|
|
119
|
-
|
|
120
|
-
require_manage(request, profile=profile)
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
# ── Dashboard ────────────────────────────────────────────────
|
|
124
|
-
|
|
125
|
-
@router.get("/dashboard")
|
|
126
|
-
async def dashboard(request: Request):
|
|
127
|
-
"""Dashboard summary: mode, memory count, health score, recent activity."""
|
|
128
|
-
try:
|
|
129
|
-
from superlocalmemory.core.config import SLMConfig
|
|
130
|
-
config = getattr(request.app.state, "config", None) or SLMConfig.load()
|
|
131
|
-
from superlocalmemory.server.profile_runtime import get_profile_runtime
|
|
132
|
-
|
|
133
|
-
active_profile = get_profile_runtime(request.app.state).snapshot.profile_id
|
|
134
|
-
|
|
135
|
-
# Read stats directly from SQLite (dashboard doesn't load engine)
|
|
136
|
-
memory_count = 0
|
|
137
|
-
counts = dict.fromkeys(COUNT_QUERIES, 0)
|
|
138
|
-
queue_depth = 0
|
|
139
|
-
db_path = config.base_dir / "memory.db"
|
|
140
|
-
if db_path.exists():
|
|
141
|
-
try:
|
|
142
|
-
conn = get_read_connection(db_path)
|
|
143
|
-
counts = counts_from_sqlite(conn, active_profile)
|
|
144
|
-
queue_depth = projection_queue_depth(conn)
|
|
145
|
-
cursor = conn.cursor()
|
|
146
|
-
try:
|
|
147
|
-
try:
|
|
148
|
-
cursor.execute(
|
|
149
|
-
"SELECT COUNT(*) FROM memories WHERE profile_id = ?",
|
|
150
|
-
(active_profile,),
|
|
151
|
-
)
|
|
152
|
-
except Exception:
|
|
153
|
-
cursor.execute(
|
|
154
|
-
"SELECT COUNT(*) FROM memories WHERE profile = ?",
|
|
155
|
-
(active_profile,),
|
|
156
|
-
)
|
|
157
|
-
memory_count = cursor.fetchone()[0]
|
|
158
|
-
except Exception:
|
|
159
|
-
pass
|
|
160
|
-
conn.close()
|
|
161
|
-
except Exception:
|
|
162
|
-
pass
|
|
163
|
-
|
|
164
|
-
from superlocalmemory.core.modes import dashboard_mode_fields
|
|
165
|
-
|
|
166
|
-
# Mode record is the single source of truth for locality claims (F-03).
|
|
167
|
-
payload = {
|
|
168
|
-
"mode": config.mode.value,
|
|
169
|
-
"mode_name": {"a": "Local Guardian", "b": "Smart Local", "c": "Full Power"}.get(config.mode.value, "Unknown"),
|
|
170
|
-
"provider": config.llm.provider or "none",
|
|
171
|
-
"model": config.llm.model or "",
|
|
172
|
-
"memory_count": memory_count,
|
|
173
|
-
"profile": active_profile,
|
|
174
|
-
"base_dir": str(config.base_dir),
|
|
175
|
-
"version": SLM_VERSION,
|
|
176
|
-
# The counts and the store's own address were missing here while
|
|
177
|
-
# every other status surface carried them, so the one surface a
|
|
178
|
-
# person actually looks at could not answer "is the graph healthy".
|
|
179
|
-
"db_path": str(db_path),
|
|
180
|
-
"db_size_mb": store_size_mb(db_path),
|
|
181
|
-
"profile_generation": get_profile_runtime(
|
|
182
|
-
request.app.state,
|
|
183
|
-
).snapshot.generation,
|
|
184
|
-
# Facts stored but not yet in the graph and vector projections. A
|
|
185
|
-
# number that does not fall is a projection that has stopped
|
|
186
|
-
# keeping up, which is otherwise invisible: nothing errors, the
|
|
187
|
-
# memory is safely in SQLite, and recall just quietly gets worse.
|
|
188
|
-
"projection_queue_depth": queue_depth,
|
|
189
|
-
**counts,
|
|
190
|
-
}
|
|
191
|
-
payload.update(dashboard_mode_fields(config.mode))
|
|
192
|
-
return payload
|
|
193
|
-
except Exception as e:
|
|
194
|
-
return _internal_error()
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
# ── Mode ─────────────────────────────────────────────────────
|
|
198
|
-
|
|
199
|
-
@router.get("/mode")
|
|
200
|
-
async def get_mode(request: Request):
|
|
201
|
-
"""Get current mode, provider, model — single source of truth for UI."""
|
|
202
|
-
try:
|
|
203
|
-
from superlocalmemory.core.config import SLMConfig
|
|
204
|
-
from urllib.parse import urlparse
|
|
205
|
-
config = getattr(request.app.state, "config", None) or SLMConfig.load()
|
|
206
|
-
current = config.mode.value
|
|
207
|
-
# SEC-L-01: expose only the endpoint HOST, not the full URL. A full
|
|
208
|
-
# api_base (e.g. an internal Azure OpenAI deployment path) leaks
|
|
209
|
-
# infrastructure topology to non-admin (viewer) users.
|
|
210
|
-
_api_base = config.llm.api_base or ""
|
|
211
|
-
_endpoint_host = urlparse(_api_base).netloc if _api_base else ""
|
|
212
|
-
return {
|
|
213
|
-
"mode": current,
|
|
214
|
-
"provider": config.llm.provider or "none",
|
|
215
|
-
"model": config.llm.model or "",
|
|
216
|
-
"has_key": bool(config.llm.api_key),
|
|
217
|
-
"endpoint": _endpoint_host,
|
|
218
|
-
"capabilities": {
|
|
219
|
-
"llm_available": bool(config.llm.provider),
|
|
220
|
-
"cross_encoder": config.retrieval.use_cross_encoder if hasattr(config, 'retrieval') else False,
|
|
221
|
-
},
|
|
222
|
-
}
|
|
223
|
-
except Exception as e:
|
|
224
|
-
return _internal_error()
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
@router.put("/mode")
|
|
228
|
-
async def set_mode(request: Request):
|
|
229
|
-
"""Switch operating mode. Body: {"mode": "a"|"b"|"c"}"""
|
|
230
|
-
# SEC-H-01: mode switch reconfigures the whole engine — admin-only (MANAGE)
|
|
231
|
-
# on top of machine auth. Owner keeps MANAGE; personal mode unaffected.
|
|
232
|
-
from superlocalmemory.server.rbac_enforce import require_manage
|
|
233
|
-
require_manage(request)
|
|
234
|
-
try:
|
|
235
|
-
body = await request.json()
|
|
236
|
-
new_mode = body.get("mode", "").lower()
|
|
237
|
-
if new_mode not in ("a", "b", "c"):
|
|
238
|
-
return JSONResponse({"error": "Invalid mode. Use a, b, or c."}, status_code=400)
|
|
239
|
-
|
|
240
|
-
from superlocalmemory.core.config import SLMConfig
|
|
241
|
-
from superlocalmemory.storage.models import Mode
|
|
242
|
-
from superlocalmemory.server.routes.helpers import log_mode_change
|
|
243
|
-
old_config = SLMConfig.load()
|
|
244
|
-
old_mode = old_config.mode.value
|
|
245
|
-
|
|
246
|
-
# Safety: a bare ``{mode:"c"}`` body (e.g., a stray dashboard button
|
|
247
|
-
# click) used to silently auto-default the model to
|
|
248
|
-
# ``anthropic/claude-sonnet-4`` with no API key, writing phantom state
|
|
249
|
-
# into config.json. Refuse that path — Mode C requires explicit
|
|
250
|
-
# provider+key via POST /api/v3/mode/set.
|
|
251
|
-
if new_mode == "c" and not old_config.llm.api_key:
|
|
252
|
-
return JSONResponse(
|
|
253
|
-
{
|
|
254
|
-
"error": (
|
|
255
|
-
"Mode C requires a cloud API key. "
|
|
256
|
-
"Configure provider + key in Settings → Step 2 "
|
|
257
|
-
"(uses POST /api/v3/mode/set)."
|
|
258
|
-
),
|
|
259
|
-
"code": "mode_c_requires_api_key",
|
|
260
|
-
},
|
|
261
|
-
status_code=400,
|
|
262
|
-
)
|
|
263
|
-
|
|
264
|
-
# Apply new mode's structural presets (retrieval, math, channel_weights)
|
|
265
|
-
# by building a fresh template, then graft them onto the loaded config so
|
|
266
|
-
# all user-tuned blocks (forgetting, injection, consolidation, scope, …)
|
|
267
|
-
# are preserved across the mode switch.
|
|
268
|
-
_template = SLMConfig.for_mode(Mode(new_mode))
|
|
269
|
-
old_config.mode = Mode(new_mode)
|
|
270
|
-
old_config.retrieval = _template.retrieval
|
|
271
|
-
old_config.math = _template.math
|
|
272
|
-
old_config.channel_weights = _template.channel_weights
|
|
273
|
-
new_config = old_config
|
|
274
|
-
|
|
275
|
-
await _apply_runtime_config(request, new_config, mode_change=True)
|
|
276
|
-
|
|
277
|
-
# Audit the change before we lose context — proves who/when/what.
|
|
278
|
-
# Captures the phantom-write case where `for_mode(C)` auto-defaults
|
|
279
|
-
# the model to "anthropic/claude-sonnet-4" (see core/config.py).
|
|
280
|
-
log_mode_change(
|
|
281
|
-
old_mode, new_mode,
|
|
282
|
-
provider=new_config.llm.provider,
|
|
283
|
-
model=new_config.llm.model,
|
|
284
|
-
source="PUT /api/v3/mode",
|
|
285
|
-
)
|
|
286
|
-
|
|
287
|
-
# V3.3: Check if embedding model changed — flag for re-indexing
|
|
288
|
-
needs_reindex = (
|
|
289
|
-
old_config.embedding.provider != new_config.embedding.provider
|
|
290
|
-
or old_config.embedding.model_name != new_config.embedding.model_name
|
|
291
|
-
)
|
|
292
|
-
|
|
293
|
-
return {
|
|
294
|
-
"success": True,
|
|
295
|
-
"mode": new_mode,
|
|
296
|
-
"needs_reindex": needs_reindex,
|
|
297
|
-
"message": "Embedding re-indexing will run on next recall." if needs_reindex else "",
|
|
298
|
-
}
|
|
299
|
-
except Exception as e:
|
|
300
|
-
return _internal_error()
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
def apply_settings_update(config: "SLMConfig", payload: dict) -> "SLMConfig":
|
|
304
|
-
"""Credential-safe LLM config update for the settings dashboard (fixes #119).
|
|
305
|
-
|
|
306
|
-
This is the SINGLE authoritative place where incoming dashboard save payloads
|
|
307
|
-
are merged onto the stored config. The POST /api/v3/mode/set handler calls
|
|
308
|
-
this function after its HTTP-layer concerns (SSRF guard, auth) are settled,
|
|
309
|
-
so the test suite (test_acceptance_core.py::P3) tests real product
|
|
310
|
-
behaviour — not a reimplementation.
|
|
311
|
-
|
|
312
|
-
SEC-L-01 PRESERVED:
|
|
313
|
-
The GET /mode route returns only ``urlparse(api_base).netloc`` (the host),
|
|
314
|
-
never the full URL. When the UI echoes that value back on save,
|
|
315
|
-
apply_settings_update detects the scheme-less netloc and silently restores
|
|
316
|
-
the stored full URL (scheme + path). This means the server-side logic
|
|
317
|
-
internally reads the stored URL but NEVER returns it to callers — viewer
|
|
318
|
-
users remain unable to discover the full endpoint topology.
|
|
319
|
-
|
|
320
|
-
Credential semantics:
|
|
321
|
-
api_key blank/omitted → UNCHANGED (browsers never repopulate pw fields)
|
|
322
|
-
api_key non-blank → applied
|
|
323
|
-
clear_api_key = True → explicit "" (deliberate wipe — only way to clear)
|
|
324
|
-
|
|
325
|
-
endpoint absent/empty → UNCHANGED
|
|
326
|
-
endpoint == stored netloc (no scheme "://") → UNCHANGED (redacted echo)
|
|
327
|
-
endpoint with "://" → applied
|
|
328
|
-
clear_base_url = True → explicit "" (deliberate wipe)
|
|
329
|
-
|
|
330
|
-
Security rule: if the destination provider or endpoint genuinely changes AND
|
|
331
|
-
no new key was supplied, the stored key is cleared to prevent accidental
|
|
332
|
-
credential reuse across providers. An explicit clear_api_key=True is
|
|
333
|
-
always honoured regardless.
|
|
334
|
-
|
|
335
|
-
NEVER logs api_key.
|
|
336
|
-
"""
|
|
337
|
-
import dataclasses as _dc
|
|
338
|
-
from urllib.parse import urlparse, urlsplit, urlunsplit
|
|
339
|
-
|
|
340
|
-
stored = config.llm
|
|
341
|
-
stored_key: str = stored.api_key
|
|
342
|
-
stored_base: str = stored.api_base or ""
|
|
343
|
-
|
|
344
|
-
# ── endpoint ─────────────────────────────────────────────────────────────
|
|
345
|
-
raw_endpoint: str = (payload.get("endpoint") or payload.get("base_url") or "").strip()
|
|
346
|
-
clear_endpoint: bool = payload.get("clear_base_url") is True
|
|
347
|
-
|
|
348
|
-
if clear_endpoint:
|
|
349
|
-
new_base = ""
|
|
350
|
-
endpoint_changed = bool(stored_base)
|
|
351
|
-
elif not raw_endpoint:
|
|
352
|
-
# Nothing supplied — keep stored URL intact.
|
|
353
|
-
new_base = stored_base
|
|
354
|
-
endpoint_changed = False
|
|
355
|
-
else:
|
|
356
|
-
# Detect the SEC-L-01 redacted echo: GET /mode returns
|
|
357
|
-
# urlparse(api_base).netloc (host only, no scheme, no path). If the
|
|
358
|
-
# client sent that exact string back, it did NOT change the endpoint —
|
|
359
|
-
# restore the full stored URL. We compare the raw string directly to
|
|
360
|
-
# stored_netloc so that hosts with ports ("api.host.com:443") also match.
|
|
361
|
-
stored_netloc: str = urlparse(stored_base).netloc if stored_base else ""
|
|
362
|
-
is_redacted_echo: bool = bool(stored_netloc) and (raw_endpoint == stored_netloc)
|
|
363
|
-
|
|
364
|
-
if is_redacted_echo:
|
|
365
|
-
new_base = stored_base
|
|
366
|
-
endpoint_changed = False
|
|
367
|
-
else:
|
|
368
|
-
new_base = raw_endpoint
|
|
369
|
-
# Canonical comparison (strip trailing slash, normalise scheme/host
|
|
370
|
-
# case) so a harmless trailing-slash difference does not clear the key.
|
|
371
|
-
def _canonical(u: str) -> str:
|
|
372
|
-
p = urlsplit(u)
|
|
373
|
-
return urlunsplit((
|
|
374
|
-
p.scheme.lower(), p.netloc.lower(),
|
|
375
|
-
p.path.rstrip("/"), p.query, "",
|
|
376
|
-
))
|
|
377
|
-
endpoint_changed = _canonical(new_base) != _canonical(stored_base)
|
|
378
|
-
|
|
379
|
-
# ── provider ─────────────────────────────────────────────────────────────
|
|
380
|
-
raw_provider: str = (payload.get("provider") or "").strip()
|
|
381
|
-
if raw_provider == "none":
|
|
382
|
-
new_provider = "" # "none" sentinel means "clear provider"
|
|
383
|
-
elif raw_provider:
|
|
384
|
-
new_provider = raw_provider
|
|
385
|
-
else:
|
|
386
|
-
new_provider = stored.provider or ""
|
|
387
|
-
|
|
388
|
-
provider_changed: bool = new_provider != (stored.provider or "")
|
|
389
|
-
|
|
390
|
-
# ── api_key ───────────────────────────────────────────────────────────────
|
|
391
|
-
# Evaluated AFTER endpoint/provider so we know whether the destination changed.
|
|
392
|
-
raw_key: str = (payload.get("api_key") or "").strip()
|
|
393
|
-
clear_key: bool = payload.get("clear_api_key") is True
|
|
394
|
-
destination_changed: bool = provider_changed or endpoint_changed
|
|
395
|
-
|
|
396
|
-
if clear_key:
|
|
397
|
-
new_key = "" # explicit user wipe
|
|
398
|
-
elif raw_key:
|
|
399
|
-
new_key = raw_key # user supplied a replacement key
|
|
400
|
-
elif destination_changed:
|
|
401
|
-
# Security: destination changed but no new key → clear to prevent
|
|
402
|
-
# the stored credential from being silently redirected to a new
|
|
403
|
-
# provider or endpoint the user may not own.
|
|
404
|
-
new_key = ""
|
|
405
|
-
else:
|
|
406
|
-
# Blank key + same destination = browser did not repopulate the
|
|
407
|
-
# password field. Preserve the stored key verbatim.
|
|
408
|
-
new_key = stored_key
|
|
409
|
-
|
|
410
|
-
# ── model ────────────────────────────────────────────────────────────────
|
|
411
|
-
raw_model: str = (payload.get("model") or "").strip()
|
|
412
|
-
new_model: str = raw_model if raw_model else (stored.model or "")
|
|
413
|
-
|
|
414
|
-
# ── apply ─────────────────────────────────────────────────────────────────
|
|
415
|
-
# dataclasses.replace preserves temperature / max_tokens / timeout_seconds.
|
|
416
|
-
# Assigning to config.llm works because SLMConfig is not frozen.
|
|
417
|
-
# NEVER include new_key in any log call.
|
|
418
|
-
config.llm = _dc.replace(
|
|
419
|
-
stored,
|
|
420
|
-
provider=new_provider,
|
|
421
|
-
model=new_model,
|
|
422
|
-
api_key=new_key,
|
|
423
|
-
api_base=new_base,
|
|
424
|
-
)
|
|
425
|
-
return config
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
@router.post("/mode/set")
|
|
429
|
-
async def set_full_config(request: Request):
|
|
430
|
-
"""Save mode + provider + model + API key together.
|
|
431
|
-
|
|
432
|
-
V3.4.24: Also accepts embedding_* fields for custom embedding endpoints.
|
|
433
|
-
"""
|
|
434
|
-
# SEC-H-01: sets the LLM provider + API key for the whole engine — admin-only.
|
|
435
|
-
from superlocalmemory.server.rbac_enforce import require_manage
|
|
436
|
-
require_manage(request)
|
|
437
|
-
try:
|
|
438
|
-
body = await request.json()
|
|
439
|
-
if not isinstance(body, dict):
|
|
440
|
-
return JSONResponse({"error": "Request body must be a JSON object"}, status_code=400)
|
|
441
|
-
from superlocalmemory.core.config import SLMConfig, EmbeddingConfig
|
|
442
|
-
from superlocalmemory.storage.models import Mode
|
|
443
|
-
from superlocalmemory.server.routes.helpers import log_mode_change
|
|
444
|
-
|
|
445
|
-
config = SLMConfig.load()
|
|
446
|
-
old_mode = config.mode.value
|
|
447
|
-
|
|
448
|
-
def _nonblank(name: str) -> str | None:
|
|
449
|
-
value = body.get(name)
|
|
450
|
-
if value is None:
|
|
451
|
-
return None
|
|
452
|
-
if not isinstance(value, str):
|
|
453
|
-
raise ValueError(f"{name} must be a string")
|
|
454
|
-
return value.strip() or None
|
|
455
|
-
|
|
456
|
-
new_mode = (_nonblank("mode") or config.mode.value).lower()
|
|
457
|
-
provider_input = _nonblank("provider")
|
|
458
|
-
model_input = _nonblank("model")
|
|
459
|
-
api_key_input = _nonblank("api_key")
|
|
460
|
-
base_url_input = _nonblank("base_url")
|
|
461
|
-
endpoint_input = _nonblank("endpoint")
|
|
462
|
-
clear_api_key = body.get("clear_api_key") is True
|
|
463
|
-
clear_base_url = body.get("clear_base_url") is True
|
|
464
|
-
|
|
465
|
-
if clear_api_key and api_key_input:
|
|
466
|
-
return JSONResponse(
|
|
467
|
-
{"error": "api_key cannot be replaced and cleared together"},
|
|
468
|
-
status_code=400,
|
|
469
|
-
)
|
|
470
|
-
if clear_base_url and (base_url_input or endpoint_input):
|
|
471
|
-
return JSONResponse(
|
|
472
|
-
{"error": "base_url cannot be replaced and cleared together"},
|
|
473
|
-
status_code=400,
|
|
474
|
-
)
|
|
475
|
-
if base_url_input and endpoint_input and base_url_input != endpoint_input:
|
|
476
|
-
return JSONResponse(
|
|
477
|
-
{"error": "base_url and endpoint must match when both are supplied"},
|
|
478
|
-
status_code=400,
|
|
479
|
-
)
|
|
480
|
-
|
|
481
|
-
if new_mode not in ("a", "b", "c"):
|
|
482
|
-
return JSONResponse({"error": "Invalid mode"}, status_code=400)
|
|
483
|
-
|
|
484
|
-
# Resolve the effective endpoint value before SSRF validation.
|
|
485
|
-
# Only the Ollama default injection happens here; the fallback to the
|
|
486
|
-
# stored URL (and redacted-echo detection) live inside
|
|
487
|
-
# apply_settings_update so that the P3 test exercises
|
|
488
|
-
# the same code path as the HTTP handler — not a reimplementation.
|
|
489
|
-
_raw_ep: str = "" if clear_base_url else (base_url_input or endpoint_input or "")
|
|
490
|
-
if (
|
|
491
|
-
not _raw_ep
|
|
492
|
-
and provider_input == "ollama"
|
|
493
|
-
and config.llm.provider != "ollama"
|
|
494
|
-
and not clear_base_url
|
|
495
|
-
):
|
|
496
|
-
_raw_ep = "http://localhost:11434"
|
|
497
|
-
|
|
498
|
-
# SSRF guard — fires only for genuinely new egress destinations.
|
|
499
|
-
# Redacted echoes (the netloc-only string GET /mode returns per
|
|
500
|
-
# SEC-L-01) are NOT outbound targets; they will be silently replaced
|
|
501
|
-
# with the stored full URL by apply_settings_update.
|
|
502
|
-
if _raw_ep and (base_url_input is not None or endpoint_input is not None):
|
|
503
|
-
from urllib.parse import urlparse as _up
|
|
504
|
-
_stored_nl: str = _up(config.llm.api_base or "").netloc
|
|
505
|
-
_is_redacted_echo: bool = bool(_stored_nl) and (_raw_ep == _stored_nl)
|
|
506
|
-
if not _is_redacted_echo:
|
|
507
|
-
client = getattr(request, "client", None)
|
|
508
|
-
endpoint_error = _validate_provider_url(
|
|
509
|
-
_raw_ep, getattr(client, "host", "") if client else ""
|
|
510
|
-
)
|
|
511
|
-
if endpoint_error:
|
|
512
|
-
return JSONResponse({"error": endpoint_error}, status_code=400)
|
|
513
|
-
|
|
514
|
-
# Build the normalised body for apply_settings_update. Inject the
|
|
515
|
-
# resolved endpoint (Ollama default when applicable) so that the
|
|
516
|
-
# credential-safe logic sees the approved value. Consolidate
|
|
517
|
-
# base_url → endpoint to keep apply_settings_update's lookup simple.
|
|
518
|
-
_apply_body: dict = dict(body)
|
|
519
|
-
if _raw_ep or clear_base_url:
|
|
520
|
-
_apply_body["endpoint"] = _raw_ep
|
|
521
|
-
_apply_body.pop("base_url", None)
|
|
522
|
-
|
|
523
|
-
# Credential-safe LLM update. All field presence, preservation, and
|
|
524
|
-
# security-clearing rules live here. Other config blocks (forgetting,
|
|
525
|
-
# injection, retrieval, math, consolidation, scope, …) are preserved
|
|
526
|
-
# because we loaded the full existing config at the top of this handler.
|
|
527
|
-
config = apply_settings_update(config, _apply_body)
|
|
528
|
-
|
|
529
|
-
# Mode switch — happens after LLM update so the correct provider/key
|
|
530
|
-
# is already in place when the runtime engine is reconfigured.
|
|
531
|
-
config.mode = Mode(new_mode)
|
|
532
|
-
|
|
533
|
-
# Update embedding only when the dashboard explicitly sent those fields;
|
|
534
|
-
# absence means "leave it alone" (AIDEV-86 / broader fix).
|
|
535
|
-
_emb_fields = ("embedding_provider", "embedding_endpoint", "embedding_key",
|
|
536
|
-
"embedding_model", "embedding_dimension")
|
|
537
|
-
if any(k in body for k in _emb_fields):
|
|
538
|
-
_old_emb = config.embedding
|
|
539
|
-
_new_provider = body.get("embedding_provider", "")
|
|
540
|
-
_new_model = body.get("embedding_model", "")
|
|
541
|
-
_new_dim = int(body.get("embedding_dimension", 0) or 0)
|
|
542
|
-
# The same range the other save route enforces. Without it a
|
|
543
|
-
# dashboard save with no dimension field stored a width of zero.
|
|
544
|
-
if _new_dim and not (64 <= _new_dim <= 8192):
|
|
545
|
-
return JSONResponse(
|
|
546
|
-
{"error": f"Dimension must be 64-8192, got {_new_dim}"},
|
|
547
|
-
status_code=400,
|
|
548
|
-
)
|
|
549
|
-
# The SECOND way to change the embedding model, and it was
|
|
550
|
-
# unguarded. Switching mode from the dashboard carries the embedding
|
|
551
|
-
# fields, so a width that the store cannot hold arrived here
|
|
552
|
-
# untouched while the other route refused it — one door bolted, the
|
|
553
|
-
# other open.
|
|
554
|
-
if not bool(body.get("force")):
|
|
555
|
-
_refusal = _refuse_incompatible_embedding(
|
|
556
|
-
config, config.embedding, _new_model, _new_dim,
|
|
557
|
-
new_provider=_new_provider,
|
|
558
|
-
)
|
|
559
|
-
if _refusal is not None:
|
|
560
|
-
return _refusal
|
|
561
|
-
config.embedding = EmbeddingConfig(
|
|
562
|
-
provider=_new_provider,
|
|
563
|
-
api_endpoint=body.get("embedding_endpoint", ""),
|
|
564
|
-
api_key=body.get("embedding_key", ""),
|
|
565
|
-
model_name=_new_model,
|
|
566
|
-
dimension=_new_dim or _old_emb.dimension,
|
|
567
|
-
# Not naming these reset them to defaults, so every embedding
|
|
568
|
-
# save from the dashboard silently put the local model back to
|
|
569
|
-
# whatever ships — the exact defect the other route had.
|
|
570
|
-
ollama_model=(
|
|
571
|
-
_new_model if _new_provider == "ollama" and _new_model
|
|
572
|
-
else _old_emb.ollama_model
|
|
573
|
-
),
|
|
574
|
-
ollama_base_url=_old_emb.ollama_base_url,
|
|
575
|
-
api_version=_old_emb.api_version,
|
|
576
|
-
deployment_name=_old_emb.deployment_name,
|
|
577
|
-
)
|
|
578
|
-
|
|
579
|
-
# When the mode actually changed, apply the new mode's structural presets
|
|
580
|
-
# (retrieval topology, math thresholds, channel weights) so the user gets
|
|
581
|
-
# the right runtime behaviour for their chosen mode.
|
|
582
|
-
if new_mode != old_mode:
|
|
583
|
-
_template = SLMConfig.for_mode(Mode(new_mode))
|
|
584
|
-
config.retrieval = _template.retrieval
|
|
585
|
-
config.math = _template.math
|
|
586
|
-
config.channel_weights = _template.channel_weights
|
|
587
|
-
|
|
588
|
-
# v3.6.12 (settings-1): mode_change=True is required to persist the new
|
|
589
|
-
# mode — save() without it hits a guard that preserves the old mode.
|
|
590
|
-
await _apply_runtime_config(request, config, mode_change=True)
|
|
591
|
-
|
|
592
|
-
log_mode_change(
|
|
593
|
-
old_mode, new_mode,
|
|
594
|
-
provider=config.llm.provider,
|
|
595
|
-
model=config.llm.model,
|
|
596
|
-
source="POST /api/v3/mode/set",
|
|
597
|
-
)
|
|
598
|
-
|
|
599
|
-
# Recycle only out-of-process fallbacks; the resident daemon engine was
|
|
600
|
-
# already acknowledged and hot-swapped by _apply_runtime_config().
|
|
601
|
-
try:
|
|
602
|
-
from superlocalmemory.core.worker_pool import WorkerPool
|
|
603
|
-
WorkerPool.shared().shutdown()
|
|
604
|
-
except Exception:
|
|
605
|
-
pass
|
|
606
|
-
|
|
607
|
-
return {
|
|
608
|
-
"success": True,
|
|
609
|
-
"mode": new_mode,
|
|
610
|
-
"provider": config.llm.provider or "none",
|
|
611
|
-
"model": config.llm.model,
|
|
612
|
-
"embedding_provider": config.embedding.provider,
|
|
613
|
-
"embedding_model": config.embedding.model_name,
|
|
614
|
-
"embedding_dimension": config.embedding.dimension,
|
|
615
|
-
}
|
|
616
|
-
except ValueError as exc:
|
|
617
|
-
return JSONResponse({"error": str(exc)}, status_code=400)
|
|
618
|
-
except Exception as e:
|
|
619
|
-
return _internal_error()
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
# ── V3.4.24: Embedding Configuration ────────────────────────────────
|
|
623
|
-
|
|
624
|
-
@router.get("/embedding/config")
|
|
625
|
-
async def get_embedding_config(request: Request):
|
|
626
|
-
"""Return current embedding configuration."""
|
|
627
|
-
try:
|
|
628
|
-
from superlocalmemory.core.config import SLMConfig
|
|
629
|
-
# The daemon may already be running a freshly hot-swapped config while
|
|
630
|
-
# a profile update is still being persisted. The dashboard must show
|
|
631
|
-
# that live truth, never silently replace it with disk defaults.
|
|
632
|
-
config = getattr(request.app.state, "config", None) or SLMConfig.load()
|
|
633
|
-
emb = config.embedding
|
|
634
|
-
return {
|
|
635
|
-
"provider": emb.provider,
|
|
636
|
-
"model_name": emb.model_name,
|
|
637
|
-
"dimension": emb.dimension,
|
|
638
|
-
"api_endpoint": emb.api_endpoint,
|
|
639
|
-
"has_key": bool(emb.api_key),
|
|
640
|
-
"is_openai_compatible": emb.is_openai_compatible,
|
|
641
|
-
"mode": config.mode.value,
|
|
642
|
-
}
|
|
643
|
-
except Exception as e:
|
|
644
|
-
return _internal_error()
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
def _refuse_incompatible_embedding(
|
|
648
|
-
config, old_emb, new_model, new_dim, new_provider=None,
|
|
649
|
-
):
|
|
650
|
-
"""None when the change is safe, otherwise the 409 to return instead.
|
|
651
|
-
|
|
652
|
-
Fail-open on anything it cannot determine: a store with no vectors yet, a
|
|
653
|
-
model server that is not running, an unreadable database. Refusing on
|
|
654
|
-
"I could not tell" would block a legitimate first-time setup, and the
|
|
655
|
-
dimension a caller declares is still checked against the store either way.
|
|
656
|
-
"""
|
|
657
|
-
from fastapi.responses import JSONResponse as _JSON
|
|
658
|
-
|
|
659
|
-
try:
|
|
660
|
-
from superlocalmemory.core.ollama_validator import (
|
|
661
|
-
EMBEDDING,
|
|
662
|
-
stored_embedding_dimension,
|
|
663
|
-
validate_ollama_model,
|
|
664
|
-
)
|
|
665
|
-
|
|
666
|
-
db_path = Path(config.base_dir) / "memory.db"
|
|
667
|
-
stored = stored_embedding_dimension(db_path)
|
|
668
|
-
if stored is None:
|
|
669
|
-
return None
|
|
670
|
-
|
|
671
|
-
# The provider being SAVED, falling back to the current one when the
|
|
672
|
-
# caller is not changing it. Reading only the current provider meant
|
|
673
|
-
# that SWITCHING to a local model never probed at all — and switching
|
|
674
|
-
# is exactly when the width changes.
|
|
675
|
-
effective_provider = (
|
|
676
|
-
new_provider
|
|
677
|
-
if new_provider is not None
|
|
678
|
-
else getattr(old_emb, "provider", "")
|
|
679
|
-
)
|
|
680
|
-
measured = None
|
|
681
|
-
if effective_provider == "ollama" or getattr(old_emb, "provider", "") == "ollama":
|
|
682
|
-
# The model being SAVED, not the one already configured. Probing the
|
|
683
|
-
# old one always matched the stored width and therefore always
|
|
684
|
-
# allowed the change — the guard measured the thing it was not
|
|
685
|
-
# protecting against.
|
|
686
|
-
probe = validate_ollama_model(
|
|
687
|
-
new_model or getattr(old_emb, "ollama_model", ""),
|
|
688
|
-
EMBEDDING,
|
|
689
|
-
base_url=getattr(old_emb, "ollama_base_url", "")
|
|
690
|
-
or "http://localhost:11434",
|
|
691
|
-
)
|
|
692
|
-
measured = probe.dimension if probe.ok else None
|
|
693
|
-
|
|
694
|
-
declared = int(new_dim or 0)
|
|
695
|
-
if measured is None and declared <= 0:
|
|
696
|
-
# Nothing to compare: the server could not be asked and the caller
|
|
697
|
-
# named no width. Allowing is the fail-open the first-time setup
|
|
698
|
-
# needs; the other route's probe still guards the common path.
|
|
699
|
-
return None
|
|
700
|
-
effective = measured if measured is not None else declared
|
|
701
|
-
if effective == stored:
|
|
702
|
-
return None
|
|
703
|
-
|
|
704
|
-
return _JSON(
|
|
705
|
-
{
|
|
706
|
-
"error": "embedding_width_mismatch",
|
|
707
|
-
"stored_dimension": stored,
|
|
708
|
-
"requested_dimension": effective,
|
|
709
|
-
"model_name": new_model,
|
|
710
|
-
"detail": (
|
|
711
|
-
f"{new_model} produces {effective}-dimensional vectors and "
|
|
712
|
-
f"this store holds {stored}-dimensional ones. Vectors of "
|
|
713
|
-
f"different widths cannot be compared, so every memory "
|
|
714
|
-
f"already stored would become unfindable by meaning. "
|
|
715
|
-
f"Rebuild them first with: slm db migrate — or resend with "
|
|
716
|
-
f"force=true if they have already been rebuilt."
|
|
717
|
-
),
|
|
718
|
-
},
|
|
719
|
-
status_code=409,
|
|
720
|
-
)
|
|
721
|
-
except Exception: # noqa: BLE001 - never block a save on the check failing
|
|
722
|
-
logger.exception("embedding width pre-check failed; allowing the save")
|
|
723
|
-
return None
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
@router.put("/embedding/config")
|
|
727
|
-
async def set_embedding_config(request: Request):
|
|
728
|
-
"""Update embedding configuration independently of mode switch."""
|
|
729
|
-
# SEC-H-01: swapping the embedding provider/key affects all recall — admin-only.
|
|
730
|
-
from superlocalmemory.server.rbac_enforce import require_manage
|
|
731
|
-
require_manage(request)
|
|
732
|
-
try:
|
|
733
|
-
body = await request.json()
|
|
734
|
-
from superlocalmemory.core.config import SLMConfig, EmbeddingConfig
|
|
735
|
-
config = getattr(request.app.state, "config", None) or SLMConfig.load()
|
|
736
|
-
|
|
737
|
-
new_provider = body.get("provider", config.embedding.provider)
|
|
738
|
-
new_model = body.get("model_name", config.embedding.model_name)
|
|
739
|
-
new_dim = int(body.get("dimension", config.embedding.dimension) or 768)
|
|
740
|
-
if not (64 <= new_dim <= 8192):
|
|
741
|
-
return JSONResponse({"error": f"Dimension must be 64-8192, got {new_dim}"}, status_code=400)
|
|
742
|
-
new_endpoint = body.get("api_endpoint", config.embedding.api_endpoint)
|
|
743
|
-
new_key = body.get("api_key", config.embedding.api_key)
|
|
744
|
-
|
|
745
|
-
old_emb = config.embedding
|
|
746
|
-
|
|
747
|
-
# A width that disagrees with what the store already holds is refused
|
|
748
|
-
# here, at the moment of writing, not merely offered as a check the
|
|
749
|
-
# caller may or may not have run. Vectors of different widths cannot be
|
|
750
|
-
# compared, so the store would keep answering similarity questions and
|
|
751
|
-
# every answer would be noise. ``force=true`` is the escape hatch for
|
|
752
|
-
# somebody who has already re-embedded.
|
|
753
|
-
if not bool(body.get("force")):
|
|
754
|
-
refusal = _refuse_incompatible_embedding(
|
|
755
|
-
config, old_emb, new_model, new_dim, new_provider=new_provider,
|
|
756
|
-
)
|
|
757
|
-
if refusal is not None:
|
|
758
|
-
return refusal
|
|
759
|
-
|
|
760
|
-
config.embedding = EmbeddingConfig(
|
|
761
|
-
model_name=new_model,
|
|
762
|
-
dimension=new_dim,
|
|
763
|
-
provider=new_provider,
|
|
764
|
-
api_endpoint=new_endpoint,
|
|
765
|
-
api_key=new_key,
|
|
766
|
-
# In Ollama mode the embedder resolves its model from
|
|
767
|
-
# ``ollama_model``, so keeping the old value here made a rename a
|
|
768
|
-
# no-op that still answered "success". A caller naming a model gets
|
|
769
|
-
# that model.
|
|
770
|
-
ollama_model=(
|
|
771
|
-
new_model if new_provider == "ollama" and new_model
|
|
772
|
-
else old_emb.ollama_model
|
|
773
|
-
),
|
|
774
|
-
ollama_base_url=old_emb.ollama_base_url,
|
|
775
|
-
api_version=old_emb.api_version,
|
|
776
|
-
deployment_name=old_emb.deployment_name,
|
|
777
|
-
)
|
|
778
|
-
await _apply_runtime_config(request, config, mode_change=False)
|
|
779
|
-
|
|
780
|
-
needs_reindex = (
|
|
781
|
-
old_emb.provider != new_provider
|
|
782
|
-
or old_emb.model_name != new_model
|
|
783
|
-
or old_emb.dimension != new_dim
|
|
784
|
-
)
|
|
785
|
-
|
|
786
|
-
# Kill workers so next request uses new config
|
|
787
|
-
try:
|
|
788
|
-
from superlocalmemory.core.worker_pool import WorkerPool
|
|
789
|
-
WorkerPool.shared().shutdown()
|
|
790
|
-
except Exception:
|
|
791
|
-
pass
|
|
792
|
-
return {
|
|
793
|
-
"success": True,
|
|
794
|
-
"provider": new_provider,
|
|
795
|
-
"model_name": new_model,
|
|
796
|
-
"dimension": new_dim,
|
|
797
|
-
"needs_reindex": needs_reindex,
|
|
798
|
-
}
|
|
799
|
-
except Exception as e:
|
|
800
|
-
return _internal_error()
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
@router.get("/scope/config")
|
|
804
|
-
async def get_scope_config(request: Request):
|
|
805
|
-
"""Return runtime multi-scope defaults used by daemon writes and recalls."""
|
|
806
|
-
try:
|
|
807
|
-
from superlocalmemory.core.config import SLMConfig
|
|
808
|
-
|
|
809
|
-
config = getattr(request.app.state, "config", None) or SLMConfig.load()
|
|
810
|
-
return {"success": True, **config.scope.as_dict()}
|
|
811
|
-
except Exception as exc:
|
|
812
|
-
return _internal_error()
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
@router.put("/scope/config")
|
|
816
|
-
async def set_scope_config(request: Request):
|
|
817
|
-
"""Validate, persist, and hot-apply explicit multi-scope defaults."""
|
|
818
|
-
# SEC-H-01: scope defaults govern cross-profile recall visibility — admin-only.
|
|
819
|
-
from superlocalmemory.server.rbac_enforce import require_manage
|
|
820
|
-
require_manage(request)
|
|
821
|
-
try:
|
|
822
|
-
body = await request.json()
|
|
823
|
-
from superlocalmemory.core.config import SLMConfig, ScopeConfig
|
|
824
|
-
|
|
825
|
-
config = SLMConfig.load()
|
|
826
|
-
current = config.scope
|
|
827
|
-
default_scope = body.get("default_scope", current.default_scope)
|
|
828
|
-
if default_scope not in {"personal", "shared", "global"}:
|
|
829
|
-
return JSONResponse(
|
|
830
|
-
{"error": "default_scope must be personal, shared, or global"},
|
|
831
|
-
status_code=400,
|
|
832
|
-
)
|
|
833
|
-
include_global = body.get(
|
|
834
|
-
"recall_include_global", current.recall_include_global,
|
|
835
|
-
)
|
|
836
|
-
include_shared = body.get(
|
|
837
|
-
"recall_include_shared", current.recall_include_shared,
|
|
838
|
-
)
|
|
839
|
-
if not isinstance(include_global, bool) or not isinstance(include_shared, bool):
|
|
840
|
-
return JSONResponse(
|
|
841
|
-
{"error": "recall scope flags must be booleans"},
|
|
842
|
-
status_code=400,
|
|
843
|
-
)
|
|
844
|
-
config.scope = ScopeConfig(
|
|
845
|
-
default_scope=default_scope,
|
|
846
|
-
recall_include_global=include_global,
|
|
847
|
-
recall_include_shared=include_shared,
|
|
848
|
-
)
|
|
849
|
-
await _apply_runtime_config(request, config, mode_change=False)
|
|
850
|
-
return {"success": True, **config.scope.as_dict()}
|
|
851
|
-
except Exception as exc:
|
|
852
|
-
return _internal_error()
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
@router.post("/embedding/test")
|
|
856
|
-
async def test_embedding_endpoint(request: Request):
|
|
857
|
-
"""Test connectivity to a custom embedding endpoint."""
|
|
858
|
-
_require_manage(request)
|
|
859
|
-
try:
|
|
860
|
-
import httpx
|
|
861
|
-
body = await request.json()
|
|
862
|
-
endpoint = body.get("api_endpoint", "").rstrip("/")
|
|
863
|
-
model = body.get("model_name", "test")
|
|
864
|
-
api_key = body.get("api_key", "")
|
|
865
|
-
|
|
866
|
-
if not endpoint:
|
|
867
|
-
return JSONResponse({"error": "No endpoint provided"}, status_code=400)
|
|
868
|
-
|
|
869
|
-
client_host = request.client.host if request.client else ""
|
|
870
|
-
error = _validate_provider_url(endpoint, client_host)
|
|
871
|
-
if error:
|
|
872
|
-
return JSONResponse({"error": error}, status_code=400)
|
|
873
|
-
|
|
874
|
-
if not endpoint.endswith("/embeddings"):
|
|
875
|
-
endpoint = f"{endpoint}/embeddings"
|
|
876
|
-
|
|
877
|
-
headers = {"Content-Type": "application/json"}
|
|
878
|
-
if api_key:
|
|
879
|
-
headers["Authorization"] = f"Bearer {api_key}"
|
|
880
|
-
|
|
881
|
-
payload = {"input": ["test embedding connection"], "model": model}
|
|
882
|
-
|
|
883
|
-
with httpx.Client(timeout=httpx.Timeout(15.0)) as client:
|
|
884
|
-
resp = client.post(endpoint, headers=headers, json=payload)
|
|
885
|
-
resp.raise_for_status()
|
|
886
|
-
data = resp.json()
|
|
887
|
-
emb_data = data.get("data", [])
|
|
888
|
-
if emb_data:
|
|
889
|
-
dim = len(emb_data[0].get("embedding", []))
|
|
890
|
-
return {
|
|
891
|
-
"success": True,
|
|
892
|
-
"message": f"Connected! Dimension: {dim}",
|
|
893
|
-
"dimension": dim,
|
|
894
|
-
}
|
|
895
|
-
return {"success": False, "error": "No embedding data returned"}
|
|
896
|
-
except httpx.HTTPStatusError as e:
|
|
897
|
-
return {"success": False, "error": f"HTTP {e.response.status_code}"}
|
|
898
|
-
except httpx.ConnectError:
|
|
899
|
-
return {"success": False, "error": "Cannot reach the embedding server. Is it running?"}
|
|
900
|
-
except httpx.TimeoutException:
|
|
901
|
-
return {"success": False, "error": "Connection timed out after 15 seconds."}
|
|
902
|
-
except Exception as e:
|
|
903
|
-
return {"success": False, "error": type(e).__name__}
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
@router.get("/embed/ping")
|
|
907
|
-
async def embed_ping():
|
|
908
|
-
"""V3.5.9: Liveness probe for McpEmbedderProxy. Returns 200 when daemon
|
|
909
|
-
embedder is ready so the proxy knows the daemon is reachable."""
|
|
910
|
-
try:
|
|
911
|
-
from .helpers import get_engine_lazy
|
|
912
|
-
# We just need to confirm the route is alive — engine check is optional
|
|
913
|
-
return {"ok": True}
|
|
914
|
-
except Exception:
|
|
915
|
-
logger.exception("embedder liveness probe failed")
|
|
916
|
-
return JSONResponse({"ok": False, "error": "Internal server error"}, status_code=503)
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
@router.post("/embed")
|
|
920
|
-
async def embed_texts(request: Request):
|
|
921
|
-
"""V3.5.9: Embed texts via daemon's FULL engine for McpEmbedderProxy.
|
|
922
|
-
|
|
923
|
-
MCP processes run LIGHT engines (no ONNX worker). This endpoint lets them
|
|
924
|
-
delegate embed_batch() to the daemon's single real embedder — one ONNX
|
|
925
|
-
worker total across all sessions (fixes PR #30 NULL embedder bug).
|
|
926
|
-
"""
|
|
927
|
-
import asyncio
|
|
928
|
-
try:
|
|
929
|
-
body = await request.json()
|
|
930
|
-
texts = body.get("texts", [])
|
|
931
|
-
if not texts:
|
|
932
|
-
return {"embeddings": []}
|
|
933
|
-
|
|
934
|
-
from .helpers import get_engine_lazy
|
|
935
|
-
engine = get_engine_lazy(request.app.state)
|
|
936
|
-
if engine is None or engine._embedder is None:
|
|
937
|
-
return JSONResponse(
|
|
938
|
-
{"error": "Embedder not available in daemon"},
|
|
939
|
-
status_code=503,
|
|
940
|
-
)
|
|
941
|
-
|
|
942
|
-
loop = asyncio.get_event_loop()
|
|
943
|
-
embeddings = await loop.run_in_executor(
|
|
944
|
-
None,
|
|
945
|
-
lambda: engine._embedder.embed_batch(texts),
|
|
946
|
-
)
|
|
947
|
-
return {"embeddings": embeddings}
|
|
948
|
-
except Exception as e:
|
|
949
|
-
return _internal_error()
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
def _validate_provider_url(url: str, client_host: str) -> str | None:
|
|
953
|
-
"""SSRF guard for outbound provider-test fetches (v3.6.12 ssrf-1).
|
|
954
|
-
|
|
955
|
-
Returns an error string if ``url`` is unsafe, else None. ALWAYS blocks
|
|
956
|
-
non-http(s) and cloud-metadata hosts. A LOOPBACK caller (the local
|
|
957
|
-
dashboard) may legitimately test local/LAN LLM endpoints, so private IPs
|
|
958
|
-
are allowed for it. A NON-loopback caller may not make the server fetch
|
|
959
|
-
private/loopback/link-local/reserved targets — that is the SSRF abuse.
|
|
960
|
-
"""
|
|
961
|
-
from superlocalmemory.server.egress_policy import (
|
|
962
|
-
EgressActor,
|
|
963
|
-
EgressVerdict,
|
|
964
|
-
validate_egress_url,
|
|
965
|
-
)
|
|
966
|
-
from superlocalmemory.server.loopback import is_loopback as _is_loopback_host
|
|
967
|
-
|
|
968
|
-
is_local = _is_loopback_host(client_host)
|
|
969
|
-
# SLM_REMOTE residue (#40): an allowlisted LAN dashboard is trusted exactly
|
|
970
|
-
# like the loopback one and may probe its own LAN LLM endpoint. This does
|
|
971
|
-
# NOT relax the SSRF guard for arbitrary remote callers —
|
|
972
|
-
# is_lan_client_allowed is False unless remote mode is ON *and* the client
|
|
973
|
-
# IP is in SLM_MCP_ALLOWED_HOSTS.
|
|
974
|
-
is_lan = False
|
|
975
|
-
try:
|
|
976
|
-
from superlocalmemory.core.remote_mode import is_lan_client_allowed
|
|
977
|
-
is_lan = bool(is_lan_client_allowed(client_host))
|
|
978
|
-
except Exception: # pragma: no cover — defensive, never weaken on import error
|
|
979
|
-
is_lan = False
|
|
980
|
-
|
|
981
|
-
result = validate_egress_url(
|
|
982
|
-
url, EgressActor(is_local=is_local, is_lan=is_lan)
|
|
983
|
-
)
|
|
984
|
-
|
|
985
|
-
if result.verdict is EgressVerdict.ALLOW:
|
|
986
|
-
return None
|
|
987
|
-
if result.verdict is EgressVerdict.DENY_SCHEME:
|
|
988
|
-
return "Only http/https endpoints are supported"
|
|
989
|
-
if result.verdict is EgressVerdict.DENY_METADATA:
|
|
990
|
-
return "Cloud metadata endpoints are not allowed"
|
|
991
|
-
if result.verdict in (
|
|
992
|
-
EgressVerdict.DENY_CREDENTIALS,
|
|
993
|
-
EgressVerdict.DENY_FRAGMENT,
|
|
994
|
-
):
|
|
995
|
-
return "Endpoint URL must not embed credentials or fragments"
|
|
996
|
-
if result.verdict is EgressVerdict.DENY_DNS_FAILURE:
|
|
997
|
-
return "Endpoint host could not be resolved"
|
|
998
|
-
# DENY_PRIVATE / DENY_MIXED_DNS / DENY_HOST
|
|
999
|
-
return "Internal/private endpoints are not allowed from a remote client"
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
@router.post("/provider/test")
|
|
1003
|
-
async def test_provider(request: Request):
|
|
1004
|
-
"""Test connectivity to an LLM provider."""
|
|
1005
|
-
try:
|
|
1006
|
-
import httpx
|
|
1007
|
-
body = await request.json()
|
|
1008
|
-
provider = body.get("provider", "")
|
|
1009
|
-
model = body.get("model", "")
|
|
1010
|
-
api_key = body.get("api_key", "")
|
|
1011
|
-
_client_host = request.client.host if request.client else ""
|
|
1012
|
-
|
|
1013
|
-
if provider == "ollama":
|
|
1014
|
-
endpoint = body.get("endpoint", "http://localhost:11434")
|
|
1015
|
-
_err = _validate_provider_url(endpoint, _client_host)
|
|
1016
|
-
if _err:
|
|
1017
|
-
return {"success": False, "error": _err}
|
|
1018
|
-
with httpx.Client(timeout=httpx.Timeout(5.0)) as c:
|
|
1019
|
-
resp = c.get(f"{endpoint}/api/tags")
|
|
1020
|
-
resp.raise_for_status()
|
|
1021
|
-
models = [m["name"] for m in resp.json().get("models", [])]
|
|
1022
|
-
# Match either exact name or base name (strip ":tag" suffix on both sides)
|
|
1023
|
-
model_base = model.split(":")[0] if model else ""
|
|
1024
|
-
found = any(m == model or m.split(":")[0] == model_base for m in models) if model else len(models) > 0
|
|
1025
|
-
return {
|
|
1026
|
-
"success": found,
|
|
1027
|
-
"message": f"Ollama OK, {len(models)} models" + (f", '{model}' available" if found and model else ""),
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
if provider == "openrouter":
|
|
1031
|
-
if not api_key:
|
|
1032
|
-
api_key = os.environ.get("OPENROUTER_API_KEY", "")
|
|
1033
|
-
if not api_key:
|
|
1034
|
-
return {"success": False, "error": "API key required"}
|
|
1035
|
-
with httpx.Client(timeout=httpx.Timeout(10.0)) as c:
|
|
1036
|
-
resp = c.get("https://openrouter.ai/api/v1/models", headers={"Authorization": f"Bearer {api_key}"})
|
|
1037
|
-
resp.raise_for_status()
|
|
1038
|
-
return {"success": True, "message": "OpenRouter connected, key valid"}
|
|
1039
|
-
|
|
1040
|
-
if provider == "openai":
|
|
1041
|
-
# V3.5.9: custom/local endpoint — api_key is optional (llama.cpp, LM Studio etc.)
|
|
1042
|
-
custom_endpoint = body.get("base_url", "").strip() or body.get("endpoint", "").strip()
|
|
1043
|
-
if custom_endpoint:
|
|
1044
|
-
_err = _validate_provider_url(custom_endpoint, _client_host)
|
|
1045
|
-
if _err:
|
|
1046
|
-
return {"success": False, "error": _err}
|
|
1047
|
-
headers_test = {"Content-Type": "application/json"}
|
|
1048
|
-
if api_key:
|
|
1049
|
-
headers_test["Authorization"] = f"Bearer {api_key}"
|
|
1050
|
-
base = custom_endpoint.rstrip("/")
|
|
1051
|
-
if not base.endswith("chat/completions"):
|
|
1052
|
-
base = f"{base}/chat/completions"
|
|
1053
|
-
with httpx.Client(timeout=httpx.Timeout(10.0)) as c:
|
|
1054
|
-
# Probe with a minimal chat request (models list not universal on local servers)
|
|
1055
|
-
probe = {"model": body.get("model", "test"), "messages": [{"role": "user", "content": "hi"}], "max_tokens": 1}
|
|
1056
|
-
resp = c.post(base, headers=headers_test, json=probe)
|
|
1057
|
-
if resp.status_code in (200, 400, 422):
|
|
1058
|
-
return {"success": True, "message": f"Custom endpoint reachable (HTTP {resp.status_code})"}
|
|
1059
|
-
resp.raise_for_status()
|
|
1060
|
-
return {"success": True, "message": "Custom OpenAI-compatible endpoint connected"}
|
|
1061
|
-
if not api_key:
|
|
1062
|
-
return {"success": False, "error": "API key required for official OpenAI endpoint"}
|
|
1063
|
-
with httpx.Client(timeout=httpx.Timeout(10.0)) as c:
|
|
1064
|
-
resp = c.get("https://api.openai.com/v1/models", headers={"Authorization": f"Bearer {api_key}"})
|
|
1065
|
-
resp.raise_for_status()
|
|
1066
|
-
return {"success": True, "message": "OpenAI connected, key valid"}
|
|
1067
|
-
|
|
1068
|
-
if provider == "anthropic":
|
|
1069
|
-
if not api_key:
|
|
1070
|
-
return {"success": False, "error": "API key required"}
|
|
1071
|
-
# Anthropic doesn't have a models list endpoint, just verify key format
|
|
1072
|
-
if api_key.startswith("sk-ant-"):
|
|
1073
|
-
return {"success": True, "message": "Anthropic key format valid"}
|
|
1074
|
-
return {"success": False, "error": "Key should start with sk-ant-"}
|
|
1075
|
-
|
|
1076
|
-
return {"success": False, "error": f"Unknown provider: {provider}"}
|
|
1077
|
-
except httpx.ConnectError:
|
|
1078
|
-
return {"success": False, "error": "Cannot connect — is the service running?"}
|
|
1079
|
-
except httpx.HTTPStatusError as e:
|
|
1080
|
-
return {"success": False, "error": f"HTTP {e.response.status_code}: Invalid key or endpoint"}
|
|
1081
|
-
except Exception as e:
|
|
1082
|
-
logger.exception("verify_key failed")
|
|
1083
|
-
return {"success": False, "error": "internal error"}
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
@router.get("/ollama/status")
|
|
1087
|
-
async def ollama_status():
|
|
1088
|
-
"""Check if Ollama is running and list available models."""
|
|
1089
|
-
try:
|
|
1090
|
-
import httpx
|
|
1091
|
-
with httpx.Client(timeout=httpx.Timeout(5.0)) as client:
|
|
1092
|
-
resp = client.get("http://localhost:11434/api/tags")
|
|
1093
|
-
resp.raise_for_status()
|
|
1094
|
-
data = resp.json()
|
|
1095
|
-
models = [
|
|
1096
|
-
{"name": m["name"], "size": m.get("size", 0)}
|
|
1097
|
-
for m in data.get("models", [])
|
|
1098
|
-
]
|
|
1099
|
-
return {"running": True, "models": models, "count": len(models)}
|
|
1100
|
-
except Exception:
|
|
1101
|
-
return {"running": False, "models": [], "count": 0}
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
# ── Provider ─────────────────────────────────────────────────
|
|
1105
|
-
|
|
1106
|
-
@router.get("/providers")
|
|
1107
|
-
async def list_providers():
|
|
1108
|
-
"""List available LLM providers with presets."""
|
|
1109
|
-
try:
|
|
1110
|
-
from superlocalmemory.core.config import SLMConfig
|
|
1111
|
-
return {"providers": SLMConfig.provider_presets()}
|
|
1112
|
-
except Exception as exc:
|
|
1113
|
-
logger.exception("list_providers failed")
|
|
1114
|
-
return {"error": "could not load provider list", "providers": []}
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
@router.get("/provider")
|
|
1118
|
-
async def get_provider():
|
|
1119
|
-
"""Get current provider configuration (API key masked)."""
|
|
1120
|
-
try:
|
|
1121
|
-
from superlocalmemory.core.config import SLMConfig
|
|
1122
|
-
config = SLMConfig.load()
|
|
1123
|
-
key = config.llm.api_key
|
|
1124
|
-
masked = f"****{key[-4:]}" if len(key) > 8 else "****" if key else ""
|
|
1125
|
-
return {
|
|
1126
|
-
"provider": config.llm.provider or "none",
|
|
1127
|
-
"model": config.llm.model,
|
|
1128
|
-
"base_url": config.llm.api_base,
|
|
1129
|
-
"api_key_masked": masked,
|
|
1130
|
-
"has_key": bool(key),
|
|
1131
|
-
}
|
|
1132
|
-
except Exception as exc:
|
|
1133
|
-
logger.exception("get_provider failed")
|
|
1134
|
-
return {"error": "could not load configuration", "provider": "unknown"}
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
@router.put("/provider")
|
|
1138
|
-
async def set_provider(request: Request):
|
|
1139
|
-
"""Set LLM provider. Body: {"provider": "openai", "api_key": "...", "model": "..."}"""
|
|
1140
|
-
# Swapping the provider + API key redirects all LLM traffic (prompt/output
|
|
1141
|
-
# interception risk) — admin-only, on top of mutation auth.
|
|
1142
|
-
from superlocalmemory.server.route_mutations import authorize_route_mutation
|
|
1143
|
-
from superlocalmemory.server.rbac_enforce import require_manage
|
|
1144
|
-
_auth = authorize_route_mutation(request, operation="update",
|
|
1145
|
-
source_agent_id="http-set-provider")
|
|
1146
|
-
require_manage(request)
|
|
1147
|
-
try:
|
|
1148
|
-
body = await request.json()
|
|
1149
|
-
provider = body.get("provider", "")
|
|
1150
|
-
api_key = body.get("api_key", "")
|
|
1151
|
-
model = body.get("model", "")
|
|
1152
|
-
base_url = body.get("base_url", "")
|
|
1153
|
-
|
|
1154
|
-
from superlocalmemory.core.config import SLMConfig, LLMConfig
|
|
1155
|
-
config = SLMConfig.load()
|
|
1156
|
-
|
|
1157
|
-
# Use preset base_url if not provided
|
|
1158
|
-
if not base_url:
|
|
1159
|
-
presets = SLMConfig.provider_presets()
|
|
1160
|
-
preset = presets.get(provider, {})
|
|
1161
|
-
base_url = preset.get("base_url", "")
|
|
1162
|
-
if not model:
|
|
1163
|
-
model = preset.get("model", "")
|
|
1164
|
-
|
|
1165
|
-
# Mutate only the LLM block — all other config is preserved.
|
|
1166
|
-
config.llm = LLMConfig(
|
|
1167
|
-
provider=provider,
|
|
1168
|
-
model=model,
|
|
1169
|
-
api_key=api_key,
|
|
1170
|
-
api_base=base_url,
|
|
1171
|
-
)
|
|
1172
|
-
config.save()
|
|
1173
|
-
|
|
1174
|
-
return {"success": True, "provider": provider, "model": model}
|
|
1175
|
-
except Exception as e:
|
|
1176
|
-
return _internal_error()
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
# ── Recall Trace ─────────────────────────────────────────────
|
|
1180
|
-
|
|
1181
|
-
@router.post("/recall/trace")
|
|
1182
|
-
async def recall_trace(request: Request):
|
|
1183
|
-
"""Recall with per-channel score breakdown.
|
|
1184
|
-
|
|
1185
|
-
v3.4.64: Replaced WorkerPool.shared() (subprocess, blocks event loop,
|
|
1186
|
-
worker crashes after ~15s) with daemon engine via run_in_executor.
|
|
1187
|
-
Same fix as POST /api/search in v3.4.63.
|
|
1188
|
-
"""
|
|
1189
|
-
import asyncio
|
|
1190
|
-
import time as _time
|
|
1191
|
-
try:
|
|
1192
|
-
body = await request.json()
|
|
1193
|
-
query = body.get("query", "")
|
|
1194
|
-
limit = body.get("limit", CANONICAL_RECALL_LIMIT)
|
|
1195
|
-
window = body.get("window", "") or ""
|
|
1196
|
-
as_of_raw = (body.get("as_of", "") or "").strip()
|
|
1197
|
-
raw_known_as_of = body.get("known_as_of", "")
|
|
1198
|
-
raw_valid_at = body.get("valid_at", "")
|
|
1199
|
-
if raw_known_as_of is not None and not isinstance(raw_known_as_of, str):
|
|
1200
|
-
return JSONResponse({"error": "invalid_known_as_of"}, status_code=400)
|
|
1201
|
-
if raw_valid_at is not None and not isinstance(raw_valid_at, str):
|
|
1202
|
-
return JSONResponse({"error": "invalid_valid_at"}, status_code=400)
|
|
1203
|
-
known_as_of_raw = (raw_known_as_of or "").strip()
|
|
1204
|
-
valid_at_raw = (raw_valid_at or "").strip()
|
|
1205
|
-
include_unknown = body.get("include_unknown", False)
|
|
1206
|
-
if not isinstance(include_unknown, bool):
|
|
1207
|
-
return JSONResponse({"error": "invalid_include_unknown"}, status_code=400)
|
|
1208
|
-
|
|
1209
|
-
# Normalize as_of at HTTP boundary. Invalid → 400.
|
|
1210
|
-
_as_of: str | None = None
|
|
1211
|
-
if as_of_raw:
|
|
1212
|
-
from superlocalmemory.retrieval.temporal_utils import normalize_as_of
|
|
1213
|
-
_as_of = normalize_as_of(as_of_raw)
|
|
1214
|
-
if _as_of is None:
|
|
1215
|
-
return JSONResponse(
|
|
1216
|
-
{"error": "invalid_as_of", "raw": as_of_raw}, status_code=400
|
|
1217
|
-
)
|
|
1218
|
-
def _normalize_named_time(raw: str, error: str) -> str | None | JSONResponse:
|
|
1219
|
-
if not raw:
|
|
1220
|
-
return None
|
|
1221
|
-
from superlocalmemory.retrieval.temporal_utils import normalize_as_of
|
|
1222
|
-
normalized = normalize_as_of(raw)
|
|
1223
|
-
if normalized is None:
|
|
1224
|
-
return JSONResponse({"error": error, "raw": raw}, status_code=400)
|
|
1225
|
-
return normalized
|
|
1226
|
-
_known_as_of = _normalize_named_time(known_as_of_raw, "invalid_known_as_of")
|
|
1227
|
-
if isinstance(_known_as_of, JSONResponse):
|
|
1228
|
-
return _known_as_of
|
|
1229
|
-
_valid_at = _normalize_named_time(valid_at_raw, "invalid_valid_at")
|
|
1230
|
-
if isinstance(_valid_at, JSONResponse):
|
|
1231
|
-
return _valid_at
|
|
1232
|
-
|
|
1233
|
-
# Use daemon engine — already loaded, shares warm page cache.
|
|
1234
|
-
# run_in_executor keeps event loop alive so browser doesn't abort.
|
|
1235
|
-
from .helpers import get_engine_lazy
|
|
1236
|
-
engine = get_engine_lazy(request.app.state)
|
|
1237
|
-
if engine is None:
|
|
1238
|
-
return JSONResponse({"error": "Engine not initialised"}, status_code=503)
|
|
1239
|
-
|
|
1240
|
-
loop = asyncio.get_event_loop()
|
|
1241
|
-
t0 = _time.monotonic()
|
|
1242
|
-
response = await loop.run_in_executor(
|
|
1243
|
-
None,
|
|
1244
|
-
lambda: engine.recall(
|
|
1245
|
-
query, limit=limit, fast=False,
|
|
1246
|
-
window=window or None, as_of=_as_of,
|
|
1247
|
-
known_as_of=_known_as_of, valid_at=_valid_at,
|
|
1248
|
-
include_unknown=include_unknown,
|
|
1249
|
-
# Whoever asked, by the name they arrived under. Without a name
|
|
1250
|
-
# the record of this recall is discarded and no outcome
|
|
1251
|
-
# reported afterwards can be matched back to it.
|
|
1252
|
-
session_id=_signal_session_id(),
|
|
1253
|
-
),
|
|
1254
|
-
)
|
|
1255
|
-
elapsed_ms = round((_time.monotonic() - t0) * 1000, 1)
|
|
1256
|
-
|
|
1257
|
-
from superlocalmemory.server.recall_serializer import (
|
|
1258
|
-
recall_response_metadata,
|
|
1259
|
-
serialize_recall_response,
|
|
1260
|
-
)
|
|
1261
|
-
results, no_confident_match = serialize_recall_response(
|
|
1262
|
-
response,
|
|
1263
|
-
limit=limit,
|
|
1264
|
-
per_fact_max=300,
|
|
1265
|
-
total_max=max(300, limit * 300),
|
|
1266
|
-
)
|
|
1267
|
-
|
|
1268
|
-
# Record learning signals (non-blocking, non-critical)
|
|
1269
|
-
try:
|
|
1270
|
-
_record_learning_signals(query, results)
|
|
1271
|
-
except Exception as _sig_exc:
|
|
1272
|
-
import logging as _log
|
|
1273
|
-
_log.getLogger(__name__).warning("Learning signal error: %s", _sig_exc)
|
|
1274
|
-
|
|
1275
|
-
return {
|
|
1276
|
-
"query": query,
|
|
1277
|
-
"query_type": getattr(response, "query_type", "semantic"),
|
|
1278
|
-
"result_count": len(results),
|
|
1279
|
-
"retrieval_time_ms": elapsed_ms,
|
|
1280
|
-
"results": results,
|
|
1281
|
-
"synthesis": "",
|
|
1282
|
-
"no_confident_match": no_confident_match,
|
|
1283
|
-
**recall_response_metadata(response),
|
|
1284
|
-
}
|
|
1285
|
-
except Exception as e:
|
|
1286
|
-
return _internal_error()
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
def _record_learning_signals(query: str, results: list) -> None:
|
|
1290
|
-
"""Record feedback + co-retrieval + confidence boost for any recall."""
|
|
1291
|
-
from superlocalmemory.core.config import SLMConfig
|
|
1292
|
-
|
|
1293
|
-
from superlocalmemory.infra.data_root import canonical_data_root
|
|
1294
|
-
slm_dir = canonical_data_root()
|
|
1295
|
-
config = SLMConfig.load()
|
|
1296
|
-
pid = config.active_profile
|
|
1297
|
-
fact_ids = [r.get("fact_id", "") for r in results[:10] if r.get("fact_id")]
|
|
1298
|
-
if not fact_ids:
|
|
1299
|
-
return
|
|
1300
|
-
|
|
1301
|
-
try:
|
|
1302
|
-
from superlocalmemory.learning.feedback import FeedbackCollector
|
|
1303
|
-
collector = FeedbackCollector(slm_dir / "learning.db")
|
|
1304
|
-
collector.record_implicit(
|
|
1305
|
-
profile_id=pid, query=query,
|
|
1306
|
-
fact_ids_returned=fact_ids, fact_ids_available=fact_ids,
|
|
1307
|
-
)
|
|
1308
|
-
except Exception:
|
|
1309
|
-
pass
|
|
1310
|
-
|
|
1311
|
-
try:
|
|
1312
|
-
from superlocalmemory.learning.signals import LearningSignals
|
|
1313
|
-
signals = LearningSignals(slm_dir / "learning.db")
|
|
1314
|
-
signals.record_co_retrieval(pid, fact_ids)
|
|
1315
|
-
except Exception:
|
|
1316
|
-
pass
|
|
1317
|
-
|
|
1318
|
-
try:
|
|
1319
|
-
from superlocalmemory.learning.signals import LearningSignals
|
|
1320
|
-
mem_db = str(slm_dir / "memory.db")
|
|
1321
|
-
for fid in fact_ids[:5]:
|
|
1322
|
-
LearningSignals.boost_confidence(mem_db, fid)
|
|
1323
|
-
except Exception:
|
|
1324
|
-
pass
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
# ── Trust Dashboard ──────────────────────────────────────────
|
|
1328
|
-
|
|
1329
|
-
@router.get("/trust/dashboard")
|
|
1330
|
-
async def trust_dashboard(request: Request):
|
|
1331
|
-
"""Trust overview: per-agent scores, alerts. Queries DB directly."""
|
|
1332
|
-
try:
|
|
1333
|
-
from superlocalmemory.core.config import SLMConfig
|
|
1334
|
-
from superlocalmemory.storage.database import DatabaseManager
|
|
1335
|
-
from superlocalmemory.storage import schema as _schema
|
|
1336
|
-
config = SLMConfig.load()
|
|
1337
|
-
pid = config.active_profile
|
|
1338
|
-
|
|
1339
|
-
db_path = config.db_path
|
|
1340
|
-
db = DatabaseManager(db_path)
|
|
1341
|
-
db.initialize(_schema)
|
|
1342
|
-
|
|
1343
|
-
# Query trust scores from DB
|
|
1344
|
-
agents = []
|
|
1345
|
-
try:
|
|
1346
|
-
rows = db.execute(
|
|
1347
|
-
"SELECT target_id, target_type, trust_score, evidence_count, "
|
|
1348
|
-
"last_updated FROM trust_scores WHERE profile_id = ? "
|
|
1349
|
-
"ORDER BY trust_score DESC",
|
|
1350
|
-
(pid,),
|
|
1351
|
-
)
|
|
1352
|
-
for r in rows:
|
|
1353
|
-
d = dict(r)
|
|
1354
|
-
agents.append({
|
|
1355
|
-
"target_id": d.get("target_id", ""),
|
|
1356
|
-
"target_type": d.get("target_type", ""),
|
|
1357
|
-
"trust_score": round(float(d.get("trust_score", 0.5)), 3),
|
|
1358
|
-
"evidence_count": d.get("evidence_count", 0),
|
|
1359
|
-
"last_updated": d.get("last_updated", ""),
|
|
1360
|
-
})
|
|
1361
|
-
except Exception:
|
|
1362
|
-
pass
|
|
1363
|
-
|
|
1364
|
-
# Aggregate stats
|
|
1365
|
-
avg = round(sum(a["trust_score"] for a in agents) / len(agents), 3) if agents else 0.5
|
|
1366
|
-
alerts = [a for a in agents if a["trust_score"] < 0.3]
|
|
1367
|
-
|
|
1368
|
-
return {
|
|
1369
|
-
"agents": agents,
|
|
1370
|
-
"avg_trust": avg,
|
|
1371
|
-
"alerts": alerts,
|
|
1372
|
-
"total": len(agents),
|
|
1373
|
-
"profile": pid,
|
|
1374
|
-
}
|
|
1375
|
-
except Exception as e:
|
|
1376
|
-
return _internal_error()
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
# ── Math Health ──────────────────────────────────────────────
|
|
1380
|
-
|
|
1381
|
-
@router.get("/math/health")
|
|
1382
|
-
async def math_health(request: Request):
|
|
1383
|
-
"""Mathematical layer health: Fisher, sheaf, Langevin status. Queries DB directly."""
|
|
1384
|
-
try:
|
|
1385
|
-
# v3.6.12 (math-1): report CONFIG-DERIVED status, not a hardcoded
|
|
1386
|
-
# "active"/"healthy" for every layer. The old code had a dead `if engine:`
|
|
1387
|
-
# (engine was always None) and returned all-green unconditionally — a
|
|
1388
|
-
# false-assurance pane. We can't probe the recall subprocess from here,
|
|
1389
|
-
# so report the real configured mode/threshold/temperature and label the
|
|
1390
|
-
# status "configured" (or "unknown" if config can't load).
|
|
1391
|
-
from superlocalmemory.core.config import SLMConfig
|
|
1392
|
-
config = SLMConfig.load()
|
|
1393
|
-
math = getattr(config, "math", None)
|
|
1394
|
-
_status = "configured" if math is not None else "unknown"
|
|
1395
|
-
health = {
|
|
1396
|
-
"fisher": {
|
|
1397
|
-
"status": _status,
|
|
1398
|
-
"description": "Fisher-Rao information geometry for similarity",
|
|
1399
|
-
"mode": getattr(math, "fisher_mode", None) if math else None,
|
|
1400
|
-
},
|
|
1401
|
-
"sheaf": {
|
|
1402
|
-
"status": _status,
|
|
1403
|
-
"description": "Sheaf cohomology for consistency detection",
|
|
1404
|
-
"threshold": getattr(math, "sheaf_contradiction_threshold", None) if math else None,
|
|
1405
|
-
},
|
|
1406
|
-
"langevin": {
|
|
1407
|
-
"status": _status,
|
|
1408
|
-
"description": "Riemannian Langevin dynamics for lifecycle",
|
|
1409
|
-
"temperature": getattr(math, "langevin_temperature", None) if math else None,
|
|
1410
|
-
},
|
|
1411
|
-
}
|
|
1412
|
-
return {
|
|
1413
|
-
"health": health,
|
|
1414
|
-
"overall": _status,
|
|
1415
|
-
"note": "config-derived; not a live runtime probe",
|
|
1416
|
-
}
|
|
1417
|
-
except Exception as e:
|
|
1418
|
-
return _internal_error()
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
# ── Auto-Capture / Auto-Recall Config ────────────────────────
|
|
1422
|
-
|
|
1423
|
-
@router.get("/auto-capture/config")
|
|
1424
|
-
async def get_auto_capture_config():
|
|
1425
|
-
"""Get auto-capture configuration."""
|
|
1426
|
-
try:
|
|
1427
|
-
from superlocalmemory.hooks.rules_engine import RulesEngine
|
|
1428
|
-
from superlocalmemory.core.config import DEFAULT_BASE_DIR
|
|
1429
|
-
rules = RulesEngine(config_path=DEFAULT_BASE_DIR / "config.json")
|
|
1430
|
-
return {"config": rules.get_capture_config()}
|
|
1431
|
-
except Exception as exc:
|
|
1432
|
-
logger.exception("get_auto_capture_config failed")
|
|
1433
|
-
return {"error": "internal error", "config": {}}
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
@router.put("/auto-capture/config")
|
|
1437
|
-
async def set_auto_capture_config(request: Request):
|
|
1438
|
-
"""Update auto-capture config. Body: {"enabled": true, "capture_decisions": true, ...}"""
|
|
1439
|
-
_require_manage(request)
|
|
1440
|
-
try:
|
|
1441
|
-
body = await request.json()
|
|
1442
|
-
from superlocalmemory.hooks.rules_engine import RulesEngine
|
|
1443
|
-
from superlocalmemory.core.config import DEFAULT_BASE_DIR
|
|
1444
|
-
config_path = DEFAULT_BASE_DIR / "config.json"
|
|
1445
|
-
rules = RulesEngine(config_path=config_path)
|
|
1446
|
-
for key, value in body.items():
|
|
1447
|
-
rules.update_rule("auto_capture", key, value)
|
|
1448
|
-
rules.save(config_path)
|
|
1449
|
-
return {"success": True, "config": rules.get_capture_config()}
|
|
1450
|
-
except Exception as e:
|
|
1451
|
-
return _internal_error()
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
@router.get("/auto-recall/config")
|
|
1455
|
-
async def get_auto_recall_config():
|
|
1456
|
-
"""Get auto-recall configuration."""
|
|
1457
|
-
try:
|
|
1458
|
-
from superlocalmemory.hooks.rules_engine import RulesEngine
|
|
1459
|
-
from superlocalmemory.core.config import DEFAULT_BASE_DIR
|
|
1460
|
-
rules = RulesEngine(config_path=DEFAULT_BASE_DIR / "config.json")
|
|
1461
|
-
return {"config": rules.get_recall_config()}
|
|
1462
|
-
except Exception as exc:
|
|
1463
|
-
logger.exception("get_auto_recall_config failed")
|
|
1464
|
-
return {"error": "internal error", "config": {}}
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
@router.put("/auto-recall/config")
|
|
1468
|
-
async def set_auto_recall_config(request: Request):
|
|
1469
|
-
"""Update auto-recall config."""
|
|
1470
|
-
_require_manage(request)
|
|
1471
|
-
try:
|
|
1472
|
-
body = await request.json()
|
|
1473
|
-
from superlocalmemory.hooks.rules_engine import RulesEngine
|
|
1474
|
-
from superlocalmemory.core.config import DEFAULT_BASE_DIR
|
|
1475
|
-
config_path = DEFAULT_BASE_DIR / "config.json"
|
|
1476
|
-
rules = RulesEngine(config_path=config_path)
|
|
1477
|
-
for key, value in body.items():
|
|
1478
|
-
rules.update_rule("auto_recall", key, value)
|
|
1479
|
-
rules.save(config_path)
|
|
1480
|
-
return {"success": True, "config": rules.get_recall_config()}
|
|
1481
|
-
except Exception as e:
|
|
1482
|
-
return _internal_error()
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
# ── Runtime behaviour config (v3.8.2 UX-1) ──────────────────
|
|
1486
|
-
# User-facing settings that take effect LIVE (no restart) via the
|
|
1487
|
-
# reconfigure_daemon_engine hot-swap path AND persist across restarts via
|
|
1488
|
-
# SLMConfig.save(). Scoped deliberately to fields that save() round-trips:
|
|
1489
|
-
# retrieval (asdict) + injection (explicit). Excludes setup-time/internal
|
|
1490
|
-
# knobs and any field save() doesn't persist (which would silently revert).
|
|
1491
|
-
_RUNTIME_CONFIG_FIELDS = (
|
|
1492
|
-
# (section, field, kind, min, max)
|
|
1493
|
-
("retrieval", "top_k", "int", 1, 200),
|
|
1494
|
-
("retrieval", "use_cross_encoder", "bool", None, None),
|
|
1495
|
-
("injection", "enabled", "bool", None, None),
|
|
1496
|
-
("injection", "core_block_enabled", "bool", None, None),
|
|
1497
|
-
("injection", "core_block_max_facts", "int", 0, 50),
|
|
1498
|
-
)
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
def _runtime_config_snapshot(config) -> dict:
|
|
1502
|
-
"""Current values of the exposed runtime fields, grouped by section."""
|
|
1503
|
-
out: dict = {}
|
|
1504
|
-
for section, field, *_ in _RUNTIME_CONFIG_FIELDS:
|
|
1505
|
-
sec = getattr(config, section, None)
|
|
1506
|
-
out.setdefault(section, {})[field] = (
|
|
1507
|
-
getattr(sec, field, None) if sec is not None else None
|
|
1508
|
-
)
|
|
1509
|
-
return out
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
@router.get("/runtime/config")
|
|
1513
|
-
async def get_runtime_config(request: Request):
|
|
1514
|
-
"""User-facing runtime behaviour settings.
|
|
1515
|
-
|
|
1516
|
-
Recall depth (top_k), reranker on/off (use_cross_encoder), and memory
|
|
1517
|
-
injection (master + core-block). All apply live via the daemon hot-swap —
|
|
1518
|
-
no restart — and persist across restarts.
|
|
1519
|
-
"""
|
|
1520
|
-
try:
|
|
1521
|
-
from superlocalmemory.core.config import SLMConfig
|
|
1522
|
-
config = getattr(request.app.state, "config", None) or SLMConfig.load()
|
|
1523
|
-
return {"success": True, "config": _runtime_config_snapshot(config)}
|
|
1524
|
-
except Exception:
|
|
1525
|
-
return _internal_error()
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
@router.put("/runtime/config")
|
|
1529
|
-
async def set_runtime_config(request: Request):
|
|
1530
|
-
"""Validate, persist, and hot-apply runtime behaviour settings.
|
|
1531
|
-
|
|
1532
|
-
Body: ``{"retrieval": {"top_k": 30}, "injection": {"enabled": false}}``.
|
|
1533
|
-
Only known fields are accepted; a bad type/range is rejected 400 and
|
|
1534
|
-
nothing is applied (fail-fast — never let a bad value wedge the engine).
|
|
1535
|
-
"""
|
|
1536
|
-
_require_manage(request)
|
|
1537
|
-
try:
|
|
1538
|
-
import dataclasses as _dc
|
|
1539
|
-
from superlocalmemory.core.config import SLMConfig
|
|
1540
|
-
|
|
1541
|
-
body = await request.json()
|
|
1542
|
-
if not isinstance(body, dict):
|
|
1543
|
-
return JSONResponse({"error": "body must be an object"}, status_code=400)
|
|
1544
|
-
|
|
1545
|
-
# Validate everything BEFORE mutating anything.
|
|
1546
|
-
updates: dict[str, dict] = {}
|
|
1547
|
-
for section, field, kind, lo, hi in _RUNTIME_CONFIG_FIELDS:
|
|
1548
|
-
sec_in = body.get(section)
|
|
1549
|
-
if not isinstance(sec_in, dict) or field not in sec_in:
|
|
1550
|
-
continue
|
|
1551
|
-
val = sec_in[field]
|
|
1552
|
-
if kind == "bool":
|
|
1553
|
-
if not isinstance(val, bool):
|
|
1554
|
-
return JSONResponse(
|
|
1555
|
-
{"error": f"{section}.{field} must be true or false"},
|
|
1556
|
-
status_code=400,
|
|
1557
|
-
)
|
|
1558
|
-
elif kind == "int":
|
|
1559
|
-
# bool is an int subclass — reject it explicitly.
|
|
1560
|
-
if isinstance(val, bool) or not isinstance(val, int):
|
|
1561
|
-
return JSONResponse(
|
|
1562
|
-
{"error": f"{section}.{field} must be an integer"},
|
|
1563
|
-
status_code=400,
|
|
1564
|
-
)
|
|
1565
|
-
if (lo is not None and val < lo) or (hi is not None and val > hi):
|
|
1566
|
-
return JSONResponse(
|
|
1567
|
-
{"error": f"{section}.{field} must be between {lo} and {hi}"},
|
|
1568
|
-
status_code=400,
|
|
1569
|
-
)
|
|
1570
|
-
updates.setdefault(section, {})[field] = val
|
|
1571
|
-
|
|
1572
|
-
if not updates:
|
|
1573
|
-
return JSONResponse(
|
|
1574
|
-
{"error": "no known runtime settings in request body"},
|
|
1575
|
-
status_code=400,
|
|
1576
|
-
)
|
|
1577
|
-
|
|
1578
|
-
# These are plain behaviour flags read per-recall — NOT model/mode
|
|
1579
|
-
# swaps. So we apply them the light way: swap the sub-config objects on
|
|
1580
|
-
# the LIVE config the daemon+engine already hold, then persist to disk.
|
|
1581
|
-
# This avoids the heavyweight engine drain+rebuild (which reloads
|
|
1582
|
-
# models and can time out draining in-flight recalls) — the running
|
|
1583
|
-
# engine simply reads the new values on its next recall/injection.
|
|
1584
|
-
live = getattr(request.app.state, "config", None) or SLMConfig.load()
|
|
1585
|
-
targets = [live]
|
|
1586
|
-
engine = getattr(request.app.state, "engine", None)
|
|
1587
|
-
eng_cfg = getattr(engine, "_config", None) if engine is not None else None
|
|
1588
|
-
if eng_cfg is not None and eng_cfg is not live:
|
|
1589
|
-
targets.append(eng_cfg)
|
|
1590
|
-
for cfg in targets:
|
|
1591
|
-
for section, changes in updates.items():
|
|
1592
|
-
sec = getattr(cfg, section, None)
|
|
1593
|
-
if sec is None:
|
|
1594
|
-
continue
|
|
1595
|
-
# replace() yields a NEW sub-config (works frozen or mutable);
|
|
1596
|
-
# assigning the attribute is an atomic reference swap.
|
|
1597
|
-
setattr(cfg, section, _dc.replace(sec, **changes))
|
|
1598
|
-
request.app.state.config = live
|
|
1599
|
-
live.save(mode_change=False) # durable across restart
|
|
1600
|
-
return {"success": True, "config": _runtime_config_snapshot(live)}
|
|
1601
|
-
except Exception:
|
|
1602
|
-
return _internal_error()
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
# ── IDE Status ───────────────────────────────────────────────
|
|
1606
|
-
|
|
1607
|
-
@router.get("/ide/status")
|
|
1608
|
-
async def ide_status():
|
|
1609
|
-
"""Get IDE connection status."""
|
|
1610
|
-
try:
|
|
1611
|
-
from superlocalmemory.hooks.ide_connector import IDEConnector
|
|
1612
|
-
connector = IDEConnector()
|
|
1613
|
-
return {"ides": connector.get_status()}
|
|
1614
|
-
except Exception as exc:
|
|
1615
|
-
logger.exception("ide_status failed")
|
|
1616
|
-
return {"error": "internal error", "ides": []}
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
@router.post("/ide/connect")
|
|
1620
|
-
async def ide_connect(request: Request):
|
|
1621
|
-
"""Connect an IDE. Body: {"ide": "cursor"} or {} for all."""
|
|
1622
|
-
try:
|
|
1623
|
-
body = await request.json()
|
|
1624
|
-
ide = body.get("ide", "")
|
|
1625
|
-
|
|
1626
|
-
from superlocalmemory.hooks.ide_connector import IDEConnector
|
|
1627
|
-
connector = IDEConnector()
|
|
1628
|
-
|
|
1629
|
-
if ide:
|
|
1630
|
-
success = connector.connect(ide)
|
|
1631
|
-
return {"success": success, "ide": ide}
|
|
1632
|
-
else:
|
|
1633
|
-
results = connector.connect_all()
|
|
1634
|
-
return {"results": results}
|
|
1635
|
-
except Exception as e:
|
|
1636
|
-
return _internal_error()
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
# ── Active Memory (V3.1) ────────────────────────────────────
|
|
1640
|
-
|
|
1641
|
-
@router.get("/learning/signals")
|
|
1642
|
-
async def learning_signals():
|
|
1643
|
-
"""Get zero-cost learning signal statistics."""
|
|
1644
|
-
try:
|
|
1645
|
-
from superlocalmemory.learning.signals import LearningSignals
|
|
1646
|
-
from superlocalmemory.core.config import SLMConfig
|
|
1647
|
-
from superlocalmemory.server.routes.helpers import DB_PATH
|
|
1648
|
-
learning_db = DB_PATH.parent / "learning.db"
|
|
1649
|
-
signals = LearningSignals(learning_db)
|
|
1650
|
-
config = SLMConfig.load()
|
|
1651
|
-
pid = config.active_profile
|
|
1652
|
-
return {"success": True, **signals.get_signal_stats(pid)}
|
|
1653
|
-
except Exception as exc:
|
|
1654
|
-
logger.exception("learning_signals failed")
|
|
1655
|
-
return {"success": False, "error": "internal error"}
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
@router.post("/learning/consolidate")
|
|
1659
|
-
async def run_consolidation(request: Request):
|
|
1660
|
-
"""Run sleep-time consolidation. Body: {dry_run: true/false}."""
|
|
1661
|
-
try:
|
|
1662
|
-
body = await request.json()
|
|
1663
|
-
dry_run = body.get("dry_run", False)
|
|
1664
|
-
# Run the full cycle OUT OF PROCESS. Step 5 trains the LightGBM ranker
|
|
1665
|
-
# (online _run_shadow_cycle or legacy cold-start); importing lightgbm
|
|
1666
|
-
# in the torch-warm daemon loads a second libomp and SIGSEGVs it (see
|
|
1667
|
-
# lightgbm_subprocess module docstring). Isolation is the fix.
|
|
1668
|
-
from superlocalmemory.learning.lightgbm_subprocess import (
|
|
1669
|
-
run_consolidation_isolated,
|
|
1670
|
-
)
|
|
1671
|
-
from superlocalmemory.core.config import SLMConfig
|
|
1672
|
-
from superlocalmemory.server.routes.helpers import DB_PATH
|
|
1673
|
-
from fastapi.concurrency import run_in_threadpool
|
|
1674
|
-
|
|
1675
|
-
config = SLMConfig.load()
|
|
1676
|
-
learning_db = DB_PATH.parent / "learning.db"
|
|
1677
|
-
_require_manage_for_profile(request, config.active_profile)
|
|
1678
|
-
authorization = authorize_route_mutation(
|
|
1679
|
-
request,
|
|
1680
|
-
operation="update",
|
|
1681
|
-
source_agent_id="http-learning-consolidate",
|
|
1682
|
-
profile_id=config.active_profile,
|
|
1683
|
-
)
|
|
1684
|
-
result = await run_in_threadpool(
|
|
1685
|
-
run_consolidation_isolated,
|
|
1686
|
-
str(DB_PATH),
|
|
1687
|
-
str(learning_db),
|
|
1688
|
-
config.active_profile,
|
|
1689
|
-
dry_run=dry_run,
|
|
1690
|
-
)
|
|
1691
|
-
if result.get("error"):
|
|
1692
|
-
return {"success": False, "error": result["error"]}
|
|
1693
|
-
# Step-5 training may have promoted a new model — drop the daemon's
|
|
1694
|
-
# cached model so the next recall reloads it from learning.db.
|
|
1695
|
-
if not dry_run:
|
|
1696
|
-
try:
|
|
1697
|
-
from superlocalmemory.learning.model_cache import invalidate
|
|
1698
|
-
invalidate(config.active_profile)
|
|
1699
|
-
except Exception:
|
|
1700
|
-
pass
|
|
1701
|
-
stats = result.get("stats") or {}
|
|
1702
|
-
authorization.complete()
|
|
1703
|
-
return {"success": True, **stats}
|
|
1704
|
-
except HTTPException:
|
|
1705
|
-
raise
|
|
1706
|
-
except Exception as exc:
|
|
1707
|
-
logger.exception("run_consolidation failed")
|
|
1708
|
-
return {"success": False, "error": "internal error"}
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
@router.get("/hooks/status")
|
|
1712
|
-
async def hooks_status():
|
|
1713
|
-
"""Check if Claude Code hooks are installed."""
|
|
1714
|
-
try:
|
|
1715
|
-
from superlocalmemory.hooks.claude_code_hooks import check_status
|
|
1716
|
-
return {"success": True, **check_status()}
|
|
1717
|
-
except Exception as exc:
|
|
1718
|
-
logger.exception("hooks_status failed")
|
|
1719
|
-
return {"success": False, "error": "internal error"}
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
# ── Phase 6: V3.2 API Endpoints ──────────────────────────────
|
|
1723
|
-
# 9 new endpoints for the V3.2 dashboard tabs:
|
|
1724
|
-
# Auto-Invoke (2), Associations (2), Consolidation (2),
|
|
1725
|
-
# Core Memory (2), VectorStore (1)
|
|
1726
|
-
#
|
|
1727
|
-
# Rules enforced:
|
|
1728
|
-
# 01 - Profile scoping on ALL endpoints
|
|
1729
|
-
# 06 - No engine import from routes (direct sqlite3)
|
|
1730
|
-
# 11 - Parameterized SQL everywhere
|
|
1731
|
-
# 18 - WorkerPool for POST consolidation/trigger
|
|
1732
|
-
# 19 - Silent errors with JSONResponse
|
|
1733
|
-
# ──────────────────────────────────────────────────────────────
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
def _load_auto_invoke_json() -> dict:
|
|
1737
|
-
"""Load auto-invoke config from config.json's auto_invoke section."""
|
|
1738
|
-
from superlocalmemory.server.routes.helpers import MEMORY_DIR
|
|
1739
|
-
config_path = MEMORY_DIR / "config.json"
|
|
1740
|
-
if config_path.exists():
|
|
1741
|
-
try:
|
|
1742
|
-
data = json.loads(config_path.read_text())
|
|
1743
|
-
return data.get("auto_invoke", {})
|
|
1744
|
-
except (json.JSONDecodeError, IOError):
|
|
1745
|
-
pass
|
|
1746
|
-
return {}
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
def _save_auto_invoke_json(auto_invoke_data: dict) -> None:
|
|
1750
|
-
"""Persist auto-invoke config into config.json's auto_invoke section."""
|
|
1751
|
-
from superlocalmemory.server.routes.helpers import MEMORY_DIR
|
|
1752
|
-
config_path = MEMORY_DIR / "config.json"
|
|
1753
|
-
cfg: dict = {}
|
|
1754
|
-
if config_path.exists():
|
|
1755
|
-
try:
|
|
1756
|
-
cfg = json.loads(config_path.read_text())
|
|
1757
|
-
except (json.JSONDecodeError, IOError):
|
|
1758
|
-
pass
|
|
1759
|
-
cfg["auto_invoke"] = auto_invoke_data
|
|
1760
|
-
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1761
|
-
config_path.write_text(json.dumps(cfg, indent=2))
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
# ── 1. GET /api/v3/auto-invoke/config ─────────────────────────
|
|
1765
|
-
|
|
1766
|
-
@router.get("/auto-invoke/config")
|
|
1767
|
-
async def get_auto_invoke_config(request: Request):
|
|
1768
|
-
"""Get current auto-invoke configuration."""
|
|
1769
|
-
try:
|
|
1770
|
-
from superlocalmemory.core.config import AutoInvokeConfig
|
|
1771
|
-
defaults = AutoInvokeConfig()
|
|
1772
|
-
|
|
1773
|
-
persisted = _load_auto_invoke_json()
|
|
1774
|
-
|
|
1775
|
-
return {
|
|
1776
|
-
"enabled": persisted.get("enabled", defaults.enabled),
|
|
1777
|
-
"min_score": persisted.get("min_score", defaults.fok_threshold),
|
|
1778
|
-
"weights": persisted.get("weights", dict(defaults.weights)),
|
|
1779
|
-
"act_r_mode": persisted.get("act_r_mode", defaults.use_act_r),
|
|
1780
|
-
"invocation_count": persisted.get("invocation_count", 0),
|
|
1781
|
-
"last_invocation": persisted.get("last_invocation", None),
|
|
1782
|
-
}
|
|
1783
|
-
except Exception as e:
|
|
1784
|
-
return _internal_error()
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
# ── 2. PUT /api/v3/auto-invoke/config ─────────────────────────
|
|
1788
|
-
|
|
1789
|
-
@router.put("/auto-invoke/config")
|
|
1790
|
-
async def set_auto_invoke_config(request: Request):
|
|
1791
|
-
"""Update auto-invoke configuration.
|
|
1792
|
-
|
|
1793
|
-
Body: {"enabled": true, "min_score": 0.15, "weights": {...}}
|
|
1794
|
-
"""
|
|
1795
|
-
_require_manage(request)
|
|
1796
|
-
try:
|
|
1797
|
-
body = await request.json()
|
|
1798
|
-
|
|
1799
|
-
# Validate min_score range
|
|
1800
|
-
min_score = body.get("min_score")
|
|
1801
|
-
if min_score is not None and (min_score < 0 or min_score > 1):
|
|
1802
|
-
return JSONResponse(
|
|
1803
|
-
{"error": "min_score must be between 0 and 1"},
|
|
1804
|
-
status_code=400,
|
|
1805
|
-
)
|
|
1806
|
-
|
|
1807
|
-
# Load existing, merge updates
|
|
1808
|
-
from superlocalmemory.core.config import AutoInvokeConfig
|
|
1809
|
-
defaults = AutoInvokeConfig()
|
|
1810
|
-
persisted = _load_auto_invoke_json()
|
|
1811
|
-
|
|
1812
|
-
updated = {
|
|
1813
|
-
"enabled": body.get("enabled", persisted.get("enabled", defaults.enabled)),
|
|
1814
|
-
"min_score": body.get("min_score", persisted.get("min_score", defaults.fok_threshold)),
|
|
1815
|
-
"weights": body.get("weights", persisted.get("weights", dict(defaults.weights))),
|
|
1816
|
-
"act_r_mode": body.get("act_r_mode", persisted.get("act_r_mode", defaults.use_act_r)),
|
|
1817
|
-
"invocation_count": persisted.get("invocation_count", 0),
|
|
1818
|
-
"last_invocation": persisted.get("last_invocation", None),
|
|
1819
|
-
}
|
|
1820
|
-
_save_auto_invoke_json(updated)
|
|
1821
|
-
|
|
1822
|
-
return updated
|
|
1823
|
-
except Exception as e:
|
|
1824
|
-
return _internal_error()
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
# ── 3. GET /api/v3/associations ───────────────────────────────
|
|
1828
|
-
|
|
1829
|
-
@router.get("/associations")
|
|
1830
|
-
async def get_associations(
|
|
1831
|
-
request: Request,
|
|
1832
|
-
limit: int = 50,
|
|
1833
|
-
type: str = "",
|
|
1834
|
-
profile: str = "",
|
|
1835
|
-
):
|
|
1836
|
-
"""Get association edges for a profile with content previews."""
|
|
1837
|
-
pid = _resolve_profile(request, profile)
|
|
1838
|
-
try:
|
|
1839
|
-
from superlocalmemory.server.routes.helpers import get_active_profile, DB_PATH
|
|
1840
|
-
import sqlite3
|
|
1841
|
-
|
|
1842
|
-
if not DB_PATH.exists():
|
|
1843
|
-
return {"edges": [], "total": 0}
|
|
1844
|
-
|
|
1845
|
-
conn = get_read_connection(DB_PATH)
|
|
1846
|
-
conn.row_factory = sqlite3.Row
|
|
1847
|
-
|
|
1848
|
-
# Build query with optional type filter (parameterized)
|
|
1849
|
-
params: list = [pid]
|
|
1850
|
-
sql = (
|
|
1851
|
-
"SELECT ae.edge_id, ae.source_fact_id, ae.target_fact_id, "
|
|
1852
|
-
"ae.association_type, ae.weight, ae.co_access_count, ae.created_at, "
|
|
1853
|
-
"sf.content AS source_content, tf.content AS target_content "
|
|
1854
|
-
"FROM association_edges ae "
|
|
1855
|
-
"LEFT JOIN atomic_facts sf ON sf.fact_id = ae.source_fact_id "
|
|
1856
|
-
"LEFT JOIN atomic_facts tf ON tf.fact_id = ae.target_fact_id "
|
|
1857
|
-
"WHERE ae.profile_id = ? "
|
|
1858
|
-
)
|
|
1859
|
-
if type:
|
|
1860
|
-
sql += "AND ae.association_type = ? "
|
|
1861
|
-
params.append(type)
|
|
1862
|
-
sql += "ORDER BY ae.created_at DESC LIMIT ?"
|
|
1863
|
-
params.append(limit)
|
|
1864
|
-
|
|
1865
|
-
rows = conn.execute(sql, params).fetchall()
|
|
1866
|
-
|
|
1867
|
-
# Total count (separate query for pagination info)
|
|
1868
|
-
count_sql = "SELECT COUNT(*) FROM association_edges WHERE profile_id = ?"
|
|
1869
|
-
count_params: list = [pid]
|
|
1870
|
-
if type:
|
|
1871
|
-
count_sql += " AND association_type = ?"
|
|
1872
|
-
count_params.append(type)
|
|
1873
|
-
total = conn.execute(count_sql, count_params).fetchone()[0]
|
|
1874
|
-
|
|
1875
|
-
conn.close()
|
|
1876
|
-
|
|
1877
|
-
edges = []
|
|
1878
|
-
for r in rows:
|
|
1879
|
-
row = dict(r)
|
|
1880
|
-
source_content = row.get("source_content") or ""
|
|
1881
|
-
target_content = row.get("target_content") or ""
|
|
1882
|
-
edges.append({
|
|
1883
|
-
"edge_id": row["edge_id"],
|
|
1884
|
-
"source_fact_id": row["source_fact_id"],
|
|
1885
|
-
"target_fact_id": row["target_fact_id"],
|
|
1886
|
-
"association_type": row["association_type"],
|
|
1887
|
-
"weight": round(float(row["weight"]), 3),
|
|
1888
|
-
"co_access_count": row["co_access_count"],
|
|
1889
|
-
"created_at": row["created_at"],
|
|
1890
|
-
"source_preview": source_content[:100],
|
|
1891
|
-
"target_preview": target_content[:100],
|
|
1892
|
-
})
|
|
1893
|
-
|
|
1894
|
-
return {"edges": edges, "total": total}
|
|
1895
|
-
except Exception as e:
|
|
1896
|
-
return _internal_error()
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
# ── 4. GET /api/v3/associations/stats ─────────────────────────
|
|
1900
|
-
|
|
1901
|
-
@router.get("/associations/stats")
|
|
1902
|
-
async def get_association_stats(request: Request, profile: str = ""):
|
|
1903
|
-
"""Get association graph statistics."""
|
|
1904
|
-
pid = _resolve_profile(request, profile)
|
|
1905
|
-
try:
|
|
1906
|
-
from superlocalmemory.server.routes.helpers import get_active_profile, DB_PATH
|
|
1907
|
-
import sqlite3
|
|
1908
|
-
|
|
1909
|
-
if not DB_PATH.exists():
|
|
1910
|
-
return {
|
|
1911
|
-
"total_edges": 0,
|
|
1912
|
-
"by_type": {},
|
|
1913
|
-
"community_count": 0,
|
|
1914
|
-
"avg_weight": 0.0,
|
|
1915
|
-
"top_connected_facts": [],
|
|
1916
|
-
}
|
|
1917
|
-
|
|
1918
|
-
conn = get_read_connection(DB_PATH)
|
|
1919
|
-
conn.row_factory = sqlite3.Row
|
|
1920
|
-
|
|
1921
|
-
# Total edges
|
|
1922
|
-
total = conn.execute(
|
|
1923
|
-
"SELECT COUNT(*) FROM association_edges WHERE profile_id = ?",
|
|
1924
|
-
(pid,),
|
|
1925
|
-
).fetchone()[0]
|
|
1926
|
-
|
|
1927
|
-
# Edges by type
|
|
1928
|
-
by_type_rows = conn.execute(
|
|
1929
|
-
"SELECT association_type, COUNT(*) AS cnt "
|
|
1930
|
-
"FROM association_edges WHERE profile_id = ? "
|
|
1931
|
-
"GROUP BY association_type",
|
|
1932
|
-
(pid,),
|
|
1933
|
-
).fetchall()
|
|
1934
|
-
by_type = {row["association_type"]: row["cnt"] for row in by_type_rows}
|
|
1935
|
-
|
|
1936
|
-
# Average weight
|
|
1937
|
-
avg_row = conn.execute(
|
|
1938
|
-
"SELECT AVG(weight) AS avg_w FROM association_edges "
|
|
1939
|
-
"WHERE profile_id = ?",
|
|
1940
|
-
(pid,),
|
|
1941
|
-
).fetchone()
|
|
1942
|
-
avg_weight = round(float(avg_row["avg_w"] or 0), 3)
|
|
1943
|
-
|
|
1944
|
-
# Community count from fact_importance table
|
|
1945
|
-
community_count = 0
|
|
1946
|
-
try:
|
|
1947
|
-
cc_row = conn.execute(
|
|
1948
|
-
"SELECT COUNT(DISTINCT community_id) AS cnt "
|
|
1949
|
-
"FROM fact_importance "
|
|
1950
|
-
"WHERE profile_id = ? AND community_id IS NOT NULL",
|
|
1951
|
-
(pid,),
|
|
1952
|
-
).fetchone()
|
|
1953
|
-
community_count = cc_row["cnt"] if cc_row else 0
|
|
1954
|
-
except Exception:
|
|
1955
|
-
pass
|
|
1956
|
-
|
|
1957
|
-
# Top connected facts (by degree = count of edges as source or target)
|
|
1958
|
-
top_facts = []
|
|
1959
|
-
try:
|
|
1960
|
-
degree_rows = conn.execute(
|
|
1961
|
-
"SELECT fact_id, degree FROM ("
|
|
1962
|
-
" SELECT source_fact_id AS fact_id, COUNT(*) AS degree "
|
|
1963
|
-
" FROM association_edges WHERE profile_id = ? "
|
|
1964
|
-
" GROUP BY source_fact_id "
|
|
1965
|
-
" UNION ALL "
|
|
1966
|
-
" SELECT target_fact_id AS fact_id, COUNT(*) AS degree "
|
|
1967
|
-
" FROM association_edges WHERE profile_id = ? "
|
|
1968
|
-
" GROUP BY target_fact_id "
|
|
1969
|
-
") GROUP BY fact_id ORDER BY SUM(degree) DESC LIMIT 5",
|
|
1970
|
-
(pid, pid),
|
|
1971
|
-
).fetchall()
|
|
1972
|
-
for dr in degree_rows:
|
|
1973
|
-
fact_id = dr["fact_id"]
|
|
1974
|
-
preview_row = conn.execute(
|
|
1975
|
-
"SELECT content FROM atomic_facts WHERE fact_id = ?",
|
|
1976
|
-
(fact_id,),
|
|
1977
|
-
).fetchone()
|
|
1978
|
-
preview = (dict(preview_row).get("content", "")[:80]) if preview_row else ""
|
|
1979
|
-
top_facts.append({
|
|
1980
|
-
"fact_id": fact_id,
|
|
1981
|
-
"degree": dr["degree"],
|
|
1982
|
-
"preview": preview,
|
|
1983
|
-
})
|
|
1984
|
-
except Exception:
|
|
1985
|
-
pass
|
|
1986
|
-
|
|
1987
|
-
conn.close()
|
|
1988
|
-
|
|
1989
|
-
return {
|
|
1990
|
-
"total_edges": total,
|
|
1991
|
-
"by_type": by_type,
|
|
1992
|
-
"community_count": community_count,
|
|
1993
|
-
"avg_weight": avg_weight,
|
|
1994
|
-
"top_connected_facts": top_facts,
|
|
1995
|
-
}
|
|
1996
|
-
except Exception as e:
|
|
1997
|
-
return _internal_error()
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
# ── 5. GET /api/v3/consolidation/status ───────────────────────
|
|
2001
|
-
|
|
2002
|
-
@router.get("/consolidation/status")
|
|
2003
|
-
async def get_consolidation_status(request: Request, profile: str = ""):
|
|
2004
|
-
"""Get consolidation status and last run results."""
|
|
2005
|
-
pid = _resolve_profile(request, profile)
|
|
2006
|
-
try:
|
|
2007
|
-
from superlocalmemory.server.routes.helpers import get_active_profile, DB_PATH
|
|
2008
|
-
from superlocalmemory.core.config import SLMConfig
|
|
2009
|
-
import sqlite3
|
|
2010
|
-
|
|
2011
|
-
config = SLMConfig.load()
|
|
2012
|
-
cons_cfg = config.consolidation
|
|
2013
|
-
|
|
2014
|
-
result: dict = {
|
|
2015
|
-
"enabled": cons_cfg.enabled,
|
|
2016
|
-
"last_run": None,
|
|
2017
|
-
"last_result": None,
|
|
2018
|
-
"triggers": {
|
|
2019
|
-
"session_end": cons_cfg.session_trigger,
|
|
2020
|
-
"idle_timeout": cons_cfg.idle_timeout_seconds,
|
|
2021
|
-
"step_count": cons_cfg.step_count_trigger,
|
|
2022
|
-
"scheduled_sessions": cons_cfg.scheduled_sessions,
|
|
2023
|
-
},
|
|
2024
|
-
"store_count_since_last": 0,
|
|
2025
|
-
}
|
|
2026
|
-
|
|
2027
|
-
if not DB_PATH.exists():
|
|
2028
|
-
return result
|
|
2029
|
-
|
|
2030
|
-
conn = get_read_connection(DB_PATH)
|
|
2031
|
-
conn.row_factory = sqlite3.Row
|
|
2032
|
-
|
|
2033
|
-
# Last consolidation log entry
|
|
2034
|
-
try:
|
|
2035
|
-
last_row = conn.execute(
|
|
2036
|
-
"SELECT timestamp, action_type, reason "
|
|
2037
|
-
"FROM consolidation_log "
|
|
2038
|
-
"WHERE profile_id = ? ORDER BY timestamp DESC LIMIT 1",
|
|
2039
|
-
(pid,),
|
|
2040
|
-
).fetchone()
|
|
2041
|
-
if last_row:
|
|
2042
|
-
result["last_run"] = dict(last_row).get("timestamp")
|
|
2043
|
-
except Exception:
|
|
2044
|
-
pass
|
|
2045
|
-
|
|
2046
|
-
# Count blocks compiled (proxy for last consolidation result)
|
|
2047
|
-
try:
|
|
2048
|
-
block_count = conn.execute(
|
|
2049
|
-
"SELECT COUNT(*) FROM core_memory_blocks WHERE profile_id = ?",
|
|
2050
|
-
(pid,),
|
|
2051
|
-
).fetchone()[0]
|
|
2052
|
-
edge_count = conn.execute(
|
|
2053
|
-
"SELECT COUNT(*) FROM association_edges WHERE profile_id = ?",
|
|
2054
|
-
(pid,),
|
|
2055
|
-
).fetchone()[0]
|
|
2056
|
-
result["last_result"] = {
|
|
2057
|
-
"blocks_compiled": block_count,
|
|
2058
|
-
"total_edges": edge_count,
|
|
2059
|
-
}
|
|
2060
|
-
except Exception:
|
|
2061
|
-
pass
|
|
2062
|
-
|
|
2063
|
-
# Store count since last consolidation
|
|
2064
|
-
try:
|
|
2065
|
-
if result["last_run"]:
|
|
2066
|
-
sc = conn.execute(
|
|
2067
|
-
"SELECT COUNT(*) FROM atomic_facts "
|
|
2068
|
-
"WHERE profile_id = ? AND created_at > ?",
|
|
2069
|
-
(pid, result["last_run"]),
|
|
2070
|
-
).fetchone()[0]
|
|
2071
|
-
result["store_count_since_last"] = sc
|
|
2072
|
-
else:
|
|
2073
|
-
sc = conn.execute(
|
|
2074
|
-
"SELECT COUNT(*) FROM atomic_facts WHERE profile_id = ?",
|
|
2075
|
-
(pid,),
|
|
2076
|
-
).fetchone()[0]
|
|
2077
|
-
result["store_count_since_last"] = sc
|
|
2078
|
-
except Exception:
|
|
2079
|
-
pass
|
|
2080
|
-
|
|
2081
|
-
conn.close()
|
|
2082
|
-
return result
|
|
2083
|
-
except Exception as e:
|
|
2084
|
-
return _internal_error()
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
# ── 6. POST /api/v3/consolidation/trigger ─────────────────────
|
|
2088
|
-
|
|
2089
|
-
@router.post("/consolidation/trigger")
|
|
2090
|
-
async def trigger_consolidation(request: Request):
|
|
2091
|
-
"""Trigger consolidation manually.
|
|
2092
|
-
|
|
2093
|
-
Body: {"lightweight": false, "profile": ""}
|
|
2094
|
-
Runs under the daemon's profile-runtime operation lease (Rule 18) so a
|
|
2095
|
-
concurrent profile switch cannot commit mid-consolidation.
|
|
2096
|
-
"""
|
|
2097
|
-
try:
|
|
2098
|
-
body = await request.json()
|
|
2099
|
-
lightweight = body.get("lightweight", False)
|
|
2100
|
-
profile = body.get("profile", "")
|
|
2101
|
-
|
|
2102
|
-
from superlocalmemory.server.routes.helpers import get_active_profile
|
|
2103
|
-
pid = _resolve_mutation_profile(profile)
|
|
2104
|
-
_require_manage_for_profile(request, pid)
|
|
2105
|
-
authorization = authorize_route_mutation(
|
|
2106
|
-
request,
|
|
2107
|
-
operation="update",
|
|
2108
|
-
source_agent_id="http-consolidation-trigger",
|
|
2109
|
-
profile_id=pid,
|
|
2110
|
-
)
|
|
2111
|
-
|
|
2112
|
-
# 4.0.8: the body of this handler moved to server/consolidation_runner
|
|
2113
|
-
# so the periodic daemon trigger and this endpoint run the SAME code
|
|
2114
|
-
# under the SAME lock. Two copies would be two definitions of
|
|
2115
|
-
# "consolidated", and only one of them would get maintained.
|
|
2116
|
-
from superlocalmemory.server.consolidation_runner import (
|
|
2117
|
-
run_full_consolidation,
|
|
2118
|
-
)
|
|
2119
|
-
|
|
2120
|
-
# background=true returns as soon as the pass is scheduled. The
|
|
2121
|
-
# session-end hook needs this: a full consolidation runs for seconds to
|
|
2122
|
-
# minutes, and a hook that waits for it either blocks the user's shell
|
|
2123
|
-
# or times out and wrongly concludes the run failed.
|
|
2124
|
-
if body.get("background"):
|
|
2125
|
-
import asyncio as _asyncio
|
|
2126
|
-
|
|
2127
|
-
_app_state = request.app.state
|
|
2128
|
-
_asyncio.create_task(
|
|
2129
|
-
run_full_consolidation(
|
|
2130
|
-
_app_state, pid, lightweight=lightweight, trigger="hook",
|
|
2131
|
-
)
|
|
2132
|
-
)
|
|
2133
|
-
authorization.complete()
|
|
2134
|
-
return {"success": True, "started": True, "background": True}
|
|
2135
|
-
|
|
2136
|
-
result = await run_full_consolidation(
|
|
2137
|
-
request.app.state, pid, lightweight=lightweight, trigger="http",
|
|
2138
|
-
)
|
|
2139
|
-
authorization.complete()
|
|
2140
|
-
return {"success": True, **result}
|
|
2141
|
-
except HTTPException:
|
|
2142
|
-
raise
|
|
2143
|
-
except Exception as e:
|
|
2144
|
-
return _internal_error()
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
# ── 7. GET /api/v3/core-memory ────────────────────────────────
|
|
2148
|
-
|
|
2149
|
-
@router.get("/core-memory")
|
|
2150
|
-
async def get_core_memory(request: Request, profile: str = ""):
|
|
2151
|
-
"""Get all Core Memory blocks for a profile."""
|
|
2152
|
-
pid = _resolve_profile(request, profile)
|
|
2153
|
-
try:
|
|
2154
|
-
from superlocalmemory.server.routes.helpers import get_active_profile, DB_PATH
|
|
2155
|
-
import sqlite3
|
|
2156
|
-
|
|
2157
|
-
if not DB_PATH.exists():
|
|
2158
|
-
return {"blocks": [], "total_chars": 0, "char_limit": 2000}
|
|
2159
|
-
|
|
2160
|
-
conn = get_read_connection(DB_PATH)
|
|
2161
|
-
conn.row_factory = sqlite3.Row
|
|
2162
|
-
|
|
2163
|
-
rows = conn.execute(
|
|
2164
|
-
"SELECT block_id, block_type, content, char_count, version, "
|
|
2165
|
-
"compiled_by, updated_at FROM core_memory_blocks "
|
|
2166
|
-
"WHERE profile_id = ? ORDER BY block_type",
|
|
2167
|
-
(pid,),
|
|
2168
|
-
).fetchall()
|
|
2169
|
-
|
|
2170
|
-
conn.close()
|
|
2171
|
-
|
|
2172
|
-
blocks = []
|
|
2173
|
-
total_chars = 0
|
|
2174
|
-
for r in rows:
|
|
2175
|
-
row = dict(r)
|
|
2176
|
-
char_count = row.get("char_count", 0) or len(row.get("content", ""))
|
|
2177
|
-
total_chars += char_count
|
|
2178
|
-
blocks.append({
|
|
2179
|
-
"block_id": row["block_id"],
|
|
2180
|
-
"block_type": row["block_type"],
|
|
2181
|
-
"content": row["content"],
|
|
2182
|
-
"char_count": char_count,
|
|
2183
|
-
"version": row["version"],
|
|
2184
|
-
"compiled_by": row["compiled_by"],
|
|
2185
|
-
"updated_at": row["updated_at"],
|
|
2186
|
-
})
|
|
2187
|
-
|
|
2188
|
-
return {"blocks": blocks, "total_chars": total_chars, "char_limit": 2000}
|
|
2189
|
-
except Exception as e:
|
|
2190
|
-
return _internal_error()
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
# ── 8. PUT /api/v3/core-memory/{block_id} ─────────────────────
|
|
2194
|
-
|
|
2195
|
-
@router.put("/core-memory/{block_id}")
|
|
2196
|
-
async def update_core_memory_block(block_id: str, request: Request):
|
|
2197
|
-
"""Update a Core Memory block's content manually.
|
|
2198
|
-
|
|
2199
|
-
Body: {"content": "Updated content..."}
|
|
2200
|
-
"""
|
|
2201
|
-
try:
|
|
2202
|
-
body = await request.json()
|
|
2203
|
-
content = body.get("content")
|
|
2204
|
-
if content is None:
|
|
2205
|
-
return JSONResponse(
|
|
2206
|
-
{"error": "content field is required"},
|
|
2207
|
-
status_code=400,
|
|
2208
|
-
)
|
|
2209
|
-
|
|
2210
|
-
from superlocalmemory.server.routes.helpers import DB_PATH, get_active_profile
|
|
2211
|
-
from superlocalmemory.storage.memory_write import memory_write
|
|
2212
|
-
from datetime import datetime, timezone
|
|
2213
|
-
|
|
2214
|
-
if not DB_PATH.exists():
|
|
2215
|
-
return JSONResponse(
|
|
2216
|
-
{"error": "Database not found"},
|
|
2217
|
-
status_code=404,
|
|
2218
|
-
)
|
|
2219
|
-
|
|
2220
|
-
pid = get_active_profile()
|
|
2221
|
-
authorization = authorize_route_mutation(
|
|
2222
|
-
request,
|
|
2223
|
-
operation="update",
|
|
2224
|
-
source_agent_id="http-core-memory-update",
|
|
2225
|
-
profile_id=pid,
|
|
2226
|
-
fact_id=block_id,
|
|
2227
|
-
content_preview=str(content),
|
|
2228
|
-
)
|
|
2229
|
-
|
|
2230
|
-
now = datetime.now(timezone.utc).isoformat()
|
|
2231
|
-
# memory_write: process write lock + busy_timeout.
|
|
2232
|
-
# SELECT + UPDATE + read-back are atomic inside the same connection.
|
|
2233
|
-
with memory_write(DB_PATH) as conn:
|
|
2234
|
-
# Verify block exists
|
|
2235
|
-
existing = conn.execute(
|
|
2236
|
-
"SELECT block_id, profile_id, block_type, version "
|
|
2237
|
-
"FROM core_memory_blocks WHERE block_id = ? AND profile_id = ?",
|
|
2238
|
-
(block_id, pid),
|
|
2239
|
-
).fetchone()
|
|
2240
|
-
|
|
2241
|
-
if not existing:
|
|
2242
|
-
raise HTTPException(status_code=404, detail=f"Block {block_id} not found")
|
|
2243
|
-
|
|
2244
|
-
new_version = dict(existing)["version"] + 1
|
|
2245
|
-
conn.execute(
|
|
2246
|
-
"UPDATE core_memory_blocks SET content = ?, char_count = ?, "
|
|
2247
|
-
"version = ?, compiled_by = 'manual', updated_at = ? "
|
|
2248
|
-
"WHERE block_id = ? AND profile_id = ?",
|
|
2249
|
-
(content, len(content), new_version, now, block_id, pid),
|
|
2250
|
-
)
|
|
2251
|
-
# Read back updated block while connection is still open.
|
|
2252
|
-
updated = conn.execute(
|
|
2253
|
-
"SELECT block_id, block_type, content, char_count, version, "
|
|
2254
|
-
"compiled_by, updated_at FROM core_memory_blocks "
|
|
2255
|
-
"WHERE block_id = ? AND profile_id = ?",
|
|
2256
|
-
(block_id, pid),
|
|
2257
|
-
).fetchone()
|
|
2258
|
-
updated_dict = dict(updated) if updated else {"block_id": block_id, "updated": True}
|
|
2259
|
-
|
|
2260
|
-
authorization.complete()
|
|
2261
|
-
return updated_dict
|
|
2262
|
-
except HTTPException:
|
|
2263
|
-
raise
|
|
2264
|
-
except Exception as e:
|
|
2265
|
-
return _internal_error()
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
# ── 9. GET /api/v3/vector-store/status ────────────────────────
|
|
2269
|
-
|
|
2270
|
-
@router.get("/vector-store/status")
|
|
2271
|
-
async def get_vector_store_status(request: Request, profile: str = ""):
|
|
2272
|
-
"""Get VectorStore health and statistics."""
|
|
2273
|
-
pid = _resolve_profile(request, profile)
|
|
2274
|
-
try:
|
|
2275
|
-
from superlocalmemory.core.config import SLMConfig
|
|
2276
|
-
from superlocalmemory.server.routes.helpers import DB_PATH
|
|
2277
|
-
import sqlite3
|
|
2278
|
-
|
|
2279
|
-
config = SLMConfig.load()
|
|
2280
|
-
|
|
2281
|
-
result: dict = {
|
|
2282
|
-
"available": False,
|
|
2283
|
-
"provider": "sqlite-vec",
|
|
2284
|
-
"dimension": config.embedding.dimension,
|
|
2285
|
-
"embedding_model": config.embedding.model_name,
|
|
2286
|
-
"total_vectors": 0,
|
|
2287
|
-
"binary_quantization": False,
|
|
2288
|
-
"binary_quantization_threshold": 100000,
|
|
2289
|
-
"fallback_to_ann": False,
|
|
2290
|
-
}
|
|
2291
|
-
|
|
2292
|
-
# Check if sqlite-vec extension is available
|
|
2293
|
-
try:
|
|
2294
|
-
import sqlite_vec # noqa: F401
|
|
2295
|
-
result["available"] = True
|
|
2296
|
-
except ImportError:
|
|
2297
|
-
result["fallback_to_ann"] = True
|
|
2298
|
-
|
|
2299
|
-
# Count vectors in embedding_metadata
|
|
2300
|
-
if DB_PATH.exists():
|
|
2301
|
-
try:
|
|
2302
|
-
conn = get_read_connection(DB_PATH)
|
|
2303
|
-
count = conn.execute(
|
|
2304
|
-
"SELECT COUNT(*) FROM embedding_metadata WHERE profile_id = ?",
|
|
2305
|
-
(pid,),
|
|
2306
|
-
).fetchone()[0]
|
|
2307
|
-
result["total_vectors"] = count
|
|
2308
|
-
conn.close()
|
|
2309
|
-
except Exception:
|
|
2310
|
-
pass
|
|
2311
|
-
|
|
2312
|
-
return result
|
|
2313
|
-
except Exception as e:
|
|
2314
|
-
return _internal_error()
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
# ── Phase 10: V3.3 API Endpoints ────────────────────────────
|
|
2318
|
-
# 7 new endpoints for the V3.3 dashboard:
|
|
2319
|
-
# Forgetting (2), Quantization (1), CCQ (1),
|
|
2320
|
-
# Soft Prompts (1), Process Health (1), V3.3 Overview (1)
|
|
2321
|
-
#
|
|
2322
|
-
# Rules enforced:
|
|
2323
|
-
# 01 - Profile scoping on ALL endpoints
|
|
2324
|
-
# 06 - No engine import from routes (direct sqlite3)
|
|
2325
|
-
# 11 - Parameterized SQL everywhere
|
|
2326
|
-
# 19 - Silent errors with JSONResponse
|
|
2327
|
-
# ──────────────────────────────────────────────────────────────
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
# ── 1a. GET /api/v3/forgetting/stats ────────────────────────
|
|
2331
|
-
|
|
2332
|
-
@router.get("/forgetting/stats")
|
|
2333
|
-
async def forgetting_stats(request: Request, profile: str = ""):
|
|
2334
|
-
"""Get memory retention zone distribution."""
|
|
2335
|
-
pid = _resolve_profile(request, profile)
|
|
2336
|
-
try:
|
|
2337
|
-
from superlocalmemory.server.routes.helpers import get_active_profile, DB_PATH
|
|
2338
|
-
import sqlite3 as _sqlite3
|
|
2339
|
-
|
|
2340
|
-
zones = {"active": 0, "warm": 0, "cold": 0, "archive": 0, "forgotten": 0}
|
|
2341
|
-
total = 0
|
|
2342
|
-
|
|
2343
|
-
if not DB_PATH.exists():
|
|
2344
|
-
return {"total": total, "zones": zones}
|
|
2345
|
-
|
|
2346
|
-
conn = get_read_connection(DB_PATH)
|
|
2347
|
-
conn.row_factory = _sqlite3.Row
|
|
2348
|
-
|
|
2349
|
-
try:
|
|
2350
|
-
rows = conn.execute(
|
|
2351
|
-
"SELECT lifecycle_zone, COUNT(*) AS cnt "
|
|
2352
|
-
"FROM fact_retention WHERE profile_id = ? "
|
|
2353
|
-
"GROUP BY lifecycle_zone",
|
|
2354
|
-
(pid,),
|
|
2355
|
-
).fetchall()
|
|
2356
|
-
for row in rows:
|
|
2357
|
-
zone = dict(row)["lifecycle_zone"]
|
|
2358
|
-
cnt = dict(row)["cnt"]
|
|
2359
|
-
if zone in zones:
|
|
2360
|
-
zones[zone] = cnt
|
|
2361
|
-
total += cnt
|
|
2362
|
-
except Exception:
|
|
2363
|
-
# Table may not exist in older DBs -- graceful fallback
|
|
2364
|
-
pass
|
|
2365
|
-
|
|
2366
|
-
conn.close()
|
|
2367
|
-
return {"total": total, "zones": zones}
|
|
2368
|
-
except Exception as e:
|
|
2369
|
-
return _internal_error()
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
# ── 1b. POST /api/v3/forgetting/run ─────────────────────────
|
|
2373
|
-
|
|
2374
|
-
@router.post("/forgetting/run")
|
|
2375
|
-
async def run_forgetting(request: Request):
|
|
2376
|
-
"""Trigger a forgetting decay cycle.
|
|
2377
|
-
|
|
2378
|
-
Body: {"profile": ""} (optional profile override).
|
|
2379
|
-
"""
|
|
2380
|
-
try:
|
|
2381
|
-
body = await request.json()
|
|
2382
|
-
profile = body.get("profile", "")
|
|
2383
|
-
|
|
2384
|
-
from superlocalmemory.server.routes.helpers import get_active_profile, DB_PATH
|
|
2385
|
-
from superlocalmemory.storage.memory_write import memory_write as _memory_write
|
|
2386
|
-
pid = _resolve_mutation_profile(profile)
|
|
2387
|
-
_require_manage_for_profile(request, pid)
|
|
2388
|
-
|
|
2389
|
-
if not DB_PATH.exists():
|
|
2390
|
-
return {"success": False, "error": "Database not found"}
|
|
2391
|
-
|
|
2392
|
-
authorization = authorize_route_mutation(
|
|
2393
|
-
request,
|
|
2394
|
-
operation="update",
|
|
2395
|
-
source_agent_id="http-forgetting-run",
|
|
2396
|
-
profile_id=pid,
|
|
2397
|
-
)
|
|
2398
|
-
|
|
2399
|
-
# memory_write: process write lock + busy_timeout — all UPDATEs atomic.
|
|
2400
|
-
updated = 0
|
|
2401
|
-
try:
|
|
2402
|
-
with _memory_write(DB_PATH) as conn:
|
|
2403
|
-
# Apply Ebbinghaus decay: reduce retention for facts not accessed recently
|
|
2404
|
-
# Formula: retention *= exp(-0.1) for each cycle (simplified batch decay)
|
|
2405
|
-
conn.execute(
|
|
2406
|
-
"UPDATE fact_retention "
|
|
2407
|
-
"SET retention_score = MAX(0.0, retention_score * 0.9), "
|
|
2408
|
-
" last_computed_at = datetime('now') "
|
|
2409
|
-
"WHERE profile_id = ? "
|
|
2410
|
-
"AND lifecycle_zone NOT IN ('archive', 'forgotten')",
|
|
2411
|
-
(pid,),
|
|
2412
|
-
)
|
|
2413
|
-
updated = conn.total_changes
|
|
2414
|
-
|
|
2415
|
-
# Transition zones based on new retention scores
|
|
2416
|
-
zone_thresholds = [
|
|
2417
|
-
("forgotten", 0.05),
|
|
2418
|
-
("archive", 0.15),
|
|
2419
|
-
("cold", 0.35),
|
|
2420
|
-
("warm", 0.65),
|
|
2421
|
-
]
|
|
2422
|
-
for zone, threshold in zone_thresholds:
|
|
2423
|
-
conn.execute(
|
|
2424
|
-
"UPDATE fact_retention "
|
|
2425
|
-
"SET lifecycle_zone = ? "
|
|
2426
|
-
"WHERE profile_id = ? "
|
|
2427
|
-
"AND retention_score < ? "
|
|
2428
|
-
"AND lifecycle_zone NOT IN ('archive', 'forgotten')",
|
|
2429
|
-
(zone, pid, threshold),
|
|
2430
|
-
)
|
|
2431
|
-
|
|
2432
|
-
# Ensure high-retention facts are active
|
|
2433
|
-
conn.execute(
|
|
2434
|
-
"UPDATE fact_retention "
|
|
2435
|
-
"SET lifecycle_zone = 'active' "
|
|
2436
|
-
"WHERE profile_id = ? AND retention_score >= 0.65 "
|
|
2437
|
-
"AND lifecycle_zone NOT IN ('archive', 'forgotten')",
|
|
2438
|
-
(pid,),
|
|
2439
|
-
)
|
|
2440
|
-
|
|
2441
|
-
from superlocalmemory.core.lifecycle_state import reconcile_profile_lifecycle
|
|
2442
|
-
reconcile_profile_lifecycle(conn, pid)
|
|
2443
|
-
except Exception as exc:
|
|
2444
|
-
logger.exception("run_forgetting decay failed")
|
|
2445
|
-
return {"success": False, "error": "internal error"}
|
|
2446
|
-
|
|
2447
|
-
authorization.complete()
|
|
2448
|
-
return {"success": True, "facts_decayed": updated, "profile": pid}
|
|
2449
|
-
except HTTPException:
|
|
2450
|
-
raise
|
|
2451
|
-
except Exception as e:
|
|
2452
|
-
return _internal_error()
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
# ── 1c. GET /api/v3/quantization/stats ──────────────────────
|
|
2456
|
-
|
|
2457
|
-
@router.get("/quantization/stats")
|
|
2458
|
-
async def quantization_stats(request: Request, profile: str = ""):
|
|
2459
|
-
"""Get embedding quantization tier distribution."""
|
|
2460
|
-
pid = _resolve_profile(request, profile)
|
|
2461
|
-
try:
|
|
2462
|
-
from superlocalmemory.server.routes.helpers import get_active_profile, DB_PATH
|
|
2463
|
-
import sqlite3 as _sqlite3
|
|
2464
|
-
|
|
2465
|
-
tiers = {"float32": 0, "int8": 0, "polar4": 0, "polar2": 0}
|
|
2466
|
-
total = 0
|
|
2467
|
-
compression_ratio = 1.0
|
|
2468
|
-
|
|
2469
|
-
if not DB_PATH.exists():
|
|
2470
|
-
return {"total": total, "tiers": tiers, "compression_ratio": compression_ratio}
|
|
2471
|
-
|
|
2472
|
-
conn = get_read_connection(DB_PATH)
|
|
2473
|
-
conn.row_factory = _sqlite3.Row
|
|
2474
|
-
|
|
2475
|
-
try:
|
|
2476
|
-
rows = conn.execute(
|
|
2477
|
-
"SELECT quantization_level, COUNT(*) AS cnt "
|
|
2478
|
-
"FROM embedding_quantization_metadata "
|
|
2479
|
-
"WHERE profile_id = ? "
|
|
2480
|
-
"GROUP BY quantization_level",
|
|
2481
|
-
(pid,),
|
|
2482
|
-
).fetchall()
|
|
2483
|
-
for row in rows:
|
|
2484
|
-
level = dict(row)["quantization_level"]
|
|
2485
|
-
cnt = dict(row)["cnt"]
|
|
2486
|
-
if level in tiers:
|
|
2487
|
-
tiers[level] = cnt
|
|
2488
|
-
total += cnt
|
|
2489
|
-
except Exception:
|
|
2490
|
-
pass
|
|
2491
|
-
|
|
2492
|
-
# Compute compression ratio from actual sizes if available
|
|
2493
|
-
try:
|
|
2494
|
-
size_row = conn.execute(
|
|
2495
|
-
"SELECT "
|
|
2496
|
-
"SUM(CASE WHEN bit_width = 32 THEN 768 * 4 ELSE "
|
|
2497
|
-
" COALESCE(compressed_size_bytes, 768 * bit_width / 8) END) AS actual, "
|
|
2498
|
-
"SUM(768 * 4) AS uncompressed "
|
|
2499
|
-
"FROM embedding_quantization_metadata "
|
|
2500
|
-
"WHERE profile_id = ?",
|
|
2501
|
-
(pid,),
|
|
2502
|
-
).fetchone()
|
|
2503
|
-
if size_row:
|
|
2504
|
-
d = dict(size_row)
|
|
2505
|
-
uncompressed = d.get("uncompressed") or 0
|
|
2506
|
-
actual = d.get("actual") or 0
|
|
2507
|
-
if actual > 0 and uncompressed > 0:
|
|
2508
|
-
compression_ratio = round(uncompressed / actual, 2)
|
|
2509
|
-
except Exception:
|
|
2510
|
-
pass
|
|
2511
|
-
|
|
2512
|
-
conn.close()
|
|
2513
|
-
return {"total": total, "tiers": tiers, "compression_ratio": compression_ratio}
|
|
2514
|
-
except Exception as e:
|
|
2515
|
-
return _internal_error()
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
# ── 1d. GET /api/v3/ccq/blocks ──────────────────────────────
|
|
2519
|
-
|
|
2520
|
-
@router.get("/ccq/blocks")
|
|
2521
|
-
async def ccq_blocks(request: Request, profile: str = "", limit: int = 50):
|
|
2522
|
-
"""Get CCQ consolidated blocks."""
|
|
2523
|
-
pid = _resolve_profile(request, profile)
|
|
2524
|
-
try:
|
|
2525
|
-
from superlocalmemory.server.routes.helpers import get_active_profile, DB_PATH
|
|
2526
|
-
import sqlite3 as _sqlite3
|
|
2527
|
-
|
|
2528
|
-
if not DB_PATH.exists():
|
|
2529
|
-
return {"blocks": [], "total": 0}
|
|
2530
|
-
|
|
2531
|
-
conn = get_read_connection(DB_PATH)
|
|
2532
|
-
conn.row_factory = _sqlite3.Row
|
|
2533
|
-
|
|
2534
|
-
blocks = []
|
|
2535
|
-
total = 0
|
|
2536
|
-
try:
|
|
2537
|
-
rows = conn.execute(
|
|
2538
|
-
"SELECT block_id, content, source_fact_ids, char_count, "
|
|
2539
|
-
"compiled_by, cluster_id, created_at "
|
|
2540
|
-
"FROM ccq_consolidated_blocks "
|
|
2541
|
-
"WHERE profile_id = ? "
|
|
2542
|
-
"ORDER BY created_at DESC LIMIT ?",
|
|
2543
|
-
(pid, limit),
|
|
2544
|
-
).fetchall()
|
|
2545
|
-
|
|
2546
|
-
for row in rows:
|
|
2547
|
-
d = dict(row)
|
|
2548
|
-
source_ids = []
|
|
2549
|
-
try:
|
|
2550
|
-
source_ids = json.loads(d.get("source_fact_ids", "[]"))
|
|
2551
|
-
except (json.JSONDecodeError, TypeError):
|
|
2552
|
-
pass
|
|
2553
|
-
blocks.append({
|
|
2554
|
-
"block_id": d["block_id"],
|
|
2555
|
-
"content": d["content"],
|
|
2556
|
-
"source_fact_count": len(source_ids),
|
|
2557
|
-
"char_count": d["char_count"],
|
|
2558
|
-
"compiled_by": d["compiled_by"],
|
|
2559
|
-
"cluster_id": d["cluster_id"],
|
|
2560
|
-
"created_at": d["created_at"],
|
|
2561
|
-
})
|
|
2562
|
-
|
|
2563
|
-
count_row = conn.execute(
|
|
2564
|
-
"SELECT COUNT(*) FROM ccq_consolidated_blocks "
|
|
2565
|
-
"WHERE profile_id = ?",
|
|
2566
|
-
(pid,),
|
|
2567
|
-
).fetchone()
|
|
2568
|
-
total = count_row[0] if count_row else 0
|
|
2569
|
-
except Exception:
|
|
2570
|
-
pass
|
|
2571
|
-
|
|
2572
|
-
conn.close()
|
|
2573
|
-
return {"blocks": blocks, "total": total}
|
|
2574
|
-
except Exception as e:
|
|
2575
|
-
return _internal_error()
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
# ── 1e. GET /api/v3/soft-prompts ─────────────────────────────
|
|
2579
|
-
|
|
2580
|
-
@router.get("/soft-prompts")
|
|
2581
|
-
async def get_soft_prompts(request: Request, profile: str = ""):
|
|
2582
|
-
"""Get active soft prompt templates."""
|
|
2583
|
-
pid = _resolve_profile(request, profile)
|
|
2584
|
-
try:
|
|
2585
|
-
from superlocalmemory.server.routes.helpers import get_active_profile, DB_PATH
|
|
2586
|
-
import sqlite3 as _sqlite3
|
|
2587
|
-
|
|
2588
|
-
if not DB_PATH.exists():
|
|
2589
|
-
return {"prompts": [], "total": 0, "total_tokens": 0}
|
|
2590
|
-
|
|
2591
|
-
conn = get_read_connection(DB_PATH)
|
|
2592
|
-
conn.row_factory = _sqlite3.Row
|
|
2593
|
-
|
|
2594
|
-
prompts = []
|
|
2595
|
-
total_tokens = 0
|
|
2596
|
-
try:
|
|
2597
|
-
rows = conn.execute(
|
|
2598
|
-
"SELECT prompt_id, category, content, confidence, "
|
|
2599
|
-
"effectiveness, token_count, retention_score, "
|
|
2600
|
-
"active, version, created_at, updated_at "
|
|
2601
|
-
"FROM soft_prompt_templates "
|
|
2602
|
-
"WHERE profile_id = ? AND active = 1 "
|
|
2603
|
-
"ORDER BY confidence DESC",
|
|
2604
|
-
(pid,),
|
|
2605
|
-
).fetchall()
|
|
2606
|
-
|
|
2607
|
-
for row in rows:
|
|
2608
|
-
d = dict(row)
|
|
2609
|
-
tokens = d.get("token_count", 0)
|
|
2610
|
-
total_tokens += tokens
|
|
2611
|
-
prompts.append({
|
|
2612
|
-
"prompt_id": d["prompt_id"],
|
|
2613
|
-
"category": d["category"],
|
|
2614
|
-
"content": d["content"][:200],
|
|
2615
|
-
"confidence": round(float(d["confidence"]), 3),
|
|
2616
|
-
"effectiveness": round(float(d.get("effectiveness", 0.5)), 3),
|
|
2617
|
-
"token_count": tokens,
|
|
2618
|
-
"retention_score": round(float(d.get("retention_score", 1.0)), 3),
|
|
2619
|
-
"version": d["version"],
|
|
2620
|
-
"created_at": d["created_at"],
|
|
2621
|
-
})
|
|
2622
|
-
except Exception:
|
|
2623
|
-
pass
|
|
2624
|
-
|
|
2625
|
-
conn.close()
|
|
2626
|
-
return {"prompts": prompts, "total": len(prompts), "total_tokens": total_tokens}
|
|
2627
|
-
except Exception as e:
|
|
2628
|
-
return _internal_error()
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
# ── 1f. GET /api/v3/health/processes ─────────────────────────
|
|
2632
|
-
|
|
2633
|
-
@router.get("/health/processes")
|
|
2634
|
-
async def process_health(request: Request):
|
|
2635
|
-
"""Get SLM process health status."""
|
|
2636
|
-
_resolve_profile(request)
|
|
2637
|
-
try:
|
|
2638
|
-
import os as _os
|
|
2639
|
-
|
|
2640
|
-
processes = {
|
|
2641
|
-
"mcp_server": {"pid": _os.getpid(), "status": "running"},
|
|
2642
|
-
"parent": {"pid": _os.getppid(), "status": "unknown"},
|
|
2643
|
-
}
|
|
2644
|
-
|
|
2645
|
-
# Check parent process
|
|
2646
|
-
try:
|
|
2647
|
-
_os.kill(_os.getppid(), 0)
|
|
2648
|
-
processes["parent"]["status"] = "running"
|
|
2649
|
-
except ProcessLookupError:
|
|
2650
|
-
processes["parent"]["status"] = "dead"
|
|
2651
|
-
except PermissionError:
|
|
2652
|
-
processes["parent"]["status"] = "running"
|
|
2653
|
-
except OSError:
|
|
2654
|
-
processes["parent"]["status"] = "unknown"
|
|
2655
|
-
|
|
2656
|
-
# Check worker pool status
|
|
2657
|
-
worker_status = "unavailable"
|
|
2658
|
-
try:
|
|
2659
|
-
from superlocalmemory.core.worker_pool import WorkerPool
|
|
2660
|
-
pool = WorkerPool.shared()
|
|
2661
|
-
worker_status = "running" if pool else "stopped"
|
|
2662
|
-
except Exception:
|
|
2663
|
-
pass
|
|
2664
|
-
processes["worker_pool"] = {"status": worker_status}
|
|
2665
|
-
|
|
2666
|
-
# Memory usage of current process (approximate)
|
|
2667
|
-
from superlocalmemory.core.platform_utils import get_rss_mb
|
|
2668
|
-
memory_mb = round(get_rss_mb(), 1)
|
|
2669
|
-
|
|
2670
|
-
return {
|
|
2671
|
-
"processes": processes,
|
|
2672
|
-
"memory_mb": memory_mb,
|
|
2673
|
-
"healthy": processes["parent"]["status"] != "dead",
|
|
2674
|
-
}
|
|
2675
|
-
except Exception as e:
|
|
2676
|
-
return _internal_error()
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
# ── 1g. GET /api/v3/v33/overview ─────────────────────────────
|
|
2680
|
-
|
|
2681
|
-
# ── v3.4.1: Graph Communities ──────────────────────────────────
|
|
2682
|
-
|
|
2683
|
-
@router.get("/graph/communities")
|
|
2684
|
-
async def get_graph_communities(request: Request, profile: str = ""):
|
|
2685
|
-
"""Get community assignments with TF-IDF labels, entities, and colors.
|
|
2686
|
-
|
|
2687
|
-
v3.4.1: Uses TF-IDF labels from config table (computed by GraphAnalyzer
|
|
2688
|
-
at consolidation time). Falls back to inline word frequency if labels
|
|
2689
|
-
not yet computed.
|
|
2690
|
-
"""
|
|
2691
|
-
pid = _resolve_profile(request, profile)
|
|
2692
|
-
try:
|
|
2693
|
-
from superlocalmemory.server.routes.helpers import get_active_profile, DB_PATH
|
|
2694
|
-
import sqlite3
|
|
2695
|
-
|
|
2696
|
-
if not DB_PATH.exists():
|
|
2697
|
-
return {"communities": [], "total": 0}
|
|
2698
|
-
|
|
2699
|
-
conn = get_read_connection(DB_PATH)
|
|
2700
|
-
conn.row_factory = sqlite3.Row
|
|
2701
|
-
|
|
2702
|
-
# Get community member counts and average pagerank
|
|
2703
|
-
comm_rows = conn.execute(
|
|
2704
|
-
"SELECT community_id, COUNT(*) AS member_count, "
|
|
2705
|
-
"AVG(pagerank_score) AS pagerank_avg "
|
|
2706
|
-
"FROM fact_importance "
|
|
2707
|
-
"WHERE profile_id = ? AND community_id IS NOT NULL "
|
|
2708
|
-
"GROUP BY community_id ORDER BY member_count DESC",
|
|
2709
|
-
(pid,),
|
|
2710
|
-
).fetchall()
|
|
2711
|
-
|
|
2712
|
-
if not comm_rows:
|
|
2713
|
-
conn.close()
|
|
2714
|
-
return {"communities": [], "total": 0}
|
|
2715
|
-
|
|
2716
|
-
# Load TF-IDF labels from config table
|
|
2717
|
-
tfidf_labels = {}
|
|
2718
|
-
try:
|
|
2719
|
-
label_key = "community_labels_" + pid
|
|
2720
|
-
label_row = conn.execute(
|
|
2721
|
-
"SELECT value FROM config WHERE key = ?",
|
|
2722
|
-
(label_key,),
|
|
2723
|
-
).fetchone()
|
|
2724
|
-
if label_row:
|
|
2725
|
-
import json as _json
|
|
2726
|
-
tfidf_labels = _json.loads(dict(label_row)["value"])
|
|
2727
|
-
except Exception:
|
|
2728
|
-
pass
|
|
2729
|
-
|
|
2730
|
-
cluster_colors = [
|
|
2731
|
-
'#667eea', '#764ba2', '#43e97b', '#38f9d7',
|
|
2732
|
-
'#4facfe', '#00f2fe', '#f093fb', '#f5576c',
|
|
2733
|
-
'#fa709a', '#fee140', '#30cfd0', '#330867',
|
|
2734
|
-
]
|
|
2735
|
-
|
|
2736
|
-
communities = []
|
|
2737
|
-
for row in comm_rows:
|
|
2738
|
-
d = dict(row)
|
|
2739
|
-
comm_id = d["community_id"]
|
|
2740
|
-
|
|
2741
|
-
# Use TF-IDF label if available, else generate inline
|
|
2742
|
-
label = tfidf_labels.get(str(comm_id), "")
|
|
2743
|
-
if not label:
|
|
2744
|
-
# Inline fallback: word frequency from top facts
|
|
2745
|
-
try:
|
|
2746
|
-
fact_rows = conn.execute(
|
|
2747
|
-
"SELECT af.content FROM fact_importance fi "
|
|
2748
|
-
"JOIN atomic_facts af ON fi.fact_id = af.fact_id "
|
|
2749
|
-
"WHERE fi.profile_id = ? AND fi.community_id = ? "
|
|
2750
|
-
"ORDER BY fi.pagerank_score DESC LIMIT 20",
|
|
2751
|
-
(pid, comm_id),
|
|
2752
|
-
).fetchall()
|
|
2753
|
-
from collections import defaultdict as _ddict
|
|
2754
|
-
wf = _ddict(int)
|
|
2755
|
-
sw = {"the", "a", "an", "is", "was", "are", "to", "of",
|
|
2756
|
-
"in", "for", "on", "with", "at", "by", "from",
|
|
2757
|
-
"and", "but", "or", "not", "it", "this", "that",
|
|
2758
|
-
"i", "we", "they", "he", "she", "you", "my"}
|
|
2759
|
-
for fr in fact_rows:
|
|
2760
|
-
for w in (dict(fr).get("content", "")).lower().split():
|
|
2761
|
-
w = w.strip(".,;:!?\"'()[]{}")
|
|
2762
|
-
if len(w) > 2 and w not in sw:
|
|
2763
|
-
wf[w] += 1
|
|
2764
|
-
top = sorted(wf.items(), key=lambda x: x[1], reverse=True)[:3]
|
|
2765
|
-
label = ", ".join(w for w, _ in top) if top else f"Community {comm_id}"
|
|
2766
|
-
except Exception:
|
|
2767
|
-
label = f"Community {comm_id}"
|
|
2768
|
-
|
|
2769
|
-
# Get top entities from canonical_entities_json
|
|
2770
|
-
top_entities = []
|
|
2771
|
-
try:
|
|
2772
|
-
ent_rows = conn.execute(
|
|
2773
|
-
"SELECT af.canonical_entities_json FROM fact_importance fi "
|
|
2774
|
-
"JOIN atomic_facts af ON fi.fact_id = af.fact_id "
|
|
2775
|
-
"WHERE fi.profile_id = ? AND fi.community_id = ? "
|
|
2776
|
-
"ORDER BY fi.pagerank_score DESC LIMIT 10",
|
|
2777
|
-
(pid, comm_id),
|
|
2778
|
-
).fetchall()
|
|
2779
|
-
import json as _json2
|
|
2780
|
-
entity_counts: dict = {}
|
|
2781
|
-
for er in ent_rows:
|
|
2782
|
-
raw = dict(er).get("canonical_entities_json", "")
|
|
2783
|
-
if raw:
|
|
2784
|
-
try:
|
|
2785
|
-
for ent in _json2.loads(raw):
|
|
2786
|
-
entity_counts[ent] = entity_counts.get(ent, 0) + 1
|
|
2787
|
-
except (ValueError, TypeError):
|
|
2788
|
-
pass
|
|
2789
|
-
top_entities = sorted(
|
|
2790
|
-
entity_counts, key=entity_counts.get, reverse=True,
|
|
2791
|
-
)[:5]
|
|
2792
|
-
except Exception:
|
|
2793
|
-
pass
|
|
2794
|
-
|
|
2795
|
-
communities.append({
|
|
2796
|
-
"community_id": comm_id,
|
|
2797
|
-
"label": label,
|
|
2798
|
-
"member_count": d["member_count"],
|
|
2799
|
-
"top_entities": top_entities,
|
|
2800
|
-
"color": cluster_colors[comm_id % len(cluster_colors)],
|
|
2801
|
-
"pagerank_avg": round(float(d["pagerank_avg"] or 0), 4),
|
|
2802
|
-
})
|
|
2803
|
-
|
|
2804
|
-
conn.close()
|
|
2805
|
-
return {"communities": communities, "total": len(communities)}
|
|
2806
|
-
|
|
2807
|
-
except Exception as e:
|
|
2808
|
-
return _internal_error()
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
@router.post("/graph/run-communities")
|
|
2812
|
-
async def run_community_detection(request: Request):
|
|
2813
|
-
"""Trigger community detection manually (runs GraphAnalyzer)."""
|
|
2814
|
-
try:
|
|
2815
|
-
from superlocalmemory.server.routes.helpers import get_active_profile, DB_PATH
|
|
2816
|
-
from superlocalmemory.storage.database import DatabaseManager
|
|
2817
|
-
from superlocalmemory.storage import schema as _schema
|
|
2818
|
-
from superlocalmemory.core.graph_analyzer import GraphAnalyzer
|
|
2819
|
-
|
|
2820
|
-
pid = get_active_profile()
|
|
2821
|
-
_require_manage_for_profile(request, pid)
|
|
2822
|
-
authorization = authorize_route_mutation(
|
|
2823
|
-
request,
|
|
2824
|
-
operation="update",
|
|
2825
|
-
source_agent_id="http-community-detection",
|
|
2826
|
-
profile_id=pid,
|
|
2827
|
-
)
|
|
2828
|
-
db = DatabaseManager(DB_PATH)
|
|
2829
|
-
db.initialize(_schema)
|
|
2830
|
-
|
|
2831
|
-
analyzer = GraphAnalyzer(db)
|
|
2832
|
-
result = analyzer.compute_and_store(pid)
|
|
2833
|
-
authorization.complete()
|
|
2834
|
-
return {"success": True, **result}
|
|
2835
|
-
except HTTPException:
|
|
2836
|
-
raise
|
|
2837
|
-
except Exception as e:
|
|
2838
|
-
return _internal_error()
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
@router.get("/v33/overview")
|
|
2842
|
-
async def v33_overview(request: Request, profile: str = ""):
|
|
2843
|
-
"""Get SLM 3.3 feature overview -- all new capabilities at a glance."""
|
|
2844
|
-
pid = _resolve_profile(request, profile)
|
|
2845
|
-
try:
|
|
2846
|
-
from superlocalmemory.server.routes.helpers import get_active_profile, DB_PATH
|
|
2847
|
-
import sqlite3 as _sqlite3
|
|
2848
|
-
|
|
2849
|
-
overview: dict = {
|
|
2850
|
-
"version": "3.3",
|
|
2851
|
-
"profile": pid,
|
|
2852
|
-
"forgetting": {"total": 0, "zones": {}},
|
|
2853
|
-
"quantization": {"total": 0, "tiers": {}, "compression_ratio": 1.0},
|
|
2854
|
-
"ccq": {"blocks": 0, "facts_archived": 0},
|
|
2855
|
-
"soft_prompts": {"total": 0, "total_tokens": 0},
|
|
2856
|
-
"hopfield": {
|
|
2857
|
-
"available": False,
|
|
2858
|
-
"description": "Modern Continuous Hopfield Network retrieval channel",
|
|
2859
|
-
},
|
|
2860
|
-
"process_health": {"healthy": True},
|
|
2861
|
-
}
|
|
2862
|
-
|
|
2863
|
-
if not DB_PATH.exists():
|
|
2864
|
-
return overview
|
|
2865
|
-
|
|
2866
|
-
conn = get_read_connection(DB_PATH)
|
|
2867
|
-
conn.row_factory = _sqlite3.Row
|
|
2868
|
-
|
|
2869
|
-
# Forgetting stats
|
|
2870
|
-
try:
|
|
2871
|
-
zones = {"active": 0, "warm": 0, "cold": 0, "archive": 0, "forgotten": 0}
|
|
2872
|
-
rows = conn.execute(
|
|
2873
|
-
"SELECT lifecycle_zone, COUNT(*) AS cnt "
|
|
2874
|
-
"FROM fact_retention WHERE profile_id = ? "
|
|
2875
|
-
"GROUP BY lifecycle_zone",
|
|
2876
|
-
(pid,),
|
|
2877
|
-
).fetchall()
|
|
2878
|
-
total_fg = 0
|
|
2879
|
-
for row in rows:
|
|
2880
|
-
d = dict(row)
|
|
2881
|
-
zone = d["lifecycle_zone"]
|
|
2882
|
-
if zone in zones:
|
|
2883
|
-
zones[zone] = d["cnt"]
|
|
2884
|
-
total_fg += d["cnt"]
|
|
2885
|
-
overview["forgetting"] = {"total": total_fg, "zones": zones}
|
|
2886
|
-
except Exception:
|
|
2887
|
-
pass
|
|
2888
|
-
|
|
2889
|
-
# Quantization stats
|
|
2890
|
-
try:
|
|
2891
|
-
tiers = {"float32": 0, "int8": 0, "polar4": 0, "polar2": 0}
|
|
2892
|
-
rows = conn.execute(
|
|
2893
|
-
"SELECT quantization_level, COUNT(*) AS cnt "
|
|
2894
|
-
"FROM embedding_quantization_metadata "
|
|
2895
|
-
"WHERE profile_id = ? GROUP BY quantization_level",
|
|
2896
|
-
(pid,),
|
|
2897
|
-
).fetchall()
|
|
2898
|
-
total_q = 0
|
|
2899
|
-
for row in rows:
|
|
2900
|
-
d = dict(row)
|
|
2901
|
-
level = d["quantization_level"]
|
|
2902
|
-
if level in tiers:
|
|
2903
|
-
tiers[level] = d["cnt"]
|
|
2904
|
-
total_q += d["cnt"]
|
|
2905
|
-
overview["quantization"] = {
|
|
2906
|
-
"total": total_q, "tiers": tiers, "compression_ratio": 1.0,
|
|
2907
|
-
}
|
|
2908
|
-
except Exception:
|
|
2909
|
-
pass
|
|
2910
|
-
|
|
2911
|
-
# CCQ stats
|
|
2912
|
-
try:
|
|
2913
|
-
block_count = conn.execute(
|
|
2914
|
-
"SELECT COUNT(*) FROM ccq_consolidated_blocks "
|
|
2915
|
-
"WHERE profile_id = ?", (pid,),
|
|
2916
|
-
).fetchone()[0]
|
|
2917
|
-
# Count archived facts (lifecycle='archived' from CCQ)
|
|
2918
|
-
archived_count = 0
|
|
2919
|
-
try:
|
|
2920
|
-
archived_count = conn.execute(
|
|
2921
|
-
"SELECT COUNT(*) FROM atomic_facts "
|
|
2922
|
-
"WHERE profile_id = ? AND lifecycle = 'archived'",
|
|
2923
|
-
(pid,),
|
|
2924
|
-
).fetchone()[0]
|
|
2925
|
-
except Exception:
|
|
2926
|
-
pass
|
|
2927
|
-
overview["ccq"] = {
|
|
2928
|
-
"blocks": block_count,
|
|
2929
|
-
"facts_archived": archived_count,
|
|
2930
|
-
}
|
|
2931
|
-
except Exception:
|
|
2932
|
-
pass
|
|
2933
|
-
|
|
2934
|
-
# Soft prompts stats
|
|
2935
|
-
try:
|
|
2936
|
-
prompt_rows = conn.execute(
|
|
2937
|
-
"SELECT COUNT(*) AS cnt, COALESCE(SUM(token_count), 0) AS tokens "
|
|
2938
|
-
"FROM soft_prompt_templates "
|
|
2939
|
-
"WHERE profile_id = ? AND active = 1",
|
|
2940
|
-
(pid,),
|
|
2941
|
-
).fetchone()
|
|
2942
|
-
if prompt_rows:
|
|
2943
|
-
d = dict(prompt_rows)
|
|
2944
|
-
overview["soft_prompts"] = {
|
|
2945
|
-
"total": d["cnt"],
|
|
2946
|
-
"total_tokens": d["tokens"],
|
|
2947
|
-
}
|
|
2948
|
-
except Exception:
|
|
2949
|
-
pass
|
|
2950
|
-
|
|
2951
|
-
# Hopfield channel availability
|
|
2952
|
-
try:
|
|
2953
|
-
from superlocalmemory.retrieval.hopfield_channel import HopfieldChannel # noqa: F401
|
|
2954
|
-
overview["hopfield"]["available"] = True
|
|
2955
|
-
except ImportError:
|
|
2956
|
-
pass
|
|
2957
|
-
|
|
2958
|
-
# Process health
|
|
2959
|
-
try:
|
|
2960
|
-
import os as _os
|
|
2961
|
-
_os.kill(_os.getppid(), 0)
|
|
2962
|
-
overview["process_health"] = {"healthy": True}
|
|
2963
|
-
except ProcessLookupError:
|
|
2964
|
-
overview["process_health"] = {"healthy": False}
|
|
2965
|
-
except (PermissionError, OSError):
|
|
2966
|
-
overview["process_health"] = {"healthy": True}
|
|
2967
|
-
|
|
2968
|
-
conn.close()
|
|
2969
|
-
return overview
|
|
2970
|
-
except Exception as e:
|
|
2971
|
-
return _internal_error()
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
# ── MCP Profiles ─────────────────────────────────────────────
|
|
2975
|
-
|
|
2976
|
-
@router.get("/mcp/profiles")
|
|
2977
|
-
async def get_mcp_profiles(request: Request):
|
|
2978
|
-
"""MCP profile summary: current profile, all profiles with tool counts and names.
|
|
2979
|
-
|
|
2980
|
-
Reads only the env var SLM_MCP_PROFILE and the pure-data profiles module.
|
|
2981
|
-
No engine required — safe to call at any point in the daemon lifecycle.
|
|
2982
|
-
|
|
2983
|
-
Note on 'current': when SLM_MCP_PROFILE is unset the MCP server falls
|
|
2984
|
-
back to the legacy _ESSENTIAL_TOOLS set. The UI reports 'core' for that
|
|
2985
|
-
state because 'core' is the recommended named-profile equivalent for new
|
|
2986
|
-
installs and is the closest documented starting point for users.
|
|
2987
|
-
"""
|
|
2988
|
-
try:
|
|
2989
|
-
from superlocalmemory.mcp.profiles import (
|
|
2990
|
-
_PROFILE_DEFINITIONS,
|
|
2991
|
-
_PROFILE_ALIASES,
|
|
2992
|
-
PROFILE_DESCRIPTIONS,
|
|
2993
|
-
)
|
|
2994
|
-
|
|
2995
|
-
raw_profile = os.environ.get("SLM_MCP_PROFILE", "").strip().lower()
|
|
2996
|
-
canonical = _PROFILE_ALIASES.get(raw_profile, raw_profile)
|
|
2997
|
-
# Blank env var, "whole", or an unknown value all resolve to "core"
|
|
2998
|
-
# for UI display purposes (safe, conservative default).
|
|
2999
|
-
if not canonical or canonical == "whole" or canonical not in _PROFILE_DEFINITIONS:
|
|
3000
|
-
current = "core"
|
|
3001
|
-
else:
|
|
3002
|
-
current = canonical
|
|
3003
|
-
|
|
3004
|
-
profiles_out: dict = {}
|
|
3005
|
-
for name, tool_set in _PROFILE_DEFINITIONS.items():
|
|
3006
|
-
profiles_out[name] = {
|
|
3007
|
-
"count": len(tool_set),
|
|
3008
|
-
"tools": sorted(tool_set),
|
|
3009
|
-
"description": PROFILE_DESCRIPTIONS.get(name, ""),
|
|
3010
|
-
}
|
|
3011
|
-
|
|
3012
|
-
all_tools: set = set()
|
|
3013
|
-
for tool_set in _PROFILE_DEFINITIONS.values():
|
|
3014
|
-
all_tools.update(tool_set)
|
|
3015
|
-
|
|
3016
|
-
return {
|
|
3017
|
-
"current": current,
|
|
3018
|
-
"profiles": profiles_out,
|
|
3019
|
-
"aliases": dict(_PROFILE_ALIASES),
|
|
3020
|
-
"total_tools": len(all_tools),
|
|
3021
|
-
}
|
|
3022
|
-
except Exception:
|
|
3023
|
-
return _internal_error()
|