superlocalmemory 3.7.8 → 3.8.1
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 +1 -1
- package/ATTRIBUTION.md +1 -3
- package/CHANGELOG.md +129 -0
- package/README.md +205 -123
- package/package.json +12 -3
- package/plugin/.claude-plugin/plugin.json +2 -3
- package/plugin/CLAUDE.md +8 -8
- package/plugin/agents/slm-governance-advisor.md +80 -0
- package/plugin/agents/slm-loop-runner.md +71 -0
- package/plugin/agents/slm-memory-advisor.md +10 -5
- package/plugin/agents/slm-optimize-advisor.md +9 -3
- package/plugin/commands/slm-loop.md +31 -0
- package/plugin/hooks/hooks.json +79 -0
- package/plugin/requirements.txt +1 -1
- package/plugin/scripts/slm-launch +46 -7
- package/plugin/settings.json +9 -0
- package/plugin/skills/slm-cache/SKILL.md +9 -1
- package/plugin/skills/slm-compress/SKILL.md +8 -1
- package/plugin/skills/slm-governance/SKILL.md +248 -0
- package/plugin/skills/slm-graph/SKILL.md +17 -3
- package/plugin/skills/slm-loop/SKILL.md +99 -0
- package/plugin/skills/slm-mesh/SKILL.md +282 -0
- package/plugin/skills/slm-profile/SKILL.md +148 -0
- package/plugin/skills/slm-recall/SKILL.md +46 -10
- package/plugin/skills/slm-remember/SKILL.md +48 -1
- package/plugin/skills/slm-scope/SKILL.md +176 -0
- package/plugin/skills/slm-session/SKILL.md +24 -1
- package/plugin/skills/slm-status/SKILL.md +18 -1
- package/plugin-src/rules/AGENTS.md +57 -18
- package/plugin-src/skills/slm-cache/SKILL.md +9 -1
- package/plugin-src/skills/slm-compress/SKILL.md +8 -1
- package/plugin-src/skills/slm-graph/SKILL.md +17 -3
- package/plugin-src/skills/slm-recall/SKILL.md +46 -10
- package/plugin-src/skills/slm-remember/SKILL.md +48 -1
- package/plugin-src/skills/slm-session/SKILL.md +24 -1
- package/plugin-src/skills/slm-status/SKILL.md +18 -1
- package/pyproject.toml +2 -1
- package/scripts/postinstall/validation.js +2 -0
- package/scripts/postinstall-interactive.js +74 -2
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/__init__.py +3 -0
- package/src/superlocalmemory/access/rbac.py +477 -0
- package/src/superlocalmemory/cli/commands.py +228 -17
- package/src/superlocalmemory/cli/compress_cmd.py +17 -7
- package/src/superlocalmemory/cli/daemon.py +7 -0
- package/src/superlocalmemory/cli/loop_cmd.py +187 -0
- package/src/superlocalmemory/cli/main.py +49 -8
- package/src/superlocalmemory/cli/mesh_cmd.py +38 -0
- package/src/superlocalmemory/cli/optimize_cmd.py +3 -0
- package/src/superlocalmemory/cli/pending_store.py +49 -13
- package/src/superlocalmemory/cli/proxy_cmd.py +4 -0
- package/src/superlocalmemory/cli/scale_engine_cmd.py +6 -0
- package/src/superlocalmemory/cli/setup_wizard.py +22 -13
- package/src/superlocalmemory/cli/version_banner.py +17 -3
- package/src/superlocalmemory/compliance/audit.py +6 -0
- package/src/superlocalmemory/compliance/gdpr.py +128 -138
- package/src/superlocalmemory/compliance/retention.py +176 -45
- package/src/superlocalmemory/core/backend_orchestrator.py +23 -59
- package/src/superlocalmemory/core/community_summary.py +267 -0
- package/src/superlocalmemory/core/config.py +216 -3
- package/src/superlocalmemory/core/consolidation_engine.py +95 -22
- package/src/superlocalmemory/core/context_cache.py +61 -18
- package/src/superlocalmemory/core/embedding_worker.py +21 -7
- package/src/superlocalmemory/core/embeddings.py +131 -46
- package/src/superlocalmemory/core/engine.py +41 -22
- package/src/superlocalmemory/core/engine_ingestion.py +359 -43
- package/src/superlocalmemory/core/engine_wiring.py +13 -0
- package/src/superlocalmemory/core/entity_community.py +178 -0
- package/src/superlocalmemory/core/graph_analyzer.py +39 -2
- package/src/superlocalmemory/core/graph_pruner.py +13 -8
- package/src/superlocalmemory/core/ingestion_command.py +134 -25
- package/src/superlocalmemory/core/injection.py +12 -7
- package/src/superlocalmemory/core/key_expander.py +138 -0
- package/src/superlocalmemory/core/maintenance.py +23 -0
- package/src/superlocalmemory/core/maintenance_scheduler.py +17 -7
- package/src/superlocalmemory/core/modes.py +1 -1
- package/src/superlocalmemory/core/mutations.py +2 -2
- package/src/superlocalmemory/core/pii.py +105 -0
- package/src/superlocalmemory/core/progressive_abstraction.py +208 -0
- package/src/superlocalmemory/core/recall_pipeline.py +7 -3
- package/src/superlocalmemory/core/recall_worker.py +20 -6
- package/src/superlocalmemory/core/scale_engine.py +60 -1
- package/src/superlocalmemory/core/security_primitives.py +40 -2
- package/src/superlocalmemory/core/store_pipeline.py +186 -29
- package/src/superlocalmemory/core/worker_pool.py +21 -6
- package/src/superlocalmemory/encoding/entity_reflexion.py +200 -0
- package/src/superlocalmemory/encoding/entity_resolver.py +34 -24
- package/src/superlocalmemory/encoding/fact_extractor.py +26 -1
- package/src/superlocalmemory/encoding/temporal_validator.py +64 -1
- package/src/superlocalmemory/evolution/evolution_store.py +122 -45
- package/src/superlocalmemory/evolution/llm_dispatch.py +12 -1
- package/src/superlocalmemory/evolution/model_selection.py +160 -0
- package/src/superlocalmemory/evolution/mutation_generator.py +16 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +127 -42
- package/src/superlocalmemory/evolution/triggers.py +22 -13
- package/src/superlocalmemory/graph/cozo_backend.py +43 -20
- package/src/superlocalmemory/hooks/adapter_base.py +5 -1
- package/src/superlocalmemory/hooks/auto_recall.py +13 -1
- package/src/superlocalmemory/hooks/claude_code_hooks.py +11 -0
- package/src/superlocalmemory/hooks/codex_assets.py +64 -5
- package/src/superlocalmemory/hooks/hook_daemon.py +20 -3
- package/src/superlocalmemory/hooks/hook_handlers.py +6 -1
- package/src/superlocalmemory/hooks/memory_protocol.py +54 -0
- package/src/superlocalmemory/hooks/portable_kit.py +148 -3
- package/src/superlocalmemory/infra/backup.py +12 -1
- package/src/superlocalmemory/infra/daemon_identity.py +40 -4
- package/src/superlocalmemory/infra/data_root.py +43 -4
- package/src/superlocalmemory/infra/event_bus.py +107 -24
- package/src/superlocalmemory/infra/rate_limiter.py +93 -0
- package/src/superlocalmemory/ingestion/adapter_manager.py +4 -1
- package/src/superlocalmemory/ingestion/credentials.py +1 -1
- package/src/superlocalmemory/learning/cross_project.py +28 -19
- package/src/superlocalmemory/learning/model_rollback.py +3 -0
- package/src/superlocalmemory/learning/ranker_retrain_online.py +2 -0
- package/src/superlocalmemory/learning/reward.py +50 -0
- package/src/superlocalmemory/learning/reward_proxy.py +42 -9
- package/src/superlocalmemory/learning/source_quality.py +523 -1
- package/src/superlocalmemory/loops/__init__.py +56 -0
- package/src/superlocalmemory/loops/budget.py +58 -0
- package/src/superlocalmemory/loops/engine.py +164 -0
- package/src/superlocalmemory/loops/ledger.py +263 -0
- package/src/superlocalmemory/loops/models.py +152 -0
- package/src/superlocalmemory/loops/rules.py +52 -0
- package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
- package/src/superlocalmemory/mcp/_pool_adapter.py +2 -0
- package/src/superlocalmemory/mcp/profiles.py +103 -0
- package/src/superlocalmemory/mcp/server.py +32 -79
- package/src/superlocalmemory/mcp/tools_active.py +4 -7
- package/src/superlocalmemory/mcp/tools_code_graph.py +51 -5
- package/src/superlocalmemory/mcp/tools_core.py +12 -4
- package/src/superlocalmemory/mcp/tools_evolution.py +6 -3
- package/src/superlocalmemory/mcp/tools_learning.py +2 -2
- package/src/superlocalmemory/mcp/tools_loops.py +300 -0
- package/src/superlocalmemory/mcp/tools_mesh.py +140 -4
- package/src/superlocalmemory/mcp/tools_optimize.py +15 -8
- package/src/superlocalmemory/mesh/broker.py +237 -129
- package/src/superlocalmemory/mesh/remote_sync.py +50 -8
- package/src/superlocalmemory/optimize/NOTICE +1 -6
- package/src/superlocalmemory/optimize/adapters/anthropic_adapter.py +1 -4
- package/src/superlocalmemory/optimize/adapters/openai_adapter.py +1 -4
- package/src/superlocalmemory/optimize/cache/semantic.py +27 -19
- package/src/superlocalmemory/optimize/compress/align.py +32 -26
- package/src/superlocalmemory/optimize/compress/ccr.py +14 -71
- package/src/superlocalmemory/optimize/compress/router.py +105 -22
- package/src/superlocalmemory/optimize/config/defaults.py +1 -1
- package/src/superlocalmemory/optimize/config/schema.py +87 -4
- package/src/superlocalmemory/optimize/metrics/counters.py +13 -4
- package/src/superlocalmemory/optimize/metrics/estimator.py +0 -3
- package/src/superlocalmemory/optimize/proxy/_helpers.py +31 -4
- package/src/superlocalmemory/optimize/storage/db.py +38 -9
- package/src/superlocalmemory/optimize/storage/schema.py +10 -0
- package/src/superlocalmemory/parameterization/pattern_extractor.py +6 -3
- package/src/superlocalmemory/retrieval/agentic.py +1 -1
- package/src/superlocalmemory/retrieval/bm25_channel.py +68 -10
- package/src/superlocalmemory/retrieval/engine.py +221 -47
- package/src/superlocalmemory/retrieval/entity_channel.py +7 -5
- package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -2
- package/src/superlocalmemory/retrieval/reranker.py +3 -4
- package/src/superlocalmemory/retrieval/semantic_channel.py +114 -21
- package/src/superlocalmemory/retrieval/spreading_activation.py +11 -2
- package/src/superlocalmemory/retrieval/temporal_channel.py +48 -9
- package/src/superlocalmemory/retrieval/temporal_frame.py +102 -0
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +135 -0
- package/src/superlocalmemory/retrieval/time_window.py +181 -0
- package/src/superlocalmemory/server/api.py +4 -4
- package/src/superlocalmemory/server/config_file.py +90 -0
- package/src/superlocalmemory/server/origin.py +50 -0
- package/src/superlocalmemory/server/profile_runtime.py +125 -8
- package/src/superlocalmemory/server/rbac_enforce.py +142 -0
- package/src/superlocalmemory/server/recall_health.py +24 -3
- package/src/superlocalmemory/server/recall_serializer.py +19 -1
- package/src/superlocalmemory/server/routes/abstraction.py +115 -0
- package/src/superlocalmemory/server/routes/agents.py +128 -38
- package/src/superlocalmemory/server/routes/backup.py +317 -70
- package/src/superlocalmemory/server/routes/behavioral.py +349 -71
- package/src/superlocalmemory/server/routes/brain.py +69 -12
- package/src/superlocalmemory/server/routes/chat.py +10 -5
- package/src/superlocalmemory/server/routes/compliance.py +171 -21
- package/src/superlocalmemory/server/routes/config_api.py +438 -0
- package/src/superlocalmemory/server/routes/data_io.py +30 -8
- package/src/superlocalmemory/server/routes/entity.py +108 -26
- package/src/superlocalmemory/server/routes/events.py +24 -8
- package/src/superlocalmemory/server/routes/evolution.py +189 -68
- package/src/superlocalmemory/server/routes/helpers.py +16 -1
- package/src/superlocalmemory/server/routes/ingest.py +7 -4
- package/src/superlocalmemory/server/routes/insights.py +3 -3
- package/src/superlocalmemory/server/routes/learning.py +289 -118
- package/src/superlocalmemory/server/routes/learning_telemetry.py +153 -0
- package/src/superlocalmemory/server/routes/lifecycle.py +59 -8
- package/src/superlocalmemory/server/routes/memories.py +182 -57
- package/src/superlocalmemory/server/routes/mesh.py +200 -31
- package/src/superlocalmemory/server/routes/optimize.py +33 -1
- package/src/superlocalmemory/server/routes/prewarm.py +2 -0
- package/src/superlocalmemory/server/routes/profiles.py +63 -17
- package/src/superlocalmemory/server/routes/ratelimit.py +132 -0
- package/src/superlocalmemory/server/routes/rbac.py +367 -0
- package/src/superlocalmemory/server/routes/stats.py +103 -158
- package/src/superlocalmemory/server/routes/tiers.py +11 -9
- package/src/superlocalmemory/server/routes/token.py +3 -13
- package/src/superlocalmemory/server/routes/v3_api.py +247 -89
- package/src/superlocalmemory/server/routes/ws.py +5 -2
- package/src/superlocalmemory/server/security_middleware.py +12 -5
- package/src/superlocalmemory/server/ui.py +20 -5
- package/src/superlocalmemory/server/unified_daemon.py +827 -72
- package/src/superlocalmemory/server/write_identity.py +38 -8
- package/src/superlocalmemory/storage/database.py +265 -53
- package/src/superlocalmemory/storage/migration_runner.py +132 -1
- package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +5 -0
- package/src/superlocalmemory/storage/migrations/M021_ingestion_log_profile.py +108 -0
- package/src/superlocalmemory/storage/migrations/M022_entity_aliases_profile.py +86 -0
- package/src/superlocalmemory/storage/migrations/M023_mesh_profile_isolation.py +194 -0
- package/src/superlocalmemory/storage/migrations/M024_rbac_users_roles.py +87 -0
- package/src/superlocalmemory/storage/migrations/M025_perf_indexes.py +90 -0
- package/src/superlocalmemory/storage/migrations/M026_rbac_memberships_fk.py +136 -0
- package/src/superlocalmemory/storage/migrations/M027_transferable_patterns_profile.py +163 -0
- package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +270 -0
- package/src/superlocalmemory/storage/migrations/M029_behavioral_history_indexes.py +137 -0
- package/src/superlocalmemory/storage/migrations/M030_entity_explorer_indexes.py +93 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +4 -0
- package/src/superlocalmemory/storage/models.py +4 -0
- package/src/superlocalmemory/storage/schema.py +136 -1
- package/src/superlocalmemory/storage/schema_v32.py +2 -0
- package/src/superlocalmemory/storage/schema_v343.py +24 -12
- package/src/superlocalmemory/storage/schema_v347.py +4 -0
- package/src/superlocalmemory/trust/gate.py +49 -8
- package/src/superlocalmemory/ui/assets/slm-icon-white.svg +64 -0
- package/src/superlocalmemory/ui/assets/slm-icon.svg +36 -0
- package/src/superlocalmemory/ui/css/design-system.css +621 -0
- package/src/superlocalmemory/ui/css/neural-glass.css +6 -0
- package/src/superlocalmemory/ui/css/od-bridge.css +158 -0
- package/src/superlocalmemory/ui/favicon.svg +35 -4
- package/src/superlocalmemory/ui/index.html +303 -173
- package/src/superlocalmemory/ui/js/brain.js +5 -20
- package/src/superlocalmemory/ui/js/core.js +100 -41
- package/src/superlocalmemory/ui/js/dashboard.js +403 -65
- package/src/superlocalmemory/ui/js/event-delegation.js +102 -0
- package/src/superlocalmemory/ui/js/knowledge-graph.js +11 -11
- package/src/superlocalmemory/ui/js/math-health.js +1 -1
- package/src/superlocalmemory/ui/js/memories.js +15 -4
- package/src/superlocalmemory/ui/js/memory-chat.js +7 -7
- package/src/superlocalmemory/ui/js/ng-entities.js +6 -8
- package/src/superlocalmemory/ui/js/ng-ingestion.js +4 -4
- package/src/superlocalmemory/ui/js/ng-mesh.js +4 -9
- package/src/superlocalmemory/ui/js/ng-shell.js +8 -8
- package/src/superlocalmemory/ui/js/ng-skills.js +54 -2
- package/src/superlocalmemory/ui/js/od-agents.js +544 -0
- package/src/superlocalmemory/ui/js/od-auth-gate.js +257 -0
- package/src/superlocalmemory/ui/js/od-backup.js +871 -0
- package/src/superlocalmemory/ui/js/od-brain.js +816 -0
- package/src/superlocalmemory/ui/js/od-entities.js +579 -0
- package/src/superlocalmemory/ui/js/od-graph.js +600 -0
- package/src/superlocalmemory/ui/js/od-health.js +539 -0
- package/src/superlocalmemory/ui/js/od-mcp.js +508 -0
- package/src/superlocalmemory/ui/js/od-memories.js +929 -0
- package/src/superlocalmemory/ui/js/od-mesh.js +553 -0
- package/src/superlocalmemory/ui/js/od-operations.js +1250 -0
- package/src/superlocalmemory/ui/js/od-optimize.js +787 -0
- package/src/superlocalmemory/ui/js/od-settings.js +1107 -0
- package/src/superlocalmemory/ui/js/od-shell.js +809 -0
- package/src/superlocalmemory/ui/js/od-skills.js +600 -0
- package/src/superlocalmemory/ui/js/od-team.js +258 -0
- package/src/superlocalmemory/ui/js/profiles.js +159 -46
- package/src/superlocalmemory/ui/js/settings.js +17 -3
- package/src/superlocalmemory/ui/js/timeline.js +34 -5
- package/src/superlocalmemory/ui/js/trust-dashboard.js +2 -2
- package/src/superlocalmemory/vector/lancedb_backend.py +8 -6
- package/plugin-src/.mcp.json +0 -12
- package/plugin-src/agents/slm-memory-advisor.md +0 -44
- package/plugin-src/agents/slm-optimize-advisor.md +0 -38
- package/plugin-src/hooks/.gitkeep +0 -0
- package/plugin-src/hooks/hooks.json +0 -23
- package/plugin-src/manifest.json +0 -25
- package/plugin-src/requirements.txt +0 -1
- package/plugin-src/rules/CLAUDE.md.fragment +0 -44
- package/plugin-src/scripts/ensure-venv.bat +0 -122
- package/plugin-src/scripts/ensure-venv.sh +0 -105
- package/plugin-src/scripts/slm-launch +0 -23
- package/plugin-src/scripts/slm-launch.bat +0 -23
- package/plugin-src/settings.json +0 -16
- package/src/superlocalmemory/learning/behavioral_listener.py +0 -94
|
@@ -31,6 +31,7 @@ import json
|
|
|
31
31
|
import logging
|
|
32
32
|
import os
|
|
33
33
|
import signal
|
|
34
|
+
import sqlite3
|
|
34
35
|
import sys
|
|
35
36
|
import threading
|
|
36
37
|
import time
|
|
@@ -65,12 +66,83 @@ from superlocalmemory.infra.data_root import (
|
|
|
65
66
|
canonical_data_root,
|
|
66
67
|
state_path,
|
|
67
68
|
)
|
|
69
|
+
from superlocalmemory.learning.source_quality import (
|
|
70
|
+
SourceQualityRepairUnavailable,
|
|
71
|
+
enumerate_source_quality_repair_profiles,
|
|
72
|
+
repair_historical_source_quality,
|
|
73
|
+
)
|
|
68
74
|
|
|
69
75
|
logger = logging.getLogger("superlocalmemory.unified_daemon")
|
|
70
76
|
|
|
71
77
|
_DEFAULT_PORT = 8765
|
|
72
78
|
_LEGACY_PORT = 8767
|
|
73
79
|
_ACTIVE_DAEMON_DESCRIPTOR: DaemonDescriptor | None = None
|
|
80
|
+
_SOURCE_QUALITY_MAX_BATCH_SIZE = 250
|
|
81
|
+
_SOURCE_QUALITY_PROFILE_REFRESH_SECONDS = 60.0
|
|
82
|
+
_FACT_ENTITY_REPAIR_MIN_RETRY_SECONDS = 0.05
|
|
83
|
+
_FACT_ENTITY_REPAIR_MAX_RETRY_SECONDS = 30.0
|
|
84
|
+
# ``wait=true`` is a compatibility affordance, never permission to hold the
|
|
85
|
+
# ASGI event loop hostage to a local LLM. Normal clients omit it and receive
|
|
86
|
+
# an immediate durable/queryable receipt; explicit waiters get this small
|
|
87
|
+
# completion window, then the M018 materializer continues in the background.
|
|
88
|
+
_REMEMBER_ENRICHMENT_WAIT_SECONDS = 0.75
|
|
89
|
+
_SENSITIVE_READ_PREFIXES = (
|
|
90
|
+
"/api/memories", "/api/facts", "/api/clusters", "/api/graph",
|
|
91
|
+
"/api/v3/associations", "/api/v3/core-memory",
|
|
92
|
+
"/api/v3/soft-prompts", "/api/v3/dashboard", "/api/v3/mode",
|
|
93
|
+
"/api/v3/embedding/config", "/api/v3/scope/config",
|
|
94
|
+
"/api/v3/storage/config", "/api/v3/daemon/config",
|
|
95
|
+
"/api/v3/mesh/config", "/api/v3/trust/config",
|
|
96
|
+
"/api/v3/forgetting/config", "/api/v3/mcp/profiles",
|
|
97
|
+
"/api/learning", "/api/behavioral",
|
|
98
|
+
)
|
|
99
|
+
_SENSITIVE_READ_EXACT_PATHS = (
|
|
100
|
+
"/api/search", "/api/v3/recall/trace", "/api/patterns",
|
|
101
|
+
"/api/feedback/stats", "/api/stats", "/api/timeline",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _is_sensitive_dashboard_read(method: str, path: str) -> bool:
|
|
106
|
+
return (
|
|
107
|
+
method == "GET"
|
|
108
|
+
and (
|
|
109
|
+
path.startswith(_SENSITIVE_READ_PREFIXES)
|
|
110
|
+
or path in _SENSITIVE_READ_EXACT_PATHS
|
|
111
|
+
or path.startswith("/api/v3/recall")
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _rbac_read_gate(request, app_state):
|
|
117
|
+
"""RBAC gate for sensitive content reads. Returns a JSONResponse to reject,
|
|
118
|
+
or None to allow. No-op unless RBAC is active (>=1 user)."""
|
|
119
|
+
from fastapi.responses import JSONResponse
|
|
120
|
+
rbac = getattr(app_state, "rbac", None)
|
|
121
|
+
if rbac is None:
|
|
122
|
+
return None
|
|
123
|
+
try:
|
|
124
|
+
active = rbac.user_count() > 0
|
|
125
|
+
except Exception:
|
|
126
|
+
# Fail CLOSED: if we cannot determine RBAC state we must not silently
|
|
127
|
+
# allow reads (a DB error would otherwise open the whole read surface).
|
|
128
|
+
return JSONResponse(status_code=503,
|
|
129
|
+
content={"error": "authorization temporarily unavailable"})
|
|
130
|
+
if not active:
|
|
131
|
+
return None # single-operator install — reads are open
|
|
132
|
+
token = (request.headers.get("x-slm-user-session", "")
|
|
133
|
+
or (request.cookies.get("slm_session", "") if request.cookies else ""))
|
|
134
|
+
user = rbac.resolve_session(token) if token else None
|
|
135
|
+
if user is None:
|
|
136
|
+
if rbac.require_login():
|
|
137
|
+
return JSONResponse(status_code=401,
|
|
138
|
+
content={"error": "Login required to read memory."})
|
|
139
|
+
return None # owner/operator, personal mode
|
|
140
|
+
from superlocalmemory.access.rbac import Permission
|
|
141
|
+
from superlocalmemory.server.routes.helpers import get_active_profile
|
|
142
|
+
if rbac.has_permission(user["user_id"], get_active_profile(), Permission.READ):
|
|
143
|
+
return None
|
|
144
|
+
return JSONResponse(status_code=403,
|
|
145
|
+
content={"error": "Your role cannot read this workspace."})
|
|
74
146
|
|
|
75
147
|
|
|
76
148
|
def _configured_daemon_port() -> int:
|
|
@@ -210,7 +282,10 @@ class EngineRecallAdapter:
|
|
|
210
282
|
if r.fact.memory_id
|
|
211
283
|
})
|
|
212
284
|
memory_map = (
|
|
213
|
-
self._engine._db.get_memory_content_batch(
|
|
285
|
+
self._engine._db.get_memory_content_batch(
|
|
286
|
+
memory_ids, self._engine.profile_id,
|
|
287
|
+
include_global=True, include_shared=True,
|
|
288
|
+
)
|
|
214
289
|
if memory_ids else {}
|
|
215
290
|
)
|
|
216
291
|
# v3.6.6: same shared chokepoint as the HTTP route — identical output.
|
|
@@ -534,7 +609,7 @@ class ObserveBuffer:
|
|
|
534
609
|
"captured": False,
|
|
535
610
|
"durable": False,
|
|
536
611
|
"reason": "durable admission failed",
|
|
537
|
-
"error":
|
|
612
|
+
"error": "internal error",
|
|
538
613
|
}
|
|
539
614
|
|
|
540
615
|
def _clear_seen(self) -> None:
|
|
@@ -643,6 +718,384 @@ async def _start_legacy_redirect(primary_port: int, legacy_port: int) -> None:
|
|
|
643
718
|
# Lifespan
|
|
644
719
|
# ---------------------------------------------------------------------------
|
|
645
720
|
|
|
721
|
+
def _warm_spreading_activation(engine, runtime) -> bool:
|
|
722
|
+
"""Pre-warm the spreading-activation channel for the active profile.
|
|
723
|
+
|
|
724
|
+
The ``--fast`` warmup recalls deliberately skip spreading activation (and the
|
|
725
|
+
Mode-C remote agentic verification), which left the first FULL user recall
|
|
726
|
+
paying the cold graph-load cost: the ``graph_edges`` + ``association_edges``
|
|
727
|
+
page cache and the ``fact_importance`` PageRank/community cache. This warms
|
|
728
|
+
that channel directly — pure local graph work, never a remote/LLM call — so
|
|
729
|
+
the first full recall is warm. Fail-soft; returns True only when it ran.
|
|
730
|
+
"""
|
|
731
|
+
try:
|
|
732
|
+
retr = getattr(engine, "_retrieval_engine", None)
|
|
733
|
+
sa = getattr(retr, "_spreading_activation", None) if retr else None
|
|
734
|
+
embedder = getattr(retr, "_embedder", None) if retr else None
|
|
735
|
+
if sa is None or embedder is None or not hasattr(embedder, "embed"):
|
|
736
|
+
return False
|
|
737
|
+
query_embedding = embedder.embed("memory recall performance")
|
|
738
|
+
if query_embedding is None:
|
|
739
|
+
return False
|
|
740
|
+
active_pid = getattr(engine, "profile_id", "default") or "default"
|
|
741
|
+
lease = runtime.operation_nowait() if runtime is not None else None
|
|
742
|
+
if lease is not None:
|
|
743
|
+
with lease as snap:
|
|
744
|
+
if snap is None:
|
|
745
|
+
return False
|
|
746
|
+
sa.search(query_embedding, profile_id=active_pid, top_k=7)
|
|
747
|
+
else:
|
|
748
|
+
sa.search(query_embedding, profile_id=active_pid, top_k=7)
|
|
749
|
+
logger.info(
|
|
750
|
+
"Spreading-activation graph pre-warmed for profile %s", active_pid,
|
|
751
|
+
)
|
|
752
|
+
return True
|
|
753
|
+
except Exception as exc:
|
|
754
|
+
logger.warning("Spreading-activation warmup failed (non-fatal): %s", exc)
|
|
755
|
+
return False
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def _set_source_quality_repair_status(application, **updates) -> dict:
|
|
759
|
+
current = getattr(
|
|
760
|
+
application.state, "source_quality_repair_status", {},
|
|
761
|
+
)
|
|
762
|
+
status = {**current, **updates}
|
|
763
|
+
application.state.source_quality_repair_status = status
|
|
764
|
+
return status
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _schedule_fact_entity_association_repair(
|
|
768
|
+
application,
|
|
769
|
+
memory_db_path: Path,
|
|
770
|
+
*,
|
|
771
|
+
batch_size: int = 250,
|
|
772
|
+
tick_seconds: float = 1.0,
|
|
773
|
+
) -> asyncio.Task:
|
|
774
|
+
"""Schedule bounded M028 backfill only after readiness is published."""
|
|
775
|
+
from superlocalmemory.storage.migrations.M028_fact_entity_associations import (
|
|
776
|
+
get_repair_status,
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
durable = get_repair_status(Path(memory_db_path))
|
|
780
|
+
application.state.fact_entity_association_repair_status = {
|
|
781
|
+
**durable,
|
|
782
|
+
"source": "startup_background_repair",
|
|
783
|
+
"batch_size": batch_size,
|
|
784
|
+
}
|
|
785
|
+
task = asyncio.create_task(
|
|
786
|
+
_fact_entity_association_repair_loop(
|
|
787
|
+
application,
|
|
788
|
+
Path(memory_db_path),
|
|
789
|
+
batch_size=batch_size,
|
|
790
|
+
tick_seconds=tick_seconds,
|
|
791
|
+
),
|
|
792
|
+
name="fact-entity-association-upgrade-repair",
|
|
793
|
+
)
|
|
794
|
+
application.state.fact_entity_association_repair_task = task
|
|
795
|
+
return task
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
async def _fact_entity_association_repair_loop(
|
|
799
|
+
application,
|
|
800
|
+
memory_db_path: Path,
|
|
801
|
+
*,
|
|
802
|
+
batch_size: int,
|
|
803
|
+
tick_seconds: float,
|
|
804
|
+
) -> None:
|
|
805
|
+
from superlocalmemory.storage.migrations.M028_fact_entity_associations import (
|
|
806
|
+
get_repair_status,
|
|
807
|
+
repair_fact_entity_associations,
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
consecutive_failures = 0
|
|
811
|
+
try:
|
|
812
|
+
while True:
|
|
813
|
+
try:
|
|
814
|
+
await asyncio.to_thread(
|
|
815
|
+
repair_fact_entity_associations,
|
|
816
|
+
memory_db_path,
|
|
817
|
+
batch_size=batch_size,
|
|
818
|
+
max_batches=1,
|
|
819
|
+
)
|
|
820
|
+
durable = await asyncio.to_thread(
|
|
821
|
+
get_repair_status, memory_db_path,
|
|
822
|
+
)
|
|
823
|
+
except sqlite3.Error as exc:
|
|
824
|
+
consecutive_failures += 1
|
|
825
|
+
retry_delay = min(
|
|
826
|
+
_FACT_ENTITY_REPAIR_MAX_RETRY_SECONDS,
|
|
827
|
+
max(
|
|
828
|
+
_FACT_ENTITY_REPAIR_MIN_RETRY_SECONDS,
|
|
829
|
+
float(tick_seconds),
|
|
830
|
+
) * (2 ** min(consecutive_failures - 1, 10)),
|
|
831
|
+
)
|
|
832
|
+
try:
|
|
833
|
+
durable = await asyncio.to_thread(
|
|
834
|
+
get_repair_status, memory_db_path,
|
|
835
|
+
)
|
|
836
|
+
except sqlite3.Error:
|
|
837
|
+
durable = getattr(
|
|
838
|
+
application.state,
|
|
839
|
+
"fact_entity_association_repair_status",
|
|
840
|
+
{},
|
|
841
|
+
)
|
|
842
|
+
application.state.fact_entity_association_repair_status = {
|
|
843
|
+
**durable,
|
|
844
|
+
"state": "retrying",
|
|
845
|
+
"source": "startup_background_repair",
|
|
846
|
+
"batch_size": batch_size,
|
|
847
|
+
"last_error": (
|
|
848
|
+
durable.get("last_error") or type(exc).__name__
|
|
849
|
+
),
|
|
850
|
+
"retry_attempt": consecutive_failures,
|
|
851
|
+
"retry_delay_seconds": retry_delay,
|
|
852
|
+
}
|
|
853
|
+
await asyncio.sleep(retry_delay)
|
|
854
|
+
continue
|
|
855
|
+
|
|
856
|
+
consecutive_failures = 0
|
|
857
|
+
application.state.fact_entity_association_repair_status = {
|
|
858
|
+
**durable,
|
|
859
|
+
"source": "startup_background_repair",
|
|
860
|
+
"batch_size": batch_size,
|
|
861
|
+
"retry_attempt": 0,
|
|
862
|
+
"retry_delay_seconds": 0.0,
|
|
863
|
+
}
|
|
864
|
+
if durable["state"] == "complete":
|
|
865
|
+
return
|
|
866
|
+
await asyncio.sleep(max(0.0, float(tick_seconds)))
|
|
867
|
+
except asyncio.CancelledError:
|
|
868
|
+
raise
|
|
869
|
+
except Exception as exc:
|
|
870
|
+
durable = await asyncio.to_thread(get_repair_status, memory_db_path)
|
|
871
|
+
application.state.fact_entity_association_repair_status = {
|
|
872
|
+
**durable,
|
|
873
|
+
"source": "startup_background_repair",
|
|
874
|
+
"batch_size": batch_size,
|
|
875
|
+
"last_error": durable.get("last_error") or type(exc).__name__,
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
async def _cancel_fact_entity_association_repair(application) -> None:
|
|
880
|
+
task = getattr(
|
|
881
|
+
application.state, "fact_entity_association_repair_task", None,
|
|
882
|
+
)
|
|
883
|
+
if task is None:
|
|
884
|
+
return
|
|
885
|
+
if not task.done():
|
|
886
|
+
task.cancel()
|
|
887
|
+
try:
|
|
888
|
+
await task
|
|
889
|
+
except asyncio.CancelledError:
|
|
890
|
+
pass
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def _schedule_source_quality_repair(
|
|
894
|
+
application,
|
|
895
|
+
memory_db_path: Path,
|
|
896
|
+
learning_db_path: Path,
|
|
897
|
+
*,
|
|
898
|
+
batch_size: int = 25,
|
|
899
|
+
tick_seconds: float = 1.0,
|
|
900
|
+
) -> asyncio.Task:
|
|
901
|
+
"""Schedule post-readiness repair without awaiting historical DB work."""
|
|
902
|
+
_set_source_quality_repair_status(
|
|
903
|
+
application,
|
|
904
|
+
state="scheduled",
|
|
905
|
+
source="startup_background_repair",
|
|
906
|
+
batch_size=batch_size,
|
|
907
|
+
profiles=[],
|
|
908
|
+
completed_profiles=[],
|
|
909
|
+
profile_results={},
|
|
910
|
+
batches_completed=0,
|
|
911
|
+
scanned=0,
|
|
912
|
+
observations=0,
|
|
913
|
+
last_error=None,
|
|
914
|
+
)
|
|
915
|
+
task = asyncio.create_task(
|
|
916
|
+
_source_quality_repair_loop(
|
|
917
|
+
application,
|
|
918
|
+
Path(memory_db_path),
|
|
919
|
+
Path(learning_db_path),
|
|
920
|
+
batch_size=batch_size,
|
|
921
|
+
tick_seconds=tick_seconds,
|
|
922
|
+
),
|
|
923
|
+
name="source-quality-upgrade-repair",
|
|
924
|
+
)
|
|
925
|
+
application.state.source_quality_repair_task = task
|
|
926
|
+
return task
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
async def _repair_one_source_quality_profile(
|
|
930
|
+
memory_db_path: Path,
|
|
931
|
+
learning_db_path: Path,
|
|
932
|
+
profile_id: str,
|
|
933
|
+
batch_size: int,
|
|
934
|
+
) -> dict[str, int | bool]:
|
|
935
|
+
worker = asyncio.create_task(
|
|
936
|
+
asyncio.to_thread(
|
|
937
|
+
repair_historical_source_quality,
|
|
938
|
+
memory_db_path,
|
|
939
|
+
learning_db_path,
|
|
940
|
+
profile_id,
|
|
941
|
+
batch_size=batch_size,
|
|
942
|
+
max_batches=1,
|
|
943
|
+
),
|
|
944
|
+
name=f"source-quality-repair-batch-{profile_id}",
|
|
945
|
+
)
|
|
946
|
+
try:
|
|
947
|
+
return await asyncio.shield(worker)
|
|
948
|
+
except asyncio.CancelledError:
|
|
949
|
+
# Cancellation cannot stop a running worker thread. Await the bounded
|
|
950
|
+
# batch so SQLite writes finish before daemon teardown closes storage.
|
|
951
|
+
await worker
|
|
952
|
+
raise
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def _record_source_quality_repair_result(
|
|
956
|
+
application,
|
|
957
|
+
profile_id: str,
|
|
958
|
+
result: dict[str, int | bool],
|
|
959
|
+
) -> bool:
|
|
960
|
+
current = getattr(
|
|
961
|
+
application.state, "source_quality_repair_status", {},
|
|
962
|
+
)
|
|
963
|
+
completed = set(current.get("completed_profiles", []))
|
|
964
|
+
if result["complete"]:
|
|
965
|
+
completed.add(profile_id)
|
|
966
|
+
results = {
|
|
967
|
+
**current.get("profile_results", {}),
|
|
968
|
+
profile_id: result,
|
|
969
|
+
}
|
|
970
|
+
_set_source_quality_repair_status(
|
|
971
|
+
application,
|
|
972
|
+
profile_results=results,
|
|
973
|
+
completed_profiles=sorted(completed),
|
|
974
|
+
batches_completed=int(current.get("batches_completed", 0)) + 1,
|
|
975
|
+
scanned=int(current.get("scanned", 0)) + int(result["scanned"]),
|
|
976
|
+
observations=int(current.get("observations", 0))
|
|
977
|
+
+ int(result["observations"]),
|
|
978
|
+
)
|
|
979
|
+
return not bool(result["complete"])
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
async def _source_quality_repair_tick(
|
|
983
|
+
application,
|
|
984
|
+
memory_db_path: Path,
|
|
985
|
+
learning_db_path: Path,
|
|
986
|
+
profiles: list[str],
|
|
987
|
+
*,
|
|
988
|
+
batch_size: int,
|
|
989
|
+
) -> list[str]:
|
|
990
|
+
_set_source_quality_repair_status(
|
|
991
|
+
application,
|
|
992
|
+
state="running",
|
|
993
|
+
profiles=profiles,
|
|
994
|
+
current_batch_size=batch_size,
|
|
995
|
+
last_error=None,
|
|
996
|
+
)
|
|
997
|
+
pending = []
|
|
998
|
+
for profile_id in profiles:
|
|
999
|
+
result = await _repair_one_source_quality_profile(
|
|
1000
|
+
memory_db_path, learning_db_path, profile_id, batch_size,
|
|
1001
|
+
)
|
|
1002
|
+
if _record_source_quality_repair_result(
|
|
1003
|
+
application, profile_id, result,
|
|
1004
|
+
):
|
|
1005
|
+
pending.append(profile_id)
|
|
1006
|
+
await asyncio.sleep(0)
|
|
1007
|
+
return pending
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
async def _discover_source_quality_profiles(
|
|
1011
|
+
memory_db_path: Path,
|
|
1012
|
+
pending: list[str] | None,
|
|
1013
|
+
completed: list[str],
|
|
1014
|
+
) -> list[str]:
|
|
1015
|
+
discovered = await asyncio.to_thread(
|
|
1016
|
+
enumerate_source_quality_repair_profiles,
|
|
1017
|
+
memory_db_path,
|
|
1018
|
+
)
|
|
1019
|
+
return sorted(
|
|
1020
|
+
(set(pending or []) | set(discovered)) - set(completed),
|
|
1021
|
+
)
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
async def _source_quality_repair_loop(
|
|
1025
|
+
application,
|
|
1026
|
+
memory_db_path: Path,
|
|
1027
|
+
learning_db_path: Path,
|
|
1028
|
+
*,
|
|
1029
|
+
batch_size: int,
|
|
1030
|
+
tick_seconds: float,
|
|
1031
|
+
) -> None:
|
|
1032
|
+
"""Run one resumable repair batch per discovered profile and tick."""
|
|
1033
|
+
pending: list[str] | None = None
|
|
1034
|
+
next_refresh = 0.0
|
|
1035
|
+
successful_ticks = 0
|
|
1036
|
+
try:
|
|
1037
|
+
while True:
|
|
1038
|
+
try:
|
|
1039
|
+
now = time.monotonic()
|
|
1040
|
+
if pending is None or now >= next_refresh:
|
|
1041
|
+
status = getattr(
|
|
1042
|
+
application.state,
|
|
1043
|
+
"source_quality_repair_status",
|
|
1044
|
+
{},
|
|
1045
|
+
)
|
|
1046
|
+
pending = await _discover_source_quality_profiles(
|
|
1047
|
+
memory_db_path,
|
|
1048
|
+
pending,
|
|
1049
|
+
status.get("completed_profiles", []),
|
|
1050
|
+
)
|
|
1051
|
+
next_refresh = (
|
|
1052
|
+
now + _SOURCE_QUALITY_PROFILE_REFRESH_SECONDS
|
|
1053
|
+
)
|
|
1054
|
+
adaptive_batch = min(
|
|
1055
|
+
_SOURCE_QUALITY_MAX_BATCH_SIZE,
|
|
1056
|
+
batch_size * (2 ** min(successful_ticks, 4)),
|
|
1057
|
+
)
|
|
1058
|
+
pending = await _source_quality_repair_tick(
|
|
1059
|
+
application,
|
|
1060
|
+
memory_db_path,
|
|
1061
|
+
learning_db_path,
|
|
1062
|
+
pending,
|
|
1063
|
+
batch_size=adaptive_batch,
|
|
1064
|
+
)
|
|
1065
|
+
except (SourceQualityRepairUnavailable, sqlite3.Error):
|
|
1066
|
+
_set_source_quality_repair_status(
|
|
1067
|
+
application,
|
|
1068
|
+
state="retrying",
|
|
1069
|
+
last_error="storage_temporarily_unavailable",
|
|
1070
|
+
)
|
|
1071
|
+
else:
|
|
1072
|
+
successful_ticks += 1
|
|
1073
|
+
if pending == []:
|
|
1074
|
+
_set_source_quality_repair_status(application, state="complete")
|
|
1075
|
+
return
|
|
1076
|
+
await asyncio.sleep(max(0.0, float(tick_seconds)))
|
|
1077
|
+
except asyncio.CancelledError:
|
|
1078
|
+
_set_source_quality_repair_status(application, state="cancelled")
|
|
1079
|
+
raise
|
|
1080
|
+
except Exception as exc:
|
|
1081
|
+
logger.warning("source-quality startup repair failed: %s", exc)
|
|
1082
|
+
_set_source_quality_repair_status(
|
|
1083
|
+
application, state="failed", last_error=type(exc).__name__,
|
|
1084
|
+
)
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
async def _cancel_source_quality_repair(application) -> None:
|
|
1088
|
+
task = getattr(application.state, "source_quality_repair_task", None)
|
|
1089
|
+
if task is None:
|
|
1090
|
+
return
|
|
1091
|
+
if not task.done():
|
|
1092
|
+
task.cancel()
|
|
1093
|
+
try:
|
|
1094
|
+
await task
|
|
1095
|
+
except asyncio.CancelledError:
|
|
1096
|
+
pass
|
|
1097
|
+
|
|
1098
|
+
|
|
646
1099
|
@asynccontextmanager
|
|
647
1100
|
async def lifespan(application: FastAPI):
|
|
648
1101
|
"""Initialize engine, workers, and optional services on startup."""
|
|
@@ -765,14 +1218,10 @@ async def lifespan(application: FastAPI):
|
|
|
765
1218
|
engine = MemoryEngine(config)
|
|
766
1219
|
engine.initialize()
|
|
767
1220
|
|
|
768
|
-
#
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
db.execute("PRAGMA journal_mode=WAL")
|
|
773
|
-
db.execute("PRAGMA synchronous=NORMAL")
|
|
774
|
-
except Exception:
|
|
775
|
-
pass
|
|
1221
|
+
# WAL is already established at DB creation (DatabaseManager._enable_wal
|
|
1222
|
+
# / schema init). Re-asserting PRAGMA journal_mode=WAL here is a
|
|
1223
|
+
# schema-level write on the shared connection that raced in-flight
|
|
1224
|
+
# startup requests for the writer lock — removed (H-CONC-3).
|
|
776
1225
|
|
|
777
1226
|
from superlocalmemory.server.profile_runtime import bind_profile_runtime
|
|
778
1227
|
|
|
@@ -846,24 +1295,28 @@ async def lifespan(application: FastAPI):
|
|
|
846
1295
|
try:
|
|
847
1296
|
import sqlite3 as _sqlite3
|
|
848
1297
|
_idx_conn = _sqlite3.connect(str(_memory_db))
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
1298
|
+
try:
|
|
1299
|
+
_idx_conn.execute("PRAGMA journal_mode=WAL")
|
|
1300
|
+
_idx_conn.execute(
|
|
1301
|
+
"CREATE INDEX IF NOT EXISTS idx_edges_source_weight "
|
|
1302
|
+
"ON graph_edges(profile_id, source_id, weight DESC)"
|
|
1303
|
+
)
|
|
1304
|
+
_idx_conn.execute(
|
|
1305
|
+
"CREATE INDEX IF NOT EXISTS idx_edges_target_weight "
|
|
1306
|
+
"ON graph_edges(profile_id, target_id, weight DESC)"
|
|
1307
|
+
)
|
|
1308
|
+
_idx_conn.execute(
|
|
1309
|
+
"CREATE INDEX IF NOT EXISTS idx_assoc_source_weight "
|
|
1310
|
+
"ON association_edges(profile_id, source_fact_id, weight DESC)"
|
|
1311
|
+
)
|
|
1312
|
+
_idx_conn.execute(
|
|
1313
|
+
"CREATE INDEX IF NOT EXISTS idx_assoc_target_weight "
|
|
1314
|
+
"ON association_edges(profile_id, target_fact_id, weight DESC)"
|
|
1315
|
+
)
|
|
1316
|
+
finally:
|
|
1317
|
+
# CP-09: close even if an execute() raises, so the connection
|
|
1318
|
+
# (and its file handle / shared DB lock) never leaks.
|
|
1319
|
+
_idx_conn.close()
|
|
867
1320
|
except Exception as _idx_exc:
|
|
868
1321
|
logger.debug("SpreadingActivation covering indexes skipped: %s", _idx_exc)
|
|
869
1322
|
|
|
@@ -909,6 +1362,11 @@ async def lifespan(application: FastAPI):
|
|
|
909
1362
|
|
|
910
1363
|
Runs after embedding warm (embed first so recall can use it).
|
|
911
1364
|
Named 'recall-warmup' so it appears clearly in thread dumps.
|
|
1365
|
+
|
|
1366
|
+
v3.x: each warmup query holds its own operation_nowait() lease
|
|
1367
|
+
(previously one lease across both queries held for up to 20s,
|
|
1368
|
+
blocking any profile switch issued at daemon start). A pending
|
|
1369
|
+
transition preempts remaining queries; they complete on next boot.
|
|
912
1370
|
"""
|
|
913
1371
|
import time as _t
|
|
914
1372
|
for _ in range(60):
|
|
@@ -920,9 +1378,20 @@ async def lifespan(application: FastAPI):
|
|
|
920
1378
|
# Fire 2 warmup queries: one to load the graph page cache,
|
|
921
1379
|
# second to warm the reranker subprocess + all producers.
|
|
922
1380
|
# Without this, dashboard POST /api/search hits 11s cold.
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
1381
|
+
# Each query holds its own brief operation_nowait() lease so a
|
|
1382
|
+
# concurrent profile switch is not blocked by both recalls.
|
|
1383
|
+
for wq in ("memory recall performance", "context injection retrieval"):
|
|
1384
|
+
with profile_runtime.operation_nowait() as _snap:
|
|
1385
|
+
if _snap is None:
|
|
1386
|
+
logger.debug(
|
|
1387
|
+
"Recall warmup preempted by profile transition "
|
|
1388
|
+
"— skipping remaining warmup queries"
|
|
1389
|
+
)
|
|
1390
|
+
break
|
|
1391
|
+
engine.recall(wq, limit=5, fast=True) # short lease so a profile switch can drain within 5s
|
|
1392
|
+
# v3.8: the --fast recalls above skip spreading activation; warm
|
|
1393
|
+
# that channel directly so the first FULL recall is not cold.
|
|
1394
|
+
_warm_spreading_activation(engine, profile_runtime)
|
|
926
1395
|
elapsed = round((_t.monotonic() - t0) * 1000)
|
|
927
1396
|
logger.info(
|
|
928
1397
|
"Recall engine pre-warmed in %dms", elapsed,
|
|
@@ -1079,9 +1548,50 @@ async def lifespan(application: FastAPI):
|
|
|
1079
1548
|
else:
|
|
1080
1549
|
application.state.mesh_broker = None
|
|
1081
1550
|
except Exception as exc:
|
|
1082
|
-
logger.
|
|
1551
|
+
logger.warning("Mesh broker init failed: %s", exc)
|
|
1083
1552
|
application.state.mesh_broker = None
|
|
1084
1553
|
|
|
1554
|
+
# RBAC / teams (C3): user identity + role enforcement over memory.db.
|
|
1555
|
+
# Additive — with zero users the daemon stays single-operator (owner).
|
|
1556
|
+
try:
|
|
1557
|
+
from superlocalmemory.access.rbac import RbacEngine
|
|
1558
|
+
rbac_db = config.db_path if config else state_path("memory.db")
|
|
1559
|
+
rbac_engine = RbacEngine(str(rbac_db))
|
|
1560
|
+
rbac_engine.purge_expired_sessions()
|
|
1561
|
+
application.state.rbac = rbac_engine
|
|
1562
|
+
logger.info("RBAC engine ready (users=%d)", rbac_engine.user_count())
|
|
1563
|
+
except Exception as exc:
|
|
1564
|
+
logger.warning("RBAC engine init failed: %s", exc)
|
|
1565
|
+
application.state.rbac = None
|
|
1566
|
+
|
|
1567
|
+
# Deployment config (v3.8.0) — read [deployment] from config.toml and wire.
|
|
1568
|
+
# Additive / fail-open: personal defaults are a no-op so existing installs
|
|
1569
|
+
# with no [deployment] section behave EXACTLY as before.
|
|
1570
|
+
# ENFORCE rule: only UPGRADE a setting, NEVER downgrade an already-stronger
|
|
1571
|
+
# runtime setting (e.g. RBAC require_login already True → leave it alone).
|
|
1572
|
+
try:
|
|
1573
|
+
from superlocalmemory.core.config import load_deployment_config
|
|
1574
|
+
deployment = load_deployment_config()
|
|
1575
|
+
application.state.deployment = deployment
|
|
1576
|
+
if deployment.require_login:
|
|
1577
|
+
_dep_rbac = getattr(application.state, "rbac", None)
|
|
1578
|
+
if _dep_rbac is not None and not _dep_rbac.require_login():
|
|
1579
|
+
_dep_rbac.set_require_login(True)
|
|
1580
|
+
logger.info(
|
|
1581
|
+
"Deployment: require_login enforced via enterprise deployment config"
|
|
1582
|
+
)
|
|
1583
|
+
# TODO: Wire deployment.pii_redaction → PII redaction subsystem (WP-10)
|
|
1584
|
+
# TODO: Wire deployment.retention_enabled → retention scheduler (WP-11)
|
|
1585
|
+
logger.info(
|
|
1586
|
+
"Deployment config loaded: mode=%s require_login=%s "
|
|
1587
|
+
"pii=%s retention=%s audit=%s",
|
|
1588
|
+
deployment.mode, deployment.require_login,
|
|
1589
|
+
deployment.pii_redaction, deployment.retention_enabled, deployment.audit,
|
|
1590
|
+
)
|
|
1591
|
+
except Exception as _dep_exc:
|
|
1592
|
+
logger.warning("Deployment config wire failed (non-fatal): %s", _dep_exc)
|
|
1593
|
+
application.state.deployment = None
|
|
1594
|
+
|
|
1085
1595
|
# Start idle watchdog if configured
|
|
1086
1596
|
idle_timeout = int(os.environ.get("SLM_DAEMON_IDLE_TIMEOUT", "0"))
|
|
1087
1597
|
if config and hasattr(config, 'daemon_idle_timeout'):
|
|
@@ -1114,7 +1624,9 @@ async def lifespan(application: FastAPI):
|
|
|
1114
1624
|
try:
|
|
1115
1625
|
from superlocalmemory.cli.context_commands import build_default_adapters
|
|
1116
1626
|
from superlocalmemory.hooks.sync_loop import schedule as _schedule_sync
|
|
1117
|
-
|
|
1627
|
+
# Keep the task handle so it can be cancelled at shutdown (H-CONC-2)
|
|
1628
|
+
# — otherwise adapter file I/O outlives the daemon.
|
|
1629
|
+
application.state._sync_task = _schedule_sync(build_default_adapters())
|
|
1118
1630
|
except Exception as exc: # pragma: no cover — defensive
|
|
1119
1631
|
logger.warning("cross-platform sync loop failed to start: %s", exc)
|
|
1120
1632
|
|
|
@@ -1219,7 +1731,33 @@ async def lifespan(application: FastAPI):
|
|
|
1219
1731
|
application.state.daemon_descriptor = _publish_process_descriptor(
|
|
1220
1732
|
_configured_daemon_port(), SLM_VERSION, "ready",
|
|
1221
1733
|
)
|
|
1222
|
-
|
|
1734
|
+
_schedule_source_quality_repair(
|
|
1735
|
+
application,
|
|
1736
|
+
state_path("memory.db"),
|
|
1737
|
+
state_path("learning.db"),
|
|
1738
|
+
)
|
|
1739
|
+
_schedule_fact_entity_association_repair(
|
|
1740
|
+
application,
|
|
1741
|
+
state_path("memory.db"),
|
|
1742
|
+
)
|
|
1743
|
+
try:
|
|
1744
|
+
yield
|
|
1745
|
+
finally:
|
|
1746
|
+
await _cancel_fact_entity_association_repair(application)
|
|
1747
|
+
await _cancel_source_quality_repair(application)
|
|
1748
|
+
|
|
1749
|
+
# Cancel the cross-platform sync loop (H-CONC-2) so adapter file I/O does
|
|
1750
|
+
# not outlive the daemon.
|
|
1751
|
+
try:
|
|
1752
|
+
_sync_task = getattr(application.state, "_sync_task", None)
|
|
1753
|
+
if _sync_task is not None and not _sync_task.done():
|
|
1754
|
+
_sync_task.cancel()
|
|
1755
|
+
try:
|
|
1756
|
+
await _sync_task
|
|
1757
|
+
except asyncio.CancelledError:
|
|
1758
|
+
pass
|
|
1759
|
+
except Exception: # pragma: no cover — defensive
|
|
1760
|
+
pass
|
|
1223
1761
|
|
|
1224
1762
|
# Cancel optimize metrics flush loop + run final flush before shutdown
|
|
1225
1763
|
try:
|
|
@@ -1463,7 +2001,9 @@ def create_app() -> FastAPI:
|
|
|
1463
2001
|
allow_origins=[
|
|
1464
2002
|
"http://localhost:8765", "http://127.0.0.1:8765",
|
|
1465
2003
|
"http://localhost:8767", "http://127.0.0.1:8767", # legacy compat
|
|
1466
|
-
|
|
2004
|
+
# M-04 (3.7.9): removed the undocumented port 8417 origin — it had
|
|
2005
|
+
# no known consumer and let any local service on 8417 make
|
|
2006
|
+
# credentialed cross-origin requests to the API.
|
|
1467
2007
|
],
|
|
1468
2008
|
allow_credentials=True,
|
|
1469
2009
|
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
|
|
@@ -1568,13 +2108,19 @@ def create_app() -> FastAPI:
|
|
|
1568
2108
|
# toggled INDEPENDENTLY from the UI with no restart. UI save fires the
|
|
1569
2109
|
# callback immediately; external edits are caught by the 2s watchdog.
|
|
1570
2110
|
_opt_store.register_change_callback(_proxy.reload_from_config)
|
|
1571
|
-
_opt_store.start_watchdog()
|
|
1572
2111
|
logger.info(
|
|
1573
2112
|
"optimize.proxy mounted on /v1/*, /v1beta/* port=8765 "
|
|
1574
2113
|
"(runtime cache/compress hot-reload enabled)"
|
|
1575
2114
|
)
|
|
1576
2115
|
else:
|
|
1577
2116
|
application.state.optimize_proxy = None
|
|
2117
|
+
# H1 fix: the config watchdog must run REGARDLESS of proxy state so
|
|
2118
|
+
# optimize.json edits (cache/compress toggles, and a future
|
|
2119
|
+
# proxy_enabled flip) are picked up at runtime. Previously it only
|
|
2120
|
+
# started when the proxy was already on, so a daemon that booted with
|
|
2121
|
+
# the proxy off never saw any optimize.json change. start_watchdog()
|
|
2122
|
+
# is idempotent.
|
|
2123
|
+
_opt_store.start_watchdog()
|
|
1578
2124
|
except ImportError:
|
|
1579
2125
|
application.state.optimize_proxy = None
|
|
1580
2126
|
logger.debug("optimize.proxy not installed — skipping")
|
|
@@ -1610,6 +2156,14 @@ def create_app() -> FastAPI:
|
|
|
1610
2156
|
if _mcp_allowed:
|
|
1611
2157
|
from mcp.server.transport_security import TransportSecuritySettings
|
|
1612
2158
|
if _mcp_allowed == "*":
|
|
2159
|
+
# M-05 (3.7.9): "*" fully disables DNS-rebinding protection.
|
|
2160
|
+
# Never silent — a convenience setting in a CI/Docker env must
|
|
2161
|
+
# not quietly expose the instance.
|
|
2162
|
+
logger.warning(
|
|
2163
|
+
"SLM_MCP_ALLOWED_HOSTS=* disables MCP DNS-rebinding "
|
|
2164
|
+
"protection entirely. Prefer an explicit host list; only "
|
|
2165
|
+
"use '*' on a trusted private network."
|
|
2166
|
+
)
|
|
1613
2167
|
_mcp_fastmcp.settings.transport_security = TransportSecuritySettings(
|
|
1614
2168
|
enable_dns_rebinding_protection=False,
|
|
1615
2169
|
)
|
|
@@ -1688,22 +2242,47 @@ def _register_dashboard_routes(application: FastAPI) -> None:
|
|
|
1688
2242
|
_write_limiter = RateLimiter(max_requests=_rl_write, window_seconds=_rl_window)
|
|
1689
2243
|
_read_limiter = RateLimiter(max_requests=_rl_read, window_seconds=_rl_window)
|
|
1690
2244
|
|
|
1691
|
-
# S9-DASH-09: loopback (127.0.0.1 / ::1) is
|
|
1692
|
-
#
|
|
1693
|
-
#
|
|
1694
|
-
#
|
|
1695
|
-
#
|
|
1696
|
-
#
|
|
1697
|
-
|
|
1698
|
-
|
|
2245
|
+
# S9-DASH-09: loopback (127.0.0.1 / ::1) is the local dashboard and
|
|
2246
|
+
# makes many rapid reads (Brain + tabs + polling). L-03 (3.7.9): rather
|
|
2247
|
+
# than exempt loopback entirely, give it a *generous* limit — far above
|
|
2248
|
+
# normal UI polling — so a runaway local agent's write flood is still
|
|
2249
|
+
# eventually throttled. A LAN browser allowlisted in SLM_REMOTE mode
|
|
2250
|
+
# (is_rate_limit_exempt, non-loopback) stays fully exempt.
|
|
2251
|
+
_lb_write = max(300, _rl_write * 10)
|
|
2252
|
+
_lb_read = max(2000, _rl_read * 20)
|
|
2253
|
+
_lb_write_limiter = RateLimiter(max_requests=_lb_write, window_seconds=_rl_window)
|
|
2254
|
+
_lb_read_limiter = RateLimiter(max_requests=_lb_read, window_seconds=_rl_window)
|
|
2255
|
+
|
|
2256
|
+
# Task #47: register the live limiters so the dashboard PUT
|
|
2257
|
+
# /api/v3/ratelimit reconfigures them at runtime (no restart), then
|
|
2258
|
+
# apply any persisted override from config.json.
|
|
2259
|
+
try:
|
|
2260
|
+
from superlocalmemory.infra.rate_limiter import (
|
|
2261
|
+
register_managed as _reg_rl, reset_managed as _reset_rl,
|
|
2262
|
+
)
|
|
2263
|
+
_reset_rl()
|
|
2264
|
+
_reg_rl("write", _write_limiter)
|
|
2265
|
+
_reg_rl("read", _read_limiter)
|
|
2266
|
+
_reg_rl("lb_write", _lb_write_limiter)
|
|
2267
|
+
_reg_rl("lb_read", _lb_read_limiter)
|
|
2268
|
+
from superlocalmemory.server.routes.ratelimit import (
|
|
2269
|
+
load_persisted_limits as _load_rl,
|
|
2270
|
+
)
|
|
2271
|
+
_load_rl()
|
|
2272
|
+
except Exception as _reg_exc: # pragma: no cover - defensive
|
|
2273
|
+
logger.debug("rate-limit runtime registration skipped: %s", _reg_exc)
|
|
1699
2274
|
|
|
1700
2275
|
@application.middleware("http")
|
|
1701
2276
|
async def rate_limit_middleware(request, call_next):
|
|
1702
2277
|
client_ip = request.client.host if request.client else "unknown"
|
|
1703
|
-
if is_rate_limit_exempt(client_ip):
|
|
1704
|
-
return await call_next(request)
|
|
1705
2278
|
is_write = request.method in ("POST", "PUT", "DELETE", "PATCH")
|
|
1706
|
-
|
|
2279
|
+
loopback = client_ip in ("127.0.0.1", "::1")
|
|
2280
|
+
if not loopback and is_rate_limit_exempt(client_ip):
|
|
2281
|
+
return await call_next(request)
|
|
2282
|
+
if loopback:
|
|
2283
|
+
limiter = _lb_write_limiter if is_write else _lb_read_limiter
|
|
2284
|
+
else:
|
|
2285
|
+
limiter = _write_limiter if is_write else _read_limiter
|
|
1707
2286
|
allowed, remaining = limiter.is_allowed(client_ip)
|
|
1708
2287
|
if not allowed:
|
|
1709
2288
|
from fastapi.responses import JSONResponse
|
|
@@ -1760,19 +2339,35 @@ def _register_dashboard_routes(application: FastAPI) -> None:
|
|
|
1760
2339
|
"error": "Remote HTTP MCP requires a configured SLM API key."
|
|
1761
2340
|
},
|
|
1762
2341
|
)
|
|
1763
|
-
#
|
|
1764
|
-
#
|
|
1765
|
-
#
|
|
1766
|
-
#
|
|
1767
|
-
#
|
|
2342
|
+
# Defense-in-depth CSRF/DNS-rebinding guard. A loopback hostname is
|
|
2343
|
+
# not, by itself, a trusted web origin: a different local process can
|
|
2344
|
+
# serve a page on another port. Credentialless browser writes must
|
|
2345
|
+
# therefore originate from this daemon's exact port. A local
|
|
2346
|
+
# integration on another port may still write when it presents a
|
|
2347
|
+
# valid credential; require_http_mutation_actor below validates it.
|
|
2348
|
+
# LAN origins remain opt-in through remote mode. Non-browser clients
|
|
2349
|
+
# (CLI/MCP/curl) send no Origin and are unaffected.
|
|
1768
2350
|
if requires_mutation_actor:
|
|
1769
2351
|
_origin = headers.get("origin", "") or headers.get("Origin", "")
|
|
1770
2352
|
if _origin:
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
2353
|
+
from superlocalmemory.server.origin import (
|
|
2354
|
+
origin_is_daemon,
|
|
2355
|
+
origin_is_loopback,
|
|
2356
|
+
)
|
|
2357
|
+
|
|
2358
|
+
_daemon = getattr(application.state, "daemon_descriptor", None)
|
|
2359
|
+
_daemon_port = getattr(_daemon, "port", None) or _configured_daemon_port()
|
|
2360
|
+
_ok_origin = origin_is_daemon(_origin, port=int(_daemon_port))
|
|
2361
|
+
_has_browser_credential = any(
|
|
2362
|
+
headers.get(_header)
|
|
2363
|
+
for _header in (
|
|
2364
|
+
"x-slm-daemon-capability",
|
|
2365
|
+
"x-install-token",
|
|
2366
|
+
"x-slm-api-key",
|
|
2367
|
+
)
|
|
2368
|
+
)
|
|
2369
|
+
if not _ok_origin and origin_is_loopback(_origin) and _has_browser_credential:
|
|
2370
|
+
_ok_origin = True
|
|
1776
2371
|
if not _ok_origin:
|
|
1777
2372
|
from superlocalmemory.core.remote_mode import is_remote_origin_allowed
|
|
1778
2373
|
_ok_origin = is_remote_origin_allowed(_origin)
|
|
@@ -1848,6 +2443,19 @@ def _register_dashboard_routes(application: FastAPI) -> None:
|
|
|
1848
2443
|
)
|
|
1849
2444
|
},
|
|
1850
2445
|
)
|
|
2446
|
+
# RBAC read gate (C3/audit SEC-C-01): sensitive content reads must
|
|
2447
|
+
# also respect roles. Engages ONLY when RBAC is active (>=1 user) —
|
|
2448
|
+
# single-operator installs are unaffected. Owner (no session) reads
|
|
2449
|
+
# freely unless company mode (require_login) is on; a logged-in user
|
|
2450
|
+
# must hold READ on the active workspace.
|
|
2451
|
+
# Config, learning, and behavioral reads expose installation,
|
|
2452
|
+
# preference, workflow, source-reputation, or outcome data.
|
|
2453
|
+
if _is_sensitive_dashboard_read(
|
|
2454
|
+
request.method, request.url.path,
|
|
2455
|
+
):
|
|
2456
|
+
_resp = _rbac_read_gate(request, application.state)
|
|
2457
|
+
if _resp is not None:
|
|
2458
|
+
return _resp
|
|
1851
2459
|
return await call_next(request)
|
|
1852
2460
|
except Exception as _auth_exc:
|
|
1853
2461
|
# v3.6.12 (failopen-1): security middleware must NEVER fail open silently.
|
|
@@ -1920,6 +2528,21 @@ def _register_dashboard_routes(application: FastAPI) -> None:
|
|
|
1920
2528
|
application.include_router(v3_router)
|
|
1921
2529
|
application.include_router(adapters_router)
|
|
1922
2530
|
|
|
2531
|
+
# RBAC / teams (C3) — user & role administration + login.
|
|
2532
|
+
try:
|
|
2533
|
+
from superlocalmemory.server.routes.rbac import router as rbac_router
|
|
2534
|
+
application.include_router(rbac_router)
|
|
2535
|
+
except ImportError:
|
|
2536
|
+
logger.debug("rbac_router not available")
|
|
2537
|
+
|
|
2538
|
+
# Config endpoints (storage, daemon, mesh, trust, forgetting)
|
|
2539
|
+
from superlocalmemory.server.routes.config_api import router as config_api_router
|
|
2540
|
+
application.include_router(config_api_router)
|
|
2541
|
+
|
|
2542
|
+
# Task #47: dashboard-editable rate limits (GET/PUT /api/v3/ratelimit)
|
|
2543
|
+
from superlocalmemory.server.routes.ratelimit import router as ratelimit_router
|
|
2544
|
+
application.include_router(ratelimit_router)
|
|
2545
|
+
|
|
1923
2546
|
# v3.4.1 chat SSE
|
|
1924
2547
|
for _mod_name in ("chat",):
|
|
1925
2548
|
try:
|
|
@@ -1931,7 +2554,7 @@ def _register_dashboard_routes(application: FastAPI) -> None:
|
|
|
1931
2554
|
pass
|
|
1932
2555
|
|
|
1933
2556
|
# Optional routers
|
|
1934
|
-
for _mod_name in ("learning", "lifecycle", "behavioral", "compliance", "insights", "timeline"):
|
|
2557
|
+
for _mod_name in ("learning", "lifecycle", "behavioral", "compliance", "insights", "timeline", "abstraction"):
|
|
1935
2558
|
try:
|
|
1936
2559
|
_mod = __import__(
|
|
1937
2560
|
f"superlocalmemory.server.routes.{_mod_name}", fromlist=["router"],
|
|
@@ -2056,7 +2679,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
2056
2679
|
)
|
|
2057
2680
|
|
|
2058
2681
|
@application.get("/health")
|
|
2059
|
-
async def health():
|
|
2682
|
+
async def health(request: Request = None):
|
|
2060
2683
|
_update_activity()
|
|
2061
2684
|
# Non-blocking peek: report status without forcing a re-init.
|
|
2062
2685
|
engine = getattr(application.state, "engine", None)
|
|
@@ -2090,6 +2713,27 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
2090
2713
|
from superlocalmemory.server.profile_runtime import get_profile_runtime
|
|
2091
2714
|
|
|
2092
2715
|
profile_snapshot = get_profile_runtime(application.state).snapshot
|
|
2716
|
+
# H-05 (3.7.9): operational metadata (pid, daemon identity including
|
|
2717
|
+
# capability_fingerprint/instance_id, active profile, readiness detail)
|
|
2718
|
+
# is returned only to loopback callers. A remote or unauthenticated
|
|
2719
|
+
# probe receives liveness fields only, so it cannot harvest targeting
|
|
2720
|
+
# intel (version stays, since clients legitimately gate on it).
|
|
2721
|
+
public = {
|
|
2722
|
+
"status": "ok",
|
|
2723
|
+
"ready": fully_ready,
|
|
2724
|
+
# Runtime readiness is more precise than descriptor lifecycle.
|
|
2725
|
+
# A process can be alive and identity-valid while retrieval warms.
|
|
2726
|
+
"state": runtime_state,
|
|
2727
|
+
"version": getattr(application, 'version', 'unknown'),
|
|
2728
|
+
}
|
|
2729
|
+
# request is None only for direct internal/test calls (no HTTP client),
|
|
2730
|
+
# which are trusted; over HTTP FastAPI always injects the real Request.
|
|
2731
|
+
client_host = request.client.host if (request and request.client) else ""
|
|
2732
|
+
_trusted = request is None or client_host in (
|
|
2733
|
+
"127.0.0.1", "::1", "localhost", "testclient",
|
|
2734
|
+
)
|
|
2735
|
+
if not _trusted:
|
|
2736
|
+
return public
|
|
2093
2737
|
return {
|
|
2094
2738
|
"status": "ok",
|
|
2095
2739
|
"ready": fully_ready,
|
|
@@ -2104,8 +2748,9 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
2104
2748
|
# health probe; includes self-heal counters.
|
|
2105
2749
|
"recall_health": _recall_health,
|
|
2106
2750
|
**(identity.public_health_fields() if identity is not None else {}),
|
|
2107
|
-
#
|
|
2108
|
-
#
|
|
2751
|
+
# runtime_state must come AFTER the identity spread so the live
|
|
2752
|
+
# readiness state wins over the descriptor's last-known lifecycle
|
|
2753
|
+
# value (which can be "starting").
|
|
2109
2754
|
"state": runtime_state,
|
|
2110
2755
|
"active_profile": profile_snapshot.profile_id,
|
|
2111
2756
|
"profile_generation": profile_snapshot.generation,
|
|
@@ -2116,11 +2761,12 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
2116
2761
|
request: Request,
|
|
2117
2762
|
q: str = "", query: str = "", limit: int = CANONICAL_RECALL_LIMIT,
|
|
2118
2763
|
session_id: str = "",
|
|
2119
|
-
fast: bool =
|
|
2764
|
+
fast: bool = True,
|
|
2120
2765
|
full: bool = False,
|
|
2121
2766
|
include_source: bool = False,
|
|
2122
2767
|
include_global: bool | None = None,
|
|
2123
2768
|
include_shared: bool | None = None,
|
|
2769
|
+
window: str = "",
|
|
2124
2770
|
):
|
|
2125
2771
|
_update_activity()
|
|
2126
2772
|
search_query = q or query # Accept both ?q= and ?query= for compatibility
|
|
@@ -2154,7 +2800,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
2154
2800
|
# recall (reranker timeout, cold embedder) blocks ALL endpoints.
|
|
2155
2801
|
import asyncio
|
|
2156
2802
|
_begin_recall()
|
|
2157
|
-
# v3.4.53:
|
|
2803
|
+
# v3.4.53: Opt-in deep recalls are gated by a semaphore to
|
|
2158
2804
|
# prevent resource oversaturation. Ollama serialises concurrent
|
|
2159
2805
|
# embedding calls and the reranker subprocess has a single lock —
|
|
2160
2806
|
# queuing more than ~3 concurrent full recalls just adds latency.
|
|
@@ -2170,6 +2816,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
2170
2816
|
fast=fast,
|
|
2171
2817
|
include_global=include_global,
|
|
2172
2818
|
include_shared=include_shared,
|
|
2819
|
+
window=window or None,
|
|
2173
2820
|
)
|
|
2174
2821
|
# v3.4.26: return the same field shape as recall_worker so
|
|
2175
2822
|
# MCP processes proxying through the daemon get recall_trace-
|
|
@@ -2179,7 +2826,10 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
2179
2826
|
if r.fact.memory_id
|
|
2180
2827
|
})
|
|
2181
2828
|
memory_map = (
|
|
2182
|
-
engine._db.get_memory_content_batch(
|
|
2829
|
+
engine._db.get_memory_content_batch(
|
|
2830
|
+
memory_ids, engine.profile_id,
|
|
2831
|
+
include_global=True, include_shared=True,
|
|
2832
|
+
)
|
|
2183
2833
|
if memory_ids else {}
|
|
2184
2834
|
)
|
|
2185
2835
|
# v3.6.6: single shared serialization chokepoint — budget + source
|
|
@@ -2267,7 +2917,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
2267
2917
|
if isinstance(extra, dict):
|
|
2268
2918
|
meta.update(extra)
|
|
2269
2919
|
command = build_engine_ingestion_command(engine)
|
|
2270
|
-
|
|
2920
|
+
ingestion_request = IngestionRequest(
|
|
2271
2921
|
content=req.content,
|
|
2272
2922
|
profile_id=engine._profile_id,
|
|
2273
2923
|
source_type="http",
|
|
@@ -2277,9 +2927,42 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
2277
2927
|
shared_with=tuple(shared_with or ()),
|
|
2278
2928
|
trusted_actor_id=trusted_actor_id,
|
|
2279
2929
|
session_id=req.session_id,
|
|
2280
|
-
)
|
|
2930
|
+
)
|
|
2931
|
+
# SQLite admission is usually milliseconds, but it can wait on a
|
|
2932
|
+
# concurrent migration or writer. Keep that wait out of ASGI so
|
|
2933
|
+
# dashboard navigation and recall stay responsive.
|
|
2934
|
+
receipt = await asyncio.to_thread(command.submit, ingestion_request)
|
|
2935
|
+
result = receipt
|
|
2936
|
+
wait_budget_exhausted = False
|
|
2937
|
+
if wait:
|
|
2938
|
+
materialization_task = asyncio.create_task(
|
|
2939
|
+
asyncio.to_thread(command.materialize, receipt.operation_id)
|
|
2940
|
+
)
|
|
2941
|
+
try:
|
|
2942
|
+
result = await asyncio.wait_for(
|
|
2943
|
+
asyncio.shield(materialization_task),
|
|
2944
|
+
timeout=_REMEMBER_ENRICHMENT_WAIT_SECONDS,
|
|
2945
|
+
)
|
|
2946
|
+
except TimeoutError:
|
|
2947
|
+
# The task retains the M018 lease and continues outside
|
|
2948
|
+
# this request. Return the durable receipt honestly;
|
|
2949
|
+
# the normal materializer can also reclaim it after a
|
|
2950
|
+
# lease expiry if the request-owned worker dies.
|
|
2951
|
+
wait_budget_exhausted = True
|
|
2952
|
+
|
|
2953
|
+
def _log_background_materialization(task):
|
|
2954
|
+
try:
|
|
2955
|
+
task.result()
|
|
2956
|
+
except Exception as exc:
|
|
2957
|
+
logger.warning(
|
|
2958
|
+
"bounded remember enrichment failed for %s: %s",
|
|
2959
|
+
receipt.operation_id,
|
|
2960
|
+
exc,
|
|
2961
|
+
)
|
|
2281
2962
|
|
|
2282
|
-
|
|
2963
|
+
materialization_task.add_done_callback(
|
|
2964
|
+
_log_background_materialization
|
|
2965
|
+
)
|
|
2283
2966
|
fact_ids = list(result.fact_ids)
|
|
2284
2967
|
# The queryable write is a separate durable transaction. A cold
|
|
2285
2968
|
# optional enrichment dependency (most often the local embedding
|
|
@@ -2322,10 +3005,13 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
2322
3005
|
"note": (
|
|
2323
3006
|
"canonical ingestion complete"
|
|
2324
3007
|
if completed
|
|
3008
|
+
else "queryable now; enrichment continues after the wait budget"
|
|
3009
|
+
if wait_budget_exhausted
|
|
2325
3010
|
else "queryable now; canonical enrichment will retry"
|
|
2326
3011
|
if enrichment_deferred
|
|
2327
3012
|
else "queryable now; canonical enrichment pending"
|
|
2328
3013
|
),
|
|
3014
|
+
"wait_budget_exhausted": wait_budget_exhausted,
|
|
2329
3015
|
}
|
|
2330
3016
|
except Exception as exc:
|
|
2331
3017
|
raise HTTPException(500, detail=str(exc))
|
|
@@ -2404,7 +3090,8 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
2404
3090
|
maint_result = _run_maint(engine._db, engine._config, pid)
|
|
2405
3091
|
results["langevin"] = {"updated": maint_result.get("updated", 0)}
|
|
2406
3092
|
except Exception as exc:
|
|
2407
|
-
|
|
3093
|
+
logger.exception("maintenance langevin step failed")
|
|
3094
|
+
results["langevin"] = {"error": "internal error"}
|
|
2408
3095
|
try:
|
|
2409
3096
|
from superlocalmemory.math.ebbinghaus import EbbinghausCurve
|
|
2410
3097
|
from superlocalmemory.learning.forgetting_scheduler import (
|
|
@@ -2416,7 +3103,8 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
2416
3103
|
)
|
|
2417
3104
|
results["forgetting"] = sched.run_decay_cycle(pid, force=False)
|
|
2418
3105
|
except Exception as exc:
|
|
2419
|
-
|
|
3106
|
+
logger.exception("maintenance forgetting step failed")
|
|
3107
|
+
results["forgetting"] = {"error": "internal error"}
|
|
2420
3108
|
try:
|
|
2421
3109
|
from superlocalmemory.learning.consolidation_worker import (
|
|
2422
3110
|
ConsolidationWorker,
|
|
@@ -2428,7 +3116,8 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
2428
3116
|
count = cw._generate_patterns(pid, False)
|
|
2429
3117
|
results["behavioral"] = {"patterns_mined": count}
|
|
2430
3118
|
except Exception as exc:
|
|
2431
|
-
|
|
3119
|
+
logger.exception("maintenance behavioral step failed")
|
|
3120
|
+
results["behavioral"] = {"error": "internal error"}
|
|
2432
3121
|
authorization.complete()
|
|
2433
3122
|
return {"ok": True, "profile": pid, **results}
|
|
2434
3123
|
except HTTPException:
|
|
@@ -2520,6 +3209,46 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
2520
3209
|
os.kill(os.getpid(), signal.SIGTERM)
|
|
2521
3210
|
return {"status": "stopping"}
|
|
2522
3211
|
|
|
3212
|
+
@application.post("/api/daemon/restart")
|
|
3213
|
+
async def restart_daemon(request: Request):
|
|
3214
|
+
"""Restart the daemon from the dashboard (non-technical end users).
|
|
3215
|
+
|
|
3216
|
+
Spawns a DETACHED ``slm restart`` process (its own session) so it
|
|
3217
|
+
survives this daemon being stopped, then returns immediately. The child
|
|
3218
|
+
stops this daemon and starts a fresh one via the standard 5-step
|
|
3219
|
+
pipeline (namespace lock → stop → start → warmup → verify).
|
|
3220
|
+
"""
|
|
3221
|
+
# Dashboard-callable: accept the install-token principal (same auth as
|
|
3222
|
+
# /remember and other dashboard mutations), not just the private CLI
|
|
3223
|
+
# capability — a non-technical user restarts from their own dashboard.
|
|
3224
|
+
_require_write_actor(request)
|
|
3225
|
+
import subprocess
|
|
3226
|
+
import sys as _sys
|
|
3227
|
+
from superlocalmemory.core.platform_utils import popen_platform_kwargs
|
|
3228
|
+
|
|
3229
|
+
logger.info("Daemon restart requested via API")
|
|
3230
|
+
_observe_buffer.flush_sync()
|
|
3231
|
+
try:
|
|
3232
|
+
subprocess.Popen(
|
|
3233
|
+
[_sys.executable, "-m", "superlocalmemory.cli.main", "restart",
|
|
3234
|
+
"--json"],
|
|
3235
|
+
stdout=subprocess.DEVNULL,
|
|
3236
|
+
stderr=subprocess.DEVNULL,
|
|
3237
|
+
# CP-04: use the shared platform kwargs (CREATE_NO_WINDOW on
|
|
3238
|
+
# Windows, start_new_session on POSIX) like every other Popen so
|
|
3239
|
+
# a GUI-triggered restart never flashes a console window.
|
|
3240
|
+
# close_fds defaults to True on all platforms.
|
|
3241
|
+
**popen_platform_kwargs(),
|
|
3242
|
+
)
|
|
3243
|
+
except Exception:
|
|
3244
|
+
logger.exception("Failed to spawn restart process")
|
|
3245
|
+
return {"success": False, "error": "Could not initiate restart"}
|
|
3246
|
+
return {
|
|
3247
|
+
"success": True,
|
|
3248
|
+
"status": "restarting",
|
|
3249
|
+
"message": "Daemon restart initiated — it will be back in a few seconds.",
|
|
3250
|
+
}
|
|
3251
|
+
|
|
2523
3252
|
@application.post("/session/open")
|
|
2524
3253
|
async def session_open(req: SessionOpenRequest, request: Request):
|
|
2525
3254
|
"""#49: Open a session locally — warm recall context with no model
|
|
@@ -2675,7 +3404,17 @@ def _materializer_actor_id() -> str:
|
|
|
2675
3404
|
|
|
2676
3405
|
|
|
2677
3406
|
def _run_materializer_operation(runtime, engine_supplier, operation):
|
|
2678
|
-
"""Run one bounded background unit against an admitted engine snapshot.
|
|
3407
|
+
"""Run one bounded background unit against an admitted engine snapshot.
|
|
3408
|
+
|
|
3409
|
+
Cooperative preemption: if a profile transition is already in progress,
|
|
3410
|
+
skip this materialization cycle entirely and return None. The caller's
|
|
3411
|
+
loop retries on the next iteration, by which time the switch has committed
|
|
3412
|
+
and a clean admission is available. This prevents the materializer from
|
|
3413
|
+
holding the operation lease during the transition drain window.
|
|
3414
|
+
"""
|
|
3415
|
+
# Writer-priority: don't acquire a new lease when a transition is draining.
|
|
3416
|
+
if runtime is not None and runtime.transitioning:
|
|
3417
|
+
return None
|
|
2679
3418
|
with runtime.operation():
|
|
2680
3419
|
# Resolve the engine only after admission. A concurrent mode/provider
|
|
2681
3420
|
# reconfiguration may have replaced the module-level engine while this
|
|
@@ -2831,7 +3570,11 @@ def _start_pending_materializer() -> None:
|
|
|
2831
3570
|
),
|
|
2832
3571
|
)
|
|
2833
3572
|
durable_complete, durable_failed = cycle_result or (0, 0)
|
|
2834
|
-
pending
|
|
3573
|
+
# Only backfill legacy pending items enqueued under the active
|
|
3574
|
+
# profile — never materialize another profile's queued memory
|
|
3575
|
+
# under whichever profile happens to be active now.
|
|
3576
|
+
_active_profile = runtime.snapshot.profile_id
|
|
3577
|
+
pending = get_pending(limit=50, profile_id=_active_profile)
|
|
2835
3578
|
if not pending and not durable_complete and not durable_failed:
|
|
2836
3579
|
time.sleep(1.0)
|
|
2837
3580
|
continue
|
|
@@ -2915,6 +3658,18 @@ def start_server(port: int = _DEFAULT_PORT) -> None:
|
|
|
2915
3658
|
or os.environ.get("SLM_HOST")
|
|
2916
3659
|
or "127.0.0.1"
|
|
2917
3660
|
)
|
|
3661
|
+
# M-01 / L-02 (3.7.9): binding beyond loopback exposes the write API to the
|
|
3662
|
+
# network, where the loopback trusted-actor bypass no longer protects it.
|
|
3663
|
+
# Warn loudly unless the operator has opted into credential enforcement.
|
|
3664
|
+
if bind_host not in ("127.0.0.1", "::1", "localhost") and \
|
|
3665
|
+
os.environ.get("SLM_REQUIRE_CREDENTIALS") != "1":
|
|
3666
|
+
logger.warning(
|
|
3667
|
+
"SLM daemon binding to %s (non-loopback): the write API is "
|
|
3668
|
+
"reachable from the network but SLM_REQUIRE_CREDENTIALS is not set "
|
|
3669
|
+
"and API-key auth may be off. A remote caller could write without "
|
|
3670
|
+
"credentials. Set SLM_REQUIRE_CREDENTIALS=1 and configure an API "
|
|
3671
|
+
"key before exposing this instance.", bind_host,
|
|
3672
|
+
)
|
|
2918
3673
|
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
2919
3674
|
# This handles a just-closed connection in TIME_WAIT. It is safe only
|
|
2920
3675
|
# with the active-listener probe immediately below; without that guard,
|