superlocalmemory 3.6.22 → 3.7.0
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/CHANGELOG.md +52 -0
- package/README.md +275 -72
- package/bin/slm-npm +43 -89
- package/docs/pi-dev-integration.md +43 -0
- package/ide/configs/antigravity-mcp.json +2 -2
- package/ide/configs/chatgpt-desktop-mcp.json +1 -1
- package/ide/configs/claude-desktop-mcp.json +2 -2
- package/ide/configs/windsurf-mcp.json +2 -2
- package/ide/hooks/context-hook.js +6 -2
- package/ide/hooks/post-recall-hook.js +7 -3
- package/ide/hooks/tool-event-hook.sh +2 -1
- package/package.json +19 -10
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/_GENERATED.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/pyproject.toml +40 -8
- package/scripts/postinstall-interactive.js +17 -94
- package/scripts/postinstall.js +185 -258
- package/scripts/preuninstall.js +9 -50
- package/src/superlocalmemory/__init__.py +2 -2
- package/src/superlocalmemory/attribution/mathematical_dna.py +1 -1
- package/src/superlocalmemory/attribution/signer.py +34 -19
- package/src/superlocalmemory/attribution/watermark.py +1 -1
- package/src/superlocalmemory/cli/_lazy_init.py +3 -5
- package/src/superlocalmemory/cli/commands.py +490 -195
- package/src/superlocalmemory/cli/context_commands.py +5 -4
- package/src/superlocalmemory/cli/daemon.py +282 -187
- package/src/superlocalmemory/cli/db_migrate.py +3 -1
- package/src/superlocalmemory/cli/diagnostics_cmd.py +28 -0
- package/src/superlocalmemory/cli/evidence_cmd.py +103 -0
- package/src/superlocalmemory/cli/ingest_cmd.py +7 -3
- package/src/superlocalmemory/cli/main.py +128 -31
- package/src/superlocalmemory/cli/pending_store.py +54 -38
- package/src/superlocalmemory/cli/scale_engine_cmd.py +37 -0
- package/src/superlocalmemory/cli/service_installer.py +57 -52
- package/src/superlocalmemory/cli/setup_wizard.py +142 -88
- package/src/superlocalmemory/cli/version_banner.py +2 -1
- package/src/superlocalmemory/code_graph/config.py +3 -1
- package/src/superlocalmemory/core/backend_orchestrator.py +81 -21
- package/src/superlocalmemory/core/config.py +65 -20
- package/src/superlocalmemory/core/consolidation_engine.py +9 -7
- package/src/superlocalmemory/core/context_cache.py +56 -8
- package/src/superlocalmemory/core/derivation_lineage.py +246 -0
- package/src/superlocalmemory/core/embedding_worker.py +32 -20
- package/src/superlocalmemory/core/embeddings.py +54 -18
- package/src/superlocalmemory/core/engine.py +150 -104
- package/src/superlocalmemory/core/engine_ingestion.py +513 -0
- package/src/superlocalmemory/core/engine_wiring.py +2 -0
- package/src/superlocalmemory/core/evidence_bundle.py +526 -0
- package/src/superlocalmemory/core/fact_consolidator.py +5 -11
- package/src/superlocalmemory/core/graph_analyzer.py +2 -2
- package/src/superlocalmemory/core/health_monitor.py +4 -2
- package/src/superlocalmemory/core/ingestion_command.py +636 -0
- package/src/superlocalmemory/core/injection.py +69 -18
- package/src/superlocalmemory/core/lifecycle_state.py +153 -0
- package/src/superlocalmemory/core/maintenance.py +23 -22
- package/src/superlocalmemory/core/maintenance_scheduler.py +51 -35
- package/src/superlocalmemory/core/mutations.py +143 -0
- package/src/superlocalmemory/core/platform_utils.py +7 -4
- package/src/superlocalmemory/core/ram_lock.py +16 -5
- package/src/superlocalmemory/core/rate_limit.py +1 -1
- package/src/superlocalmemory/core/recall_pipeline.py +60 -101
- package/src/superlocalmemory/core/recall_worker.py +76 -59
- package/src/superlocalmemory/core/registry.py +1 -1
- package/src/superlocalmemory/core/scale_engine.py +293 -0
- package/src/superlocalmemory/core/score_contract.py +62 -0
- package/src/superlocalmemory/core/security_primitives.py +3 -1
- package/src/superlocalmemory/core/slm_disabled.py +3 -5
- package/src/superlocalmemory/core/store_pipeline.py +172 -40
- package/src/superlocalmemory/core/tier_manager.py +32 -20
- package/src/superlocalmemory/core/worker_pool.py +13 -4
- package/src/superlocalmemory/dynamics/activation_guided_quantization.py +1 -1
- package/src/superlocalmemory/dynamics/eap_scheduler.py +10 -3
- package/src/superlocalmemory/dynamics/ebbinghaus_langevin_coupling.py +1 -1
- package/src/superlocalmemory/dynamics/fisher_langevin_coupling.py +1 -1
- package/src/superlocalmemory/encoding/auto_linker.py +1 -1
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +7 -16
- package/src/superlocalmemory/encoding/consolidator.py +22 -5
- package/src/superlocalmemory/encoding/fact_extractor.py +1 -1
- package/src/superlocalmemory/encoding/foresight.py +2 -0
- package/src/superlocalmemory/encoding/graph_builder.py +1 -1
- package/src/superlocalmemory/encoding/temporal_parser.py +2 -0
- package/src/superlocalmemory/evaluation/__init__.py +13 -0
- package/src/superlocalmemory/evaluation/calibration.py +308 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +2 -1
- package/src/superlocalmemory/graph/cozo_backend.py +256 -23
- package/src/superlocalmemory/hooks/_outcome_common.py +21 -11
- package/src/superlocalmemory/hooks/antigravity_adapter.py +10 -31
- package/src/superlocalmemory/hooks/auto_invoker.py +25 -27
- package/src/superlocalmemory/hooks/auto_recall.py +31 -6
- package/src/superlocalmemory/hooks/auto_recall_hook.py +13 -33
- package/src/superlocalmemory/hooks/before_web_hook.py +9 -7
- package/src/superlocalmemory/hooks/claude_code_hooks.py +126 -39
- package/src/superlocalmemory/hooks/codex_assets.py +59 -0
- package/src/superlocalmemory/hooks/codex_hooks.py +186 -0
- package/src/superlocalmemory/hooks/context_payload.py +1 -1
- package/src/superlocalmemory/hooks/copilot_adapter.py +9 -24
- package/src/superlocalmemory/hooks/cursor_adapter.py +10 -32
- package/src/superlocalmemory/hooks/hook_daemon.py +4 -2
- package/src/superlocalmemory/hooks/hook_handlers.py +241 -55
- package/src/superlocalmemory/hooks/memory_protocol.py +5 -3
- package/src/superlocalmemory/hooks/post_tool_async_hook.py +4 -2
- package/src/superlocalmemory/hooks/session_registry.py +15 -8
- package/src/superlocalmemory/hooks/stop_outcome_hook.py +10 -6
- package/src/superlocalmemory/hooks/topic_shift_hook.py +42 -12
- package/src/superlocalmemory/hooks/user_prompt_hook.py +9 -14
- package/src/superlocalmemory/hooks/user_prompt_rehash_hook.py +19 -11
- package/src/superlocalmemory/infra/auth_middleware.py +38 -5
- package/src/superlocalmemory/infra/backup.py +7 -5
- package/src/superlocalmemory/infra/cloud_backup.py +18 -8
- package/src/superlocalmemory/infra/daemon_identity.py +248 -0
- package/src/superlocalmemory/infra/data_root.py +199 -0
- package/src/superlocalmemory/infra/event_bus.py +3 -1
- package/src/superlocalmemory/infra/local_diagnostics.py +327 -0
- package/src/superlocalmemory/infra/process_reaper.py +23 -0
- package/src/superlocalmemory/ingestion/adapter_manager.py +27 -9
- package/src/superlocalmemory/ingestion/base_adapter.py +25 -31
- package/src/superlocalmemory/ingestion/calendar_adapter.py +13 -4
- package/src/superlocalmemory/ingestion/credentials.py +14 -7
- package/src/superlocalmemory/ingestion/gmail_adapter.py +13 -4
- package/src/superlocalmemory/ingestion/transcript_adapter.py +7 -2
- package/src/superlocalmemory/learning/consolidation_quantization_worker.py +1 -1
- package/src/superlocalmemory/learning/ensemble.py +11 -0
- package/src/superlocalmemory/learning/entity_compiler.py +1 -1
- package/src/superlocalmemory/learning/feedback.py +1 -1
- package/src/superlocalmemory/learning/forgetting_scheduler.py +12 -7
- package/src/superlocalmemory/learning/quantization_scheduler.py +1 -1
- package/src/superlocalmemory/learning/ranker.py +4 -1
- package/src/superlocalmemory/learning/source_quality.py +1 -1
- package/src/superlocalmemory/learning/trigram_index.py +3 -2
- package/src/superlocalmemory/llm/backbone.py +13 -8
- package/src/superlocalmemory/math/ebbinghaus.py +1 -1
- package/src/superlocalmemory/math/fisher.py +1 -1
- package/src/superlocalmemory/math/fisher_quantized.py +1 -1
- package/src/superlocalmemory/math/hopfield.py +1 -1
- package/src/superlocalmemory/math/langevin.py +1 -1
- package/src/superlocalmemory/math/polar_quant.py +3 -4
- package/src/superlocalmemory/math/qjl.py +1 -1
- package/src/superlocalmemory/math/sheaf.py +1 -1
- package/src/superlocalmemory/math/turbo_quant.py +3 -2
- package/src/superlocalmemory/mcp/_daemon_proxy.py +12 -11
- package/src/superlocalmemory/mcp/_pool_adapter.py +27 -0
- package/src/superlocalmemory/mcp/http_transport.py +53 -0
- package/src/superlocalmemory/mcp/server.py +39 -13
- package/src/superlocalmemory/mcp/shared.py +69 -3
- package/src/superlocalmemory/mcp/tools_active.py +141 -31
- package/src/superlocalmemory/mcp/tools_core.py +128 -29
- package/src/superlocalmemory/mcp/tools_evolution.py +5 -7
- package/src/superlocalmemory/mcp/tools_learning.py +42 -2
- package/src/superlocalmemory/mcp/tools_mesh.py +7 -23
- package/src/superlocalmemory/mcp/tools_optimize.py +8 -1
- package/src/superlocalmemory/mcp/tools_v28.py +23 -2
- package/src/superlocalmemory/mcp/tools_v3.py +26 -1
- package/src/superlocalmemory/mcp/tools_v33.py +56 -17
- package/src/superlocalmemory/mesh/broker.py +2 -0
- package/src/superlocalmemory/mesh/remote_sync.py +50 -12
- package/src/superlocalmemory/optimize/cache/manager.py +77 -1
- package/src/superlocalmemory/optimize/cache/semantic.py +23 -3
- package/src/superlocalmemory/optimize/compress/ccr.py +4 -0
- package/src/superlocalmemory/optimize/compress/router.py +6 -1
- package/src/superlocalmemory/optimize/config/__init__.py +5 -0
- package/src/superlocalmemory/optimize/config/store.py +6 -4
- package/src/superlocalmemory/optimize/proxy/_helpers.py +15 -5
- package/src/superlocalmemory/optimize/proxy/capture.py +3 -2
- package/src/superlocalmemory/optimize/proxy/server.py +2 -2
- package/src/superlocalmemory/optimize/storage/db.py +14 -13
- package/src/superlocalmemory/retrieval/agentic.py +1 -1
- package/src/superlocalmemory/retrieval/ann_index.py +1 -1
- package/src/superlocalmemory/retrieval/bm25_channel.py +35 -11
- package/src/superlocalmemory/retrieval/bridge_discovery.py +73 -8
- package/src/superlocalmemory/retrieval/engine.py +169 -79
- package/src/superlocalmemory/retrieval/entity_channel.py +289 -67
- package/src/superlocalmemory/retrieval/forgetting_filter.py +1 -1
- package/src/superlocalmemory/retrieval/fusion.py +1 -1
- package/src/superlocalmemory/retrieval/hopfield_channel.py +118 -30
- package/src/superlocalmemory/retrieval/profile_channel.py +1 -1
- package/src/superlocalmemory/retrieval/quantization_aware_search.py +16 -10
- package/src/superlocalmemory/retrieval/reranker.py +56 -20
- package/src/superlocalmemory/retrieval/scope_policy.py +85 -0
- package/src/superlocalmemory/retrieval/semantic_channel.py +122 -14
- package/src/superlocalmemory/retrieval/spreading_activation.py +141 -25
- package/src/superlocalmemory/retrieval/strategy.py +1 -1
- package/src/superlocalmemory/retrieval/temporal_channel.py +30 -15
- package/src/superlocalmemory/retrieval/vector_store.py +1 -1
- package/src/superlocalmemory/server/api.py +10 -7
- package/src/superlocalmemory/server/bandit_loops.py +4 -2
- package/src/superlocalmemory/server/recall_serializer.py +24 -0
- package/src/superlocalmemory/server/route_mutations.py +84 -0
- package/src/superlocalmemory/server/routes/agents.py +8 -6
- package/src/superlocalmemory/server/routes/brain.py +14 -12
- package/src/superlocalmemory/server/routes/chat.py +29 -12
- package/src/superlocalmemory/server/routes/data_io.py +55 -24
- package/src/superlocalmemory/server/routes/helpers.py +29 -4
- package/src/superlocalmemory/server/routes/ingest.py +53 -36
- package/src/superlocalmemory/server/routes/memories.py +104 -43
- package/src/superlocalmemory/server/routes/mesh.py +31 -0
- package/src/superlocalmemory/server/routes/profiles.py +26 -4
- package/src/superlocalmemory/server/routes/tiers.py +43 -11
- package/src/superlocalmemory/server/routes/timeline.py +5 -1
- package/src/superlocalmemory/server/routes/v3_api.py +76 -21
- package/src/superlocalmemory/server/security_middleware.py +1 -1
- package/src/superlocalmemory/server/ui.py +6 -3
- package/src/superlocalmemory/server/unified_daemon.py +680 -293
- package/src/superlocalmemory/server/write_identity.py +147 -0
- package/src/superlocalmemory/storage/access_log.py +4 -3
- package/src/superlocalmemory/storage/database.py +118 -25
- package/src/superlocalmemory/storage/migration_runner.py +84 -1
- package/src/superlocalmemory/storage/migration_v33.py +1 -1
- package/src/superlocalmemory/storage/migrations/M002_model_state_history.py +6 -60
- package/src/superlocalmemory/storage/migrations/M018_ingestion_operations.py +120 -0
- package/src/superlocalmemory/storage/migrations/M019_derivation_lineage.py +54 -0
- package/src/superlocalmemory/storage/migrations/M020_model_state_integrity.py +52 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +5 -0
- package/src/superlocalmemory/storage/models.py +16 -0
- package/src/superlocalmemory/storage/quantized_store.py +20 -3
- package/src/superlocalmemory/storage/v2_migrator.py +5 -3
- package/src/superlocalmemory/ui/favicon.svg +5 -0
- package/src/superlocalmemory/ui/index.html +1 -0
- package/src/superlocalmemory/ui/js/compliance.js +1 -1
- package/src/superlocalmemory/ui/js/core.js +49 -8
- package/src/superlocalmemory/ui/js/dashboard.js +23 -2
- package/src/superlocalmemory/ui/js/feedback.js +1 -1
- package/src/superlocalmemory/ui/js/graph-filters.js +1 -1
- package/src/superlocalmemory/ui/js/graph-ui.js +1 -1
- package/src/superlocalmemory/ui/js/lifecycle.js +1 -1
- package/src/superlocalmemory/ui/js/ng-mesh.js +15 -49
- package/src/superlocalmemory/ui/js/settings.js +4 -2
- package/src/superlocalmemory/vector/lancedb_backend.py +57 -9
- package/bin/slm +0 -59
- package/bin/slm.bat +0 -77
- package/bin/slm.cmd +0 -5
- package/ide/integrations/langchain/README.md +0 -106
- package/ide/integrations/langchain/langchain_superlocalmemory/__init__.py +0 -9
- package/ide/integrations/langchain/langchain_superlocalmemory/chat_message_history.py +0 -201
- package/ide/integrations/langchain/pyproject.toml +0 -38
- package/ide/integrations/langchain/tests/__init__.py +0 -3
- package/ide/integrations/langchain/tests/test_chat_message_history.py +0 -215
- package/ide/integrations/langchain/tests/test_security.py +0 -117
- package/ide/integrations/llamaindex/README.md +0 -81
- package/ide/integrations/llamaindex/llama_index/storage/chat_store/superlocalmemory/__init__.py +0 -9
- package/ide/integrations/llamaindex/llama_index/storage/chat_store/superlocalmemory/base.py +0 -316
- package/ide/integrations/llamaindex/pyproject.toml +0 -43
- package/ide/integrations/llamaindex/tests/__init__.py +0 -3
- package/ide/integrations/llamaindex/tests/test_chat_store.py +0 -294
- package/ide/integrations/llamaindex/tests/test_security.py +0 -241
- 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 -15
- package/plugin-src/scripts/slm-launch.bat +0 -17
- package/plugin-src/settings.json +0 -16
- package/plugin-src/skills/slm-cache/SKILL.md +0 -140
- package/plugin-src/skills/slm-compress/SKILL.md +0 -143
- package/plugin-src/skills/slm-graph/SKILL.md +0 -300
- package/plugin-src/skills/slm-recall/SKILL.md +0 -204
- package/plugin-src/skills/slm-remember/SKILL.md +0 -194
- package/plugin-src/skills/slm-session/SKILL.md +0 -207
- package/plugin-src/skills/slm-status/SKILL.md +0 -149
- package/scripts/__tests__/build-plugin.test.mjs +0 -613
- package/scripts/_savings_math.py +0 -270
- package/scripts/build-dmg.sh +0 -417
- package/scripts/build-plugin.js +0 -742
- package/scripts/build-slm-hook.ps1 +0 -40
- package/scripts/build-slm-hook.sh +0 -45
- package/scripts/build_entry.py +0 -452
- package/scripts/ci/stage5b_gate.sh +0 -50
- package/scripts/dogfood_savings.py +0 -490
- package/scripts/generate-thumbnails.py +0 -218
- package/scripts/install-skills.ps1 +0 -4
- package/scripts/install-skills.sh +0 -5
- package/scripts/install.ps1 +0 -701
- package/scripts/install.sh +0 -1015
- package/scripts/postinstall_binary.js +0 -287
- package/scripts/prepack.js +0 -33
- package/scripts/release_manifest.py +0 -273
- package/scripts/slm-hook.spec +0 -56
- package/scripts/start-dashboard.ps1 +0 -52
- package/scripts/start-dashboard.sh +0 -41
- package/scripts/sync-wiki.ps1 +0 -127
- package/scripts/sync-wiki.sh +0 -82
- package/scripts/test-dmg.sh +0 -161
- package/scripts/test-npm-package.ps1 +0 -252
- package/scripts/test-npm-package.sh +0 -207
- package/scripts/verify-install.ps1 +0 -294
- package/scripts/verify-install.sh +0 -266
- package/scripts/verify-v27.ps1 +0 -301
- package/scripts/verify-v27.sh +0 -233
- package/src/superlocalmemory.egg-info/PKG-INFO +0 -513
- package/src/superlocalmemory.egg-info/SOURCES.txt +0 -529
- package/src/superlocalmemory.egg-info/dependency_links.txt +0 -1
- package/src/superlocalmemory.egg-info/entry_points.txt +0 -2
- package/src/superlocalmemory.egg-info/requires.txt +0 -71
- package/src/superlocalmemory.egg-info/top_level.txt +0 -1
|
@@ -20,7 +20,7 @@ Port 8767: TCP redirect for backward compat (deprecated)
|
|
|
20
20
|
24/7 by default. Opt-in auto-kill: --idle-timeout=1800
|
|
21
21
|
|
|
22
22
|
Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
23
|
-
License:
|
|
23
|
+
License: AGPL-3.0-or-later
|
|
24
24
|
"""
|
|
25
25
|
|
|
26
26
|
from __future__ import annotations
|
|
@@ -34,7 +34,9 @@ import signal
|
|
|
34
34
|
import sys
|
|
35
35
|
import threading
|
|
36
36
|
import time
|
|
37
|
+
import uuid
|
|
37
38
|
from contextlib import asynccontextmanager, AsyncExitStack
|
|
39
|
+
from dataclasses import replace
|
|
38
40
|
from datetime import datetime, timezone
|
|
39
41
|
from pathlib import Path
|
|
40
42
|
from typing import Optional
|
|
@@ -51,13 +53,85 @@ from fastapi.middleware.gzip import GZipMiddleware
|
|
|
51
53
|
from pydantic import BaseModel
|
|
52
54
|
|
|
53
55
|
from superlocalmemory.core.config import CANONICAL_RECALL_LIMIT
|
|
56
|
+
from superlocalmemory.infra.daemon_identity import (
|
|
57
|
+
DaemonDescriptor,
|
|
58
|
+
build_descriptor,
|
|
59
|
+
clear_descriptor,
|
|
60
|
+
descriptor_path,
|
|
61
|
+
write_descriptor,
|
|
62
|
+
)
|
|
63
|
+
from superlocalmemory.infra.data_root import (
|
|
64
|
+
assert_no_durable_root_conflict,
|
|
65
|
+
canonical_data_root,
|
|
66
|
+
state_path,
|
|
67
|
+
)
|
|
54
68
|
|
|
55
69
|
logger = logging.getLogger("superlocalmemory.unified_daemon")
|
|
56
70
|
|
|
57
71
|
_DEFAULT_PORT = 8765
|
|
58
72
|
_LEGACY_PORT = 8767
|
|
59
|
-
|
|
60
|
-
|
|
73
|
+
_ACTIVE_DAEMON_DESCRIPTOR: DaemonDescriptor | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _configured_daemon_port() -> int:
|
|
77
|
+
"""Return the configured bind port, falling back safely to the default."""
|
|
78
|
+
try:
|
|
79
|
+
return int(os.environ.get("SLM_DAEMON_PORT", "") or _DEFAULT_PORT)
|
|
80
|
+
except ValueError:
|
|
81
|
+
return _DEFAULT_PORT
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _process_descriptor(port: int, version: str, state: str) -> DaemonDescriptor:
|
|
85
|
+
"""Return this process's stable namespace/instance identity."""
|
|
86
|
+
global _ACTIVE_DAEMON_DESCRIPTOR
|
|
87
|
+
if _ACTIVE_DAEMON_DESCRIPTOR is None:
|
|
88
|
+
descriptor = build_descriptor(
|
|
89
|
+
port=port,
|
|
90
|
+
version=version,
|
|
91
|
+
pid=os.getpid(),
|
|
92
|
+
instance_id=os.environ.get("SLM_DAEMON_INSTANCE_ID") or None,
|
|
93
|
+
capability=os.environ.get("SLM_DAEMON_CAPABILITY") or None,
|
|
94
|
+
state=state,
|
|
95
|
+
)
|
|
96
|
+
os.environ["SLM_DAEMON_INSTANCE_ID"] = descriptor.instance_id
|
|
97
|
+
os.environ["SLM_DAEMON_CAPABILITY"] = descriptor.capability
|
|
98
|
+
_ACTIVE_DAEMON_DESCRIPTOR = descriptor
|
|
99
|
+
elif _ACTIVE_DAEMON_DESCRIPTOR.state != state:
|
|
100
|
+
_ACTIVE_DAEMON_DESCRIPTOR = replace(
|
|
101
|
+
_ACTIVE_DAEMON_DESCRIPTOR,
|
|
102
|
+
state=state,
|
|
103
|
+
port=port,
|
|
104
|
+
version=version,
|
|
105
|
+
)
|
|
106
|
+
return _ACTIVE_DAEMON_DESCRIPTOR
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _publish_process_descriptor(
|
|
110
|
+
port: int, version: str, state: str,
|
|
111
|
+
) -> DaemonDescriptor:
|
|
112
|
+
"""Atomically publish identity plus one-release PID/port mirrors."""
|
|
113
|
+
descriptor = _process_descriptor(port, version, state)
|
|
114
|
+
write_descriptor(descriptor)
|
|
115
|
+
pid_file = descriptor_path().with_name("daemon.pid")
|
|
116
|
+
port_file = descriptor_path().with_name("daemon.port")
|
|
117
|
+
pid_file.write_text(str(descriptor.pid))
|
|
118
|
+
port_file.write_text(str(descriptor.port))
|
|
119
|
+
return descriptor
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _cleanup_process_descriptor(descriptor: DaemonDescriptor | None) -> None:
|
|
123
|
+
"""Remove lifecycle state only when this process still owns the instance."""
|
|
124
|
+
if descriptor is None or not clear_descriptor(descriptor.instance_id):
|
|
125
|
+
return
|
|
126
|
+
for path, expected in (
|
|
127
|
+
(descriptor_path().with_name("daemon.pid"), str(descriptor.pid)),
|
|
128
|
+
(descriptor_path().with_name("daemon.port"), str(descriptor.port)),
|
|
129
|
+
):
|
|
130
|
+
try:
|
|
131
|
+
if path.read_text().strip() == expected:
|
|
132
|
+
path.unlink()
|
|
133
|
+
except OSError:
|
|
134
|
+
pass
|
|
61
135
|
|
|
62
136
|
|
|
63
137
|
# ---------------------------------------------------------------------------
|
|
@@ -68,6 +142,8 @@ class RememberRequest(BaseModel):
|
|
|
68
142
|
content: str
|
|
69
143
|
tags: str = ""
|
|
70
144
|
metadata: dict | None = None # v3.4.26: pass-through from MCP pool_store
|
|
145
|
+
idempotency_key: str | None = None
|
|
146
|
+
session_id: str = ""
|
|
71
147
|
# v3.6.15 multi-scope: visibility of the new memory. ``None`` scope means
|
|
72
148
|
# "use the configured default_scope" (personal). shared_with is the list of
|
|
73
149
|
# profile_ids for scope='shared'.
|
|
@@ -123,6 +199,7 @@ class EngineRecallAdapter:
|
|
|
123
199
|
)
|
|
124
200
|
# v3.6.6: same shared chokepoint as the HTTP route — identical output.
|
|
125
201
|
from superlocalmemory.server.recall_serializer import (
|
|
202
|
+
recall_response_metadata,
|
|
126
203
|
serialize_recall_response,
|
|
127
204
|
)
|
|
128
205
|
_rc = getattr(self._engine._config, "retrieval", None)
|
|
@@ -148,6 +225,7 @@ class EngineRecallAdapter:
|
|
|
148
225
|
"total_candidates": getattr(response, "total_candidates", 0),
|
|
149
226
|
"results": results,
|
|
150
227
|
"no_confident_match": no_confident_match,
|
|
228
|
+
**recall_response_metadata(response),
|
|
151
229
|
}
|
|
152
230
|
|
|
153
231
|
|
|
@@ -237,15 +315,15 @@ def _sanitize_json_text(text: str) -> str:
|
|
|
237
315
|
# ---------------------------------------------------------------------------
|
|
238
316
|
|
|
239
317
|
class ObserveBuffer:
|
|
240
|
-
"""
|
|
318
|
+
"""Durable observation admission with a short duplicate window.
|
|
241
319
|
|
|
242
|
-
|
|
243
|
-
|
|
320
|
+
An accepted observation is submitted to M018 before ``enqueue`` returns.
|
|
321
|
+
The timer clears only the in-memory duplicate set; it never owns evidence
|
|
322
|
+
or delays persistence.
|
|
244
323
|
"""
|
|
245
324
|
|
|
246
325
|
def __init__(self, debounce_sec: float = 3.0):
|
|
247
326
|
self._debounce_sec = debounce_sec
|
|
248
|
-
self._buffer: list[str] = []
|
|
249
327
|
self._seen: set[str] = set()
|
|
250
328
|
self._lock = threading.Lock()
|
|
251
329
|
self._timer: threading.Timer | None = None
|
|
@@ -254,17 +332,16 @@ class ObserveBuffer:
|
|
|
254
332
|
def set_engine(self, engine) -> None:
|
|
255
333
|
self._engine = engine
|
|
256
334
|
|
|
257
|
-
def enqueue(self, content: str) -> dict:
|
|
258
|
-
content_hash = hashlib.
|
|
335
|
+
def enqueue(self, content: str, *, trusted_actor_id: str = "") -> dict:
|
|
336
|
+
content_hash = hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
259
337
|
with self._lock:
|
|
260
338
|
if content_hash in self._seen:
|
|
261
339
|
return {"captured": False, "reason": "duplicate within debounce window"}
|
|
262
340
|
self._seen.add(content_hash)
|
|
263
|
-
self.
|
|
264
|
-
buf_size = len(self._buffer)
|
|
341
|
+
window_size = len(self._seen)
|
|
265
342
|
if self._timer is not None:
|
|
266
343
|
self._timer.cancel()
|
|
267
|
-
self._timer = threading.Timer(self._debounce_sec, self.
|
|
344
|
+
self._timer = threading.Timer(self._debounce_sec, self._clear_seen)
|
|
268
345
|
self._timer.daemon = True
|
|
269
346
|
self._timer.start()
|
|
270
347
|
_emit_event(
|
|
@@ -272,74 +349,113 @@ class ObserveBuffer:
|
|
|
272
349
|
payload={
|
|
273
350
|
"content_hash": content_hash,
|
|
274
351
|
"content_preview": content[:120],
|
|
275
|
-
"buffer_size":
|
|
352
|
+
"buffer_size": window_size,
|
|
276
353
|
},
|
|
277
354
|
)
|
|
278
|
-
return {"captured": True, "queued": True, "buffer_size": buf_size}
|
|
279
|
-
|
|
280
|
-
def _flush(self) -> None:
|
|
281
|
-
with self._lock:
|
|
282
|
-
if not self._buffer:
|
|
283
|
-
return
|
|
284
|
-
batch = list(self._buffer)
|
|
285
|
-
self._buffer.clear()
|
|
286
|
-
self._seen.clear()
|
|
287
|
-
self._timer = None
|
|
288
|
-
|
|
289
355
|
if self._engine is None:
|
|
290
|
-
|
|
356
|
+
with self._lock:
|
|
357
|
+
self._seen.discard(content_hash)
|
|
358
|
+
return {
|
|
359
|
+
"captured": False,
|
|
360
|
+
"durable": False,
|
|
361
|
+
"reason": "memory engine unavailable",
|
|
362
|
+
}
|
|
291
363
|
|
|
292
364
|
try:
|
|
293
365
|
from superlocalmemory.hooks.auto_capture import AutoCapture
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
366
|
+
from superlocalmemory.core.engine_ingestion import (
|
|
367
|
+
build_engine_ingestion_command,
|
|
368
|
+
)
|
|
369
|
+
from superlocalmemory.core.ingestion_command import IngestionRequest
|
|
370
|
+
|
|
371
|
+
decision = AutoCapture().evaluate(content)
|
|
372
|
+
if not decision.capture:
|
|
373
|
+
_emit_event(
|
|
374
|
+
"memory.dropped",
|
|
375
|
+
payload={
|
|
376
|
+
"reason": decision.reason,
|
|
377
|
+
"content_preview": content[:120],
|
|
378
|
+
},
|
|
379
|
+
)
|
|
380
|
+
return {
|
|
381
|
+
"captured": False,
|
|
382
|
+
"durable": False,
|
|
383
|
+
"reason": decision.reason,
|
|
384
|
+
"category": decision.category,
|
|
385
|
+
"confidence": round(decision.confidence, 3),
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
scope_config = getattr(self._engine._config, "scope", None)
|
|
389
|
+
scope = getattr(scope_config, "default_scope", "personal")
|
|
390
|
+
command = build_engine_ingestion_command(self._engine)
|
|
391
|
+
receipt = command.submit(IngestionRequest(
|
|
392
|
+
content=content,
|
|
393
|
+
profile_id=self._engine._profile_id,
|
|
394
|
+
source_type="http-observe",
|
|
395
|
+
idempotency_key=f"observe:v1:{content_hash}",
|
|
396
|
+
metadata={
|
|
397
|
+
"source": "auto-capture",
|
|
398
|
+
"category": decision.category,
|
|
399
|
+
"confidence": decision.confidence,
|
|
400
|
+
},
|
|
401
|
+
scope=scope,
|
|
402
|
+
trusted_actor_id=trusted_actor_id or _materializer_actor_id(),
|
|
403
|
+
))
|
|
404
|
+
_emit_event(
|
|
405
|
+
"memory.captured",
|
|
406
|
+
payload={
|
|
407
|
+
"operation_id": receipt.operation_id,
|
|
408
|
+
"category": decision.category,
|
|
409
|
+
"confidence": decision.confidence,
|
|
410
|
+
"content_preview": content[:120],
|
|
411
|
+
},
|
|
334
412
|
)
|
|
413
|
+
return {
|
|
414
|
+
"captured": True,
|
|
415
|
+
"durable": True,
|
|
416
|
+
"queued": receipt.state.value != "complete",
|
|
417
|
+
"operation_id": receipt.operation_id,
|
|
418
|
+
"fact_ids": list(receipt.fact_ids),
|
|
419
|
+
"materialization_state": receipt.state.value,
|
|
420
|
+
"category": decision.category,
|
|
421
|
+
"confidence": round(decision.confidence, 3),
|
|
422
|
+
}
|
|
335
423
|
except Exception as exc:
|
|
336
|
-
|
|
424
|
+
with self._lock:
|
|
425
|
+
self._seen.discard(content_hash)
|
|
426
|
+
logger.warning(
|
|
427
|
+
"ObserveBuffer: durable admission failed for content %.40r: %s",
|
|
428
|
+
content,
|
|
429
|
+
exc,
|
|
430
|
+
)
|
|
431
|
+
_emit_event(
|
|
432
|
+
"memory.dropped",
|
|
433
|
+
payload={
|
|
434
|
+
"reason": "durable admission failed",
|
|
435
|
+
"content_preview": content[:120],
|
|
436
|
+
},
|
|
437
|
+
)
|
|
438
|
+
return {
|
|
439
|
+
"captured": False,
|
|
440
|
+
"durable": False,
|
|
441
|
+
"reason": "durable admission failed",
|
|
442
|
+
"error": str(exc),
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
def _clear_seen(self) -> None:
|
|
446
|
+
with self._lock:
|
|
447
|
+
self._seen.clear()
|
|
448
|
+
self._timer = None
|
|
449
|
+
|
|
450
|
+
def _flush(self) -> None:
|
|
451
|
+
"""Compatibility alias: no evidence is buffered in V3.7."""
|
|
452
|
+
self._clear_seen()
|
|
337
453
|
|
|
338
454
|
def flush_sync(self) -> None:
|
|
339
|
-
"""
|
|
455
|
+
"""Clear duplicate-window state for shutdown."""
|
|
340
456
|
if self._timer is not None:
|
|
341
457
|
self._timer.cancel()
|
|
342
|
-
self.
|
|
458
|
+
self._clear_seen()
|
|
343
459
|
|
|
344
460
|
|
|
345
461
|
_observe_buffer = ObserveBuffer(
|
|
@@ -440,18 +556,38 @@ async def lifespan(application: FastAPI):
|
|
|
440
556
|
engine = None
|
|
441
557
|
config = None
|
|
442
558
|
|
|
559
|
+
# The local dashboard obtains its short-lived browser credential from
|
|
560
|
+
# ``/internal/token`` before its first write or token-gated read. A
|
|
561
|
+
# completely fresh Mode A install may not ingest or recall anything during
|
|
562
|
+
# startup, so neither of those paths has created the install token yet.
|
|
563
|
+
# Create it as part of the daemon's durable identity bootstrap instead.
|
|
564
|
+
# This keeps the token endpoint read-only (and therefore fail-closed when a
|
|
565
|
+
# token is unexpectedly missing after startup) while making every normal
|
|
566
|
+
# daemon-backed dashboard usable from its first page load.
|
|
567
|
+
try:
|
|
568
|
+
from superlocalmemory.core.security_primitives import ensure_install_token
|
|
569
|
+
|
|
570
|
+
ensure_install_token()
|
|
571
|
+
except Exception as exc: # pragma: no cover - startup remains fail-soft
|
|
572
|
+
logger.warning("install-token bootstrap failed: %s", exc)
|
|
573
|
+
|
|
574
|
+
# Register the SSE bridge inside the application lifespan. FastAPI's
|
|
575
|
+
# legacy ``on_event`` hook is deprecated and, more importantly, made a
|
|
576
|
+
# second startup mechanism compete with the daemon's existing lifespan.
|
|
577
|
+
from superlocalmemory.server.routes.events import register_event_listener
|
|
578
|
+
register_event_listener()
|
|
579
|
+
|
|
443
580
|
# H-21 (Stage 8) — first-boot-after-upgrade notice. Compare the cached
|
|
444
581
|
# version marker against the current package version; if they differ
|
|
445
582
|
# (fresh install or upgrade), log a one-time banner with a link to the
|
|
446
583
|
# CHANGELOG. Non-fatal; any filesystem error is swallowed.
|
|
447
584
|
try:
|
|
448
|
-
from pathlib import Path as _VP
|
|
449
585
|
try:
|
|
450
586
|
from importlib.metadata import version as _pkg_version
|
|
451
587
|
_slm_version = _pkg_version("superlocalmemory")
|
|
452
588
|
except Exception:
|
|
453
589
|
_slm_version = "unknown"
|
|
454
|
-
_version_marker =
|
|
590
|
+
_version_marker = state_path(".last_version")
|
|
455
591
|
_prev = None
|
|
456
592
|
if _version_marker.exists():
|
|
457
593
|
try:
|
|
@@ -488,9 +624,8 @@ async def lifespan(application: FastAPI):
|
|
|
488
624
|
# engine init so later queries see the expected columns/tables.
|
|
489
625
|
# Non-fatal: any failure here is logged and the daemon still starts.
|
|
490
626
|
try:
|
|
491
|
-
from pathlib import Path as _P
|
|
492
627
|
from superlocalmemory.storage.migration_runner import apply_all
|
|
493
|
-
_home =
|
|
628
|
+
_home = canonical_data_root()
|
|
494
629
|
_learning_db = _home / "learning.db"
|
|
495
630
|
_memory_db = _home / "memory.db"
|
|
496
631
|
_result = apply_all(_learning_db, _memory_db)
|
|
@@ -520,7 +655,7 @@ async def lifespan(application: FastAPI):
|
|
|
520
655
|
except Exception as _exc:
|
|
521
656
|
logger.warning("migration runner crashed (non-fatal): %s", _exc)
|
|
522
657
|
application.state.migration_result = {
|
|
523
|
-
"applied": [], "skipped": [], "failed": [],
|
|
658
|
+
"applied": [], "skipped": [], "failed": ["_runner_crash"],
|
|
524
659
|
"details": {"_crash": str(_exc)},
|
|
525
660
|
}
|
|
526
661
|
|
|
@@ -565,16 +700,28 @@ async def lifespan(application: FastAPI):
|
|
|
565
700
|
set_orchestrator(orch)
|
|
566
701
|
_cozo_backend = orch.get_graph_backend()
|
|
567
702
|
_lancedb_backend = orch.get_vector_backend()
|
|
568
|
-
#
|
|
703
|
+
# Cozo storage may be active before its canonical-entity retrieval
|
|
704
|
+
# projection is parity-proven. Never route mismatched ID spaces.
|
|
569
705
|
re = getattr(engine, '_retrieval_engine', None)
|
|
570
706
|
if re is not None:
|
|
571
707
|
eg = getattr(re, '_entity', None)
|
|
572
|
-
if
|
|
708
|
+
if (
|
|
709
|
+
eg is not None
|
|
710
|
+
and _cozo_backend is not None
|
|
711
|
+
and orch.graph_retrieval_ready()
|
|
712
|
+
):
|
|
573
713
|
try:
|
|
574
714
|
eg._cozo = _cozo_backend
|
|
575
715
|
logger.info("CozoDB backend wired into entity_graph channel")
|
|
576
716
|
except Exception as exc:
|
|
577
717
|
logger.warning("CozoDB channel injection failed: %s", exc)
|
|
718
|
+
semantic = getattr(re, '_semantic', None)
|
|
719
|
+
if semantic is not None and _lancedb_backend is not None:
|
|
720
|
+
try:
|
|
721
|
+
semantic.set_scale_vector_backend(_lancedb_backend)
|
|
722
|
+
logger.info("LanceDB backend wired into semantic channel with SQLite shadow")
|
|
723
|
+
except Exception as exc:
|
|
724
|
+
logger.warning("LanceDB channel injection failed: %s", exc)
|
|
578
725
|
logger.info("BackendOrchestrator: ready (cozo=%s, lancedb=%s)",
|
|
579
726
|
"active" if _cozo_backend else "off",
|
|
580
727
|
"active" if _lancedb_backend else "off")
|
|
@@ -660,12 +807,12 @@ async def lifespan(application: FastAPI):
|
|
|
660
807
|
# uses the daemon's engine directly via EngineRecallAdapter.
|
|
661
808
|
# WorkerPool is still available as fallback for dashboard/chat routes.
|
|
662
809
|
|
|
663
|
-
#
|
|
810
|
+
# The reranker constructor has already started its background warmup.
|
|
811
|
+
# Never block daemon publication here: a first-time model download or
|
|
812
|
+
# ONNX compilation previously held every CLI/MCP request for 120s.
|
|
813
|
+
# Until it is ready, retrieval uses its deterministic fallback scorer;
|
|
814
|
+
# the worker upgrades subsequent recalls without changing their API.
|
|
664
815
|
retrieval_eng = getattr(engine, '_retrieval_engine', None)
|
|
665
|
-
if retrieval_eng:
|
|
666
|
-
reranker = getattr(retrieval_eng, '_reranker', None)
|
|
667
|
-
if reranker and hasattr(reranker, 'warmup_sync'):
|
|
668
|
-
reranker.warmup_sync(timeout=120)
|
|
669
816
|
|
|
670
817
|
# V3.4.11: Pre-warm embedding worker (load ONNX model on startup)
|
|
671
818
|
# Without this, first recall takes 60-90s for model load.
|
|
@@ -793,10 +940,9 @@ async def lifespan(application: FastAPI):
|
|
|
793
940
|
# Previously routed through WorkerPool → recall_worker subprocess,
|
|
794
941
|
# which loaded a duplicate MemoryEngine (~800 MB waste).
|
|
795
942
|
try:
|
|
796
|
-
from pathlib import Path as _QP
|
|
797
943
|
from superlocalmemory.core.queue_consumer import QueueConsumer
|
|
798
944
|
from superlocalmemory.core.recall_queue import RecallQueue
|
|
799
|
-
_queue_db =
|
|
945
|
+
_queue_db = state_path("recall_queue.db")
|
|
800
946
|
_recall_queue = RecallQueue(_queue_db)
|
|
801
947
|
_queue_consumer = QueueConsumer(
|
|
802
948
|
queue=_recall_queue,
|
|
@@ -854,7 +1000,7 @@ async def lifespan(application: FastAPI):
|
|
|
854
1000
|
mesh_enabled = getattr(config, 'mesh_enabled', True) if config else True
|
|
855
1001
|
if mesh_enabled:
|
|
856
1002
|
from superlocalmemory.mesh.broker import MeshBroker
|
|
857
|
-
db_path = config.db_path if config else
|
|
1003
|
+
db_path = config.db_path if config else state_path("memory.db")
|
|
858
1004
|
mesh_broker = MeshBroker(str(db_path))
|
|
859
1005
|
mesh_broker.start_cleanup()
|
|
860
1006
|
application.state.mesh_broker = mesh_broker
|
|
@@ -874,7 +1020,8 @@ async def lifespan(application: FastAPI):
|
|
|
874
1020
|
# Start legacy port redirect
|
|
875
1021
|
enable_legacy = os.environ.get("SLM_DISABLE_LEGACY_PORT", "").lower() not in ("1", "true")
|
|
876
1022
|
if enable_legacy:
|
|
877
|
-
|
|
1023
|
+
identity = application.state.daemon_descriptor
|
|
1024
|
+
asyncio.create_task(_start_legacy_redirect(identity.port, _LEGACY_PORT))
|
|
878
1025
|
|
|
879
1026
|
# V3.4.22 LLD-02: signal-worker background drainer (S8-SK-01 fix).
|
|
880
1027
|
# Without this, ``signals.enqueue`` fills a bounded queue and drops
|
|
@@ -883,8 +1030,7 @@ async def lifespan(application: FastAPI):
|
|
|
883
1030
|
if os.environ.get("SLM_SIGNALS_ENABLED", "1") != "0":
|
|
884
1031
|
try:
|
|
885
1032
|
from superlocalmemory.learning import signal_worker as _sw
|
|
886
|
-
|
|
887
|
-
_learning_db = _P.home() / ".superlocalmemory" / "learning.db"
|
|
1033
|
+
_learning_db = state_path("learning.db")
|
|
888
1034
|
_sw.start(_learning_db)
|
|
889
1035
|
application.state.signal_worker_started = True
|
|
890
1036
|
logger.info("signal_worker started on %s", _learning_db)
|
|
@@ -920,11 +1066,12 @@ async def lifespan(application: FastAPI):
|
|
|
920
1066
|
# Python's logging module then wrote the full stack to stderr. Because the
|
|
921
1067
|
# call runs inside FastAPI's stacked merged_lifespan, each dump was ~30 KB
|
|
922
1068
|
# and the error log grew to tens of MB within a day.
|
|
1069
|
+
_display_port = _configured_daemon_port()
|
|
923
1070
|
if idle_timeout <= 0:
|
|
924
|
-
_ready_msg = f"Unified daemon ready on port {
|
|
1071
|
+
_ready_msg = f"Unified daemon ready on port {_display_port} (24/7 mode)"
|
|
925
1072
|
else:
|
|
926
1073
|
_ready_msg = (
|
|
927
|
-
f"Unified daemon ready on port {
|
|
1074
|
+
f"Unified daemon ready on port {_display_port} "
|
|
928
1075
|
f"(idle timeout: {idle_timeout}s)"
|
|
929
1076
|
)
|
|
930
1077
|
logger.info(_ready_msg)
|
|
@@ -994,6 +1141,13 @@ async def lifespan(application: FastAPI):
|
|
|
994
1141
|
_mcp_lifespan_exc,
|
|
995
1142
|
)
|
|
996
1143
|
|
|
1144
|
+
# Uvicorn enters this lifespan only after it has bound the listener.
|
|
1145
|
+
# Publishing ``ready`` here prevents a failed competing process from
|
|
1146
|
+
# overwriting the live daemon descriptor before it owns the port.
|
|
1147
|
+
from superlocalmemory.server.routes.helpers import SLM_VERSION
|
|
1148
|
+
application.state.daemon_descriptor = _publish_process_descriptor(
|
|
1149
|
+
_configured_daemon_port(), SLM_VERSION, "ready",
|
|
1150
|
+
)
|
|
997
1151
|
yield
|
|
998
1152
|
|
|
999
1153
|
# Cancel optimize metrics flush loop + run final flush before shutdown
|
|
@@ -1166,8 +1320,9 @@ async def lifespan(application: FastAPI):
|
|
|
1166
1320
|
engine.close()
|
|
1167
1321
|
except Exception:
|
|
1168
1322
|
pass
|
|
1169
|
-
|
|
1170
|
-
|
|
1323
|
+
_cleanup_process_descriptor(
|
|
1324
|
+
getattr(application.state, "daemon_descriptor", None),
|
|
1325
|
+
)
|
|
1171
1326
|
logger.info("Unified daemon shutdown complete")
|
|
1172
1327
|
|
|
1173
1328
|
|
|
@@ -1175,6 +1330,24 @@ async def lifespan(application: FastAPI):
|
|
|
1175
1330
|
# App factory
|
|
1176
1331
|
# ---------------------------------------------------------------------------
|
|
1177
1332
|
|
|
1333
|
+
def _configure_mcp_transport_settings(fastmcp) -> bool:
|
|
1334
|
+
"""Apply the current transport mode without leaking singleton state.
|
|
1335
|
+
|
|
1336
|
+
``superlocalmemory.mcp.server.server`` is process-global. App factories
|
|
1337
|
+
are invoked more than once by tests and embedded hosts, so both flags must
|
|
1338
|
+
be assigned on every call; an earlier stateless app must not silently turn
|
|
1339
|
+
a later default app stateless. Keeping this small policy separate also
|
|
1340
|
+
lets tests exercise the wiring without reloading FastMCP and rebuilding
|
|
1341
|
+
hundreds of Pydantic models in a native-heavy Python process.
|
|
1342
|
+
"""
|
|
1343
|
+
from superlocalmemory.core.remote_mode import mcp_stateless
|
|
1344
|
+
|
|
1345
|
+
stateless = bool(mcp_stateless())
|
|
1346
|
+
fastmcp.settings.stateless_http = stateless
|
|
1347
|
+
fastmcp.settings.json_response = stateless
|
|
1348
|
+
return stateless
|
|
1349
|
+
|
|
1350
|
+
|
|
1178
1351
|
def create_app() -> FastAPI:
|
|
1179
1352
|
"""Create the unified FastAPI application."""
|
|
1180
1353
|
from superlocalmemory.server.routes.helpers import SLM_VERSION
|
|
@@ -1185,6 +1358,10 @@ def create_app() -> FastAPI:
|
|
|
1185
1358
|
version=SLM_VERSION,
|
|
1186
1359
|
lifespan=lifespan,
|
|
1187
1360
|
)
|
|
1361
|
+
identity_port = _configured_daemon_port()
|
|
1362
|
+
application.state.daemon_descriptor = _process_descriptor(
|
|
1363
|
+
identity_port, SLM_VERSION, "starting",
|
|
1364
|
+
)
|
|
1188
1365
|
|
|
1189
1366
|
# -- Middleware --
|
|
1190
1367
|
from superlocalmemory.server.security_middleware import SecurityHeadersMiddleware
|
|
@@ -1199,7 +1376,10 @@ def create_app() -> FastAPI:
|
|
|
1199
1376
|
],
|
|
1200
1377
|
allow_credentials=True,
|
|
1201
1378
|
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
|
|
1202
|
-
allow_headers=[
|
|
1379
|
+
allow_headers=[
|
|
1380
|
+
"Content-Type", "Authorization", "X-SLM-API-Key",
|
|
1381
|
+
"X-SLM-Daemon-Capability", "X-SLM-Target-Instance",
|
|
1382
|
+
],
|
|
1203
1383
|
)
|
|
1204
1384
|
|
|
1205
1385
|
# -- Register all dashboard routes (from existing api.py) --
|
|
@@ -1359,10 +1539,8 @@ def create_app() -> FastAPI:
|
|
|
1359
1539
|
# clients keep full stateful sessions); enabled by SLM_REMOTE=1 or
|
|
1360
1540
|
# SLM_MCP_STATELESS=1. Per-agent /mcp/{agent_id} routing is unaffected
|
|
1361
1541
|
# (path-based, not session-based).
|
|
1362
|
-
from superlocalmemory.core.remote_mode import
|
|
1363
|
-
if
|
|
1364
|
-
_mcp_fastmcp.settings.stateless_http = True
|
|
1365
|
-
_mcp_fastmcp.settings.json_response = True
|
|
1542
|
+
from superlocalmemory.core.remote_mode import is_remote_mode
|
|
1543
|
+
if _configure_mcp_transport_settings(_mcp_fastmcp):
|
|
1366
1544
|
if is_remote_mode():
|
|
1367
1545
|
logger.warning(
|
|
1368
1546
|
"MCP transport: STATELESS mode ON (SLM_REMOTE) — LAN "
|
|
@@ -1387,7 +1565,11 @@ def create_app() -> FastAPI:
|
|
|
1387
1565
|
from superlocalmemory.mcp.agent_context import AgentIDExtractorASGI
|
|
1388
1566
|
|
|
1389
1567
|
application.mount("/mcp", AgentIDExtractorASGI(_mcp_app))
|
|
1390
|
-
logger.info(
|
|
1568
|
+
logger.info(
|
|
1569
|
+
"MCP HTTP transport mounted at /mcp (Streamable HTTP, port %d; "
|
|
1570
|
+
"per-agent routing enabled)",
|
|
1571
|
+
_configured_daemon_port(),
|
|
1572
|
+
)
|
|
1391
1573
|
except Exception as _mcp_exc: # pragma: no cover — defensive
|
|
1392
1574
|
logger.warning("MCP HTTP mount failed (non-fatal, stdio still works): %s", _mcp_exc)
|
|
1393
1575
|
|
|
@@ -1450,16 +1632,20 @@ def _register_dashboard_routes(application: FastAPI) -> None:
|
|
|
1450
1632
|
|
|
1451
1633
|
# Auth middleware (graceful)
|
|
1452
1634
|
try:
|
|
1453
|
-
from superlocalmemory.infra.auth_middleware import
|
|
1635
|
+
from superlocalmemory.infra.auth_middleware import (
|
|
1636
|
+
authorize_http_mcp_request,
|
|
1637
|
+
check_api_key,
|
|
1638
|
+
)
|
|
1639
|
+
from superlocalmemory.server.write_identity import (
|
|
1640
|
+
require_http_mutation_actor,
|
|
1641
|
+
)
|
|
1454
1642
|
|
|
1455
1643
|
# Auth-exempt path prefixes — proxy routes carry provider API keys
|
|
1456
1644
|
# (x-api-key for Anthropic, Authorization: Bearer for OpenAI, x-goog-api-key
|
|
1457
1645
|
# for Gemini), never X-SLM-API-Key. Verified: auth_middleware.py:50-82
|
|
1458
1646
|
# returns False for POST when api_key file exists and X-SLM-API-Key
|
|
1459
1647
|
# is absent.
|
|
1460
|
-
|
|
1461
|
-
# via the MCP protocol; they have no knowledge of X-SLM-API-Key.
|
|
1462
|
-
_AUTH_EXEMPT_PREFIXES = ("/v1/", "/v1beta/", "/mcp")
|
|
1648
|
+
_AUTH_EXEMPT_PREFIXES = ("/v1/", "/v1beta/")
|
|
1463
1649
|
|
|
1464
1650
|
@application.middleware("http")
|
|
1465
1651
|
async def auth_middleware(request, call_next):
|
|
@@ -1467,13 +1653,27 @@ def _register_dashboard_routes(application: FastAPI) -> None:
|
|
|
1467
1653
|
if request.url.path.startswith(_AUTH_EXEMPT_PREFIXES):
|
|
1468
1654
|
return await call_next(request)
|
|
1469
1655
|
is_write = request.method in ("POST", "PUT", "DELETE", "PATCH")
|
|
1656
|
+
records_recall_telemetry = request.url.path.startswith("/recall")
|
|
1657
|
+
requires_mutation_actor = is_write or records_recall_telemetry
|
|
1470
1658
|
headers = dict(request.headers)
|
|
1659
|
+
client_host = request.client.host if request.client else ""
|
|
1660
|
+
if request.url.path.startswith("/mcp") and not authorize_http_mcp_request(
|
|
1661
|
+
headers,
|
|
1662
|
+
client_host=client_host,
|
|
1663
|
+
):
|
|
1664
|
+
from fastapi.responses import JSONResponse
|
|
1665
|
+
return JSONResponse(
|
|
1666
|
+
status_code=401,
|
|
1667
|
+
content={
|
|
1668
|
+
"error": "Remote HTTP MCP requires a configured SLM API key."
|
|
1669
|
+
},
|
|
1670
|
+
)
|
|
1471
1671
|
# v3.6.12 (csrf-1): defense-in-depth CSRF/DNS-rebinding guard on
|
|
1472
1672
|
# state-changing requests. A cross-origin browser Origin is rejected;
|
|
1473
1673
|
# loopback origins (the local dashboard) always pass, and LAN origins
|
|
1474
1674
|
# pass only when explicitly allowlisted in SLM_REMOTE mode. Non-browser
|
|
1475
1675
|
# clients (CLI/MCP/curl) send no Origin and are unaffected.
|
|
1476
|
-
if
|
|
1676
|
+
if requires_mutation_actor:
|
|
1477
1677
|
_origin = headers.get("origin", "") or headers.get("Origin", "")
|
|
1478
1678
|
if _origin:
|
|
1479
1679
|
_ok_origin = any(_origin.startswith(p) for p in (
|
|
@@ -1490,6 +1690,26 @@ def _register_dashboard_routes(application: FastAPI) -> None:
|
|
|
1490
1690
|
status_code=403,
|
|
1491
1691
|
content={"error": "cross-origin request rejected"},
|
|
1492
1692
|
)
|
|
1693
|
+
_mesh_secret = None
|
|
1694
|
+
if request.url.path.startswith("/mesh"):
|
|
1695
|
+
_mesh_broker = getattr(application.state, "mesh_broker", None)
|
|
1696
|
+
_mesh_secret = getattr(_mesh_broker, "_shared_secret", None)
|
|
1697
|
+
try:
|
|
1698
|
+
request.state.authenticated_actor = require_http_mutation_actor(
|
|
1699
|
+
request,
|
|
1700
|
+
getattr(application.state, "daemon_descriptor", None),
|
|
1701
|
+
actor_kind="http-route",
|
|
1702
|
+
mesh_secret=_mesh_secret,
|
|
1703
|
+
)
|
|
1704
|
+
except Exception as _identity_exc:
|
|
1705
|
+
from fastapi import HTTPException as _HTTPException
|
|
1706
|
+
from fastapi.responses import JSONResponse
|
|
1707
|
+
if isinstance(_identity_exc, _HTTPException):
|
|
1708
|
+
return JSONResponse(
|
|
1709
|
+
status_code=_identity_exc.status_code,
|
|
1710
|
+
content={"error": str(_identity_exc.detail)},
|
|
1711
|
+
)
|
|
1712
|
+
raise
|
|
1493
1713
|
if not check_api_key(headers, is_write=is_write):
|
|
1494
1714
|
from fastapi.responses import JSONResponse
|
|
1495
1715
|
return JSONResponse(
|
|
@@ -1515,7 +1735,7 @@ def _register_dashboard_routes(application: FastAPI) -> None:
|
|
|
1515
1735
|
|
|
1516
1736
|
@application.middleware("http")
|
|
1517
1737
|
async def _failclosed_auth(request, call_next):
|
|
1518
|
-
if request.url.path.startswith(("/v1/", "/v1beta/"
|
|
1738
|
+
if request.url.path.startswith(("/v1/", "/v1beta/")):
|
|
1519
1739
|
return await call_next(request)
|
|
1520
1740
|
is_write = request.method in ("POST", "PUT", "DELETE", "PATCH")
|
|
1521
1741
|
client_host = request.client.host if request.client else ""
|
|
@@ -1538,9 +1758,7 @@ def _register_dashboard_routes(application: FastAPI) -> None:
|
|
|
1538
1758
|
from superlocalmemory.server.routes.profiles import router as profiles_router
|
|
1539
1759
|
from superlocalmemory.server.routes.backup import router as backup_router
|
|
1540
1760
|
from superlocalmemory.server.routes.data_io import router as data_io_router
|
|
1541
|
-
from superlocalmemory.server.routes.events import
|
|
1542
|
-
router as events_router, register_event_listener,
|
|
1543
|
-
)
|
|
1761
|
+
from superlocalmemory.server.routes.events import router as events_router
|
|
1544
1762
|
from superlocalmemory.server.routes.agents import router as agents_router
|
|
1545
1763
|
from superlocalmemory.server.routes.ws import router as ws_router, manager as ws_manager
|
|
1546
1764
|
from superlocalmemory.server.routes.v3_api import router as v3_router
|
|
@@ -1661,12 +1879,6 @@ def _register_dashboard_routes(application: FastAPI) -> None:
|
|
|
1661
1879
|
html = index_path.read_text()
|
|
1662
1880
|
return html.replace("__SLM_VERSION__", _SLM_VERSION)
|
|
1663
1881
|
|
|
1664
|
-
# Startup event for event listener
|
|
1665
|
-
@application.on_event("startup")
|
|
1666
|
-
async def startup_event():
|
|
1667
|
-
register_event_listener()
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
1882
|
def _register_daemon_routes(application: FastAPI) -> None:
|
|
1671
1883
|
"""Add daemon-specific routes for CLI integration."""
|
|
1672
1884
|
global _last_activity
|
|
@@ -1685,11 +1897,56 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
1685
1897
|
raise HTTPException(503, detail="Engine not initialized")
|
|
1686
1898
|
return engine
|
|
1687
1899
|
|
|
1900
|
+
def _require_daemon_actor(request: Request) -> str:
|
|
1901
|
+
"""Authenticate the private capability for this exact process."""
|
|
1902
|
+
from superlocalmemory.server.write_identity import require_daemon_actor
|
|
1903
|
+
|
|
1904
|
+
return require_daemon_actor(
|
|
1905
|
+
request,
|
|
1906
|
+
getattr(application.state, "daemon_descriptor", None),
|
|
1907
|
+
)
|
|
1908
|
+
|
|
1909
|
+
def _require_write_actor(request: Request) -> str:
|
|
1910
|
+
"""Authenticate a local write and return its trusted actor.
|
|
1911
|
+
|
|
1912
|
+
Caller-provided agent labels are audit metadata only. A mutating
|
|
1913
|
+
daemon client may borrow the process actor only after proving the
|
|
1914
|
+
private capability for this exact instance. Same-origin dashboard
|
|
1915
|
+
writers instead present the install token, which is never the daemon
|
|
1916
|
+
process capability.
|
|
1917
|
+
"""
|
|
1918
|
+
from superlocalmemory.server.write_identity import require_write_actor
|
|
1919
|
+
|
|
1920
|
+
return require_write_actor(
|
|
1921
|
+
request,
|
|
1922
|
+
getattr(application.state, "daemon_descriptor", None),
|
|
1923
|
+
actor_kind="dashboard",
|
|
1924
|
+
)
|
|
1925
|
+
|
|
1688
1926
|
@application.get("/health")
|
|
1689
1927
|
async def health():
|
|
1690
1928
|
_update_activity()
|
|
1691
1929
|
# Non-blocking peek: report status without forcing a re-init.
|
|
1692
1930
|
engine = getattr(application.state, "engine", None)
|
|
1931
|
+
migration_result = getattr(application.state, "migration_result", None)
|
|
1932
|
+
migration_failures = list(
|
|
1933
|
+
(migration_result or {}).get("failed", []) or []
|
|
1934
|
+
)
|
|
1935
|
+
migration_details = (migration_result or {}).get("details", {}) or {}
|
|
1936
|
+
migrations_ready = bool(migration_result) and not migration_failures
|
|
1937
|
+
if migration_details.get("_crash"):
|
|
1938
|
+
migrations_ready = False
|
|
1939
|
+
readiness = {
|
|
1940
|
+
"engine": engine is not None,
|
|
1941
|
+
"migrations": migrations_ready,
|
|
1942
|
+
"retrieval": bool(_embedding_warm),
|
|
1943
|
+
"migration_failures": migration_failures,
|
|
1944
|
+
}
|
|
1945
|
+
base_ready = all((readiness["engine"], readiness["migrations"]))
|
|
1946
|
+
fully_ready = base_ready and readiness["retrieval"]
|
|
1947
|
+
runtime_state = (
|
|
1948
|
+
"ready" if fully_ready else "warming" if base_ready else "not_ready"
|
|
1949
|
+
)
|
|
1693
1950
|
# v3.6.8: surface the recall-health verdict so a silently-degraded
|
|
1694
1951
|
# recall path (warm-but-broken embedder) is VISIBLE, never silent.
|
|
1695
1952
|
try:
|
|
@@ -1697,8 +1954,11 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
1697
1954
|
_recall_health = get_recall_health()
|
|
1698
1955
|
except Exception:
|
|
1699
1956
|
_recall_health = {"recall_healthy": None}
|
|
1957
|
+
identity = getattr(application.state, "daemon_descriptor", None)
|
|
1700
1958
|
return {
|
|
1701
1959
|
"status": "ok",
|
|
1960
|
+
"ready": fully_ready,
|
|
1961
|
+
"readiness": readiness,
|
|
1702
1962
|
"pid": os.getpid(),
|
|
1703
1963
|
"engine": "initialized" if engine else "unavailable",
|
|
1704
1964
|
"version": getattr(application, 'version', 'unknown'),
|
|
@@ -1708,6 +1968,10 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
1708
1968
|
# v3.6.8: True iff the semantic channel actually fired on the last
|
|
1709
1969
|
# health probe; includes self-heal counters.
|
|
1710
1970
|
"recall_health": _recall_health,
|
|
1971
|
+
**(identity.public_health_fields() if identity is not None else {}),
|
|
1972
|
+
# Runtime readiness is more precise than descriptor lifecycle.
|
|
1973
|
+
# A process can be alive and identity-valid while retrieval warms.
|
|
1974
|
+
"state": runtime_state,
|
|
1711
1975
|
}
|
|
1712
1976
|
|
|
1713
1977
|
@application.get("/recall")
|
|
@@ -1736,6 +2000,16 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
1736
2000
|
if not effective_sid:
|
|
1737
2001
|
import time as _t
|
|
1738
2002
|
effective_sid = f"http:{int(_t.time() * 1000)}"
|
|
2003
|
+
recall_actor = getattr(request.state, "authenticated_actor", "")
|
|
2004
|
+
if not recall_actor:
|
|
2005
|
+
from superlocalmemory.server.write_identity import (
|
|
2006
|
+
require_http_mutation_actor,
|
|
2007
|
+
)
|
|
2008
|
+
recall_actor = require_http_mutation_actor(
|
|
2009
|
+
request,
|
|
2010
|
+
getattr(application.state, "daemon_descriptor", None),
|
|
2011
|
+
actor_kind="http-recall",
|
|
2012
|
+
)
|
|
1739
2013
|
# v3.4.32: mark recall in-flight so the pending materializer pauses
|
|
1740
2014
|
# v3.4.52: run engine.recall() in a thread-pool executor so the
|
|
1741
2015
|
# FastAPI event loop stays responsive for /health, /remember, and
|
|
@@ -1747,13 +2021,15 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
1747
2021
|
# prevent resource oversaturation. Ollama serialises concurrent
|
|
1748
2022
|
# embedding calls and the reranker subprocess has a single lock —
|
|
1749
2023
|
# queuing more than ~3 concurrent full recalls just adds latency.
|
|
1750
|
-
# Fast recalls
|
|
2024
|
+
# Fast recalls retain the bounded retrieval channels but skip remote
|
|
2025
|
+
# agentic verification, so they do not need the full-recall semaphore.
|
|
1751
2026
|
if not fast:
|
|
1752
2027
|
await _recall_semaphore.acquire()
|
|
1753
2028
|
try:
|
|
1754
2029
|
response = await asyncio.to_thread(
|
|
1755
2030
|
engine.recall,
|
|
1756
2031
|
search_query, limit=limit, session_id=effective_sid,
|
|
2032
|
+
agent_id=recall_actor,
|
|
1757
2033
|
fast=fast,
|
|
1758
2034
|
include_global=include_global,
|
|
1759
2035
|
include_shared=include_shared,
|
|
@@ -1772,6 +2048,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
1772
2048
|
# v3.6.6: single shared serialization chokepoint — budget + source
|
|
1773
2049
|
# discipline + no_confident_match, identical across every surface.
|
|
1774
2050
|
from superlocalmemory.server.recall_serializer import (
|
|
2051
|
+
recall_response_metadata,
|
|
1775
2052
|
serialize_recall_response,
|
|
1776
2053
|
)
|
|
1777
2054
|
_rc = getattr(engine._config, "retrieval", None)
|
|
@@ -1800,6 +2077,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
1800
2077
|
"results": results,
|
|
1801
2078
|
"count": len(results),
|
|
1802
2079
|
"no_confident_match": no_confident_match,
|
|
2080
|
+
**recall_response_metadata(response),
|
|
1803
2081
|
}
|
|
1804
2082
|
except Exception as exc:
|
|
1805
2083
|
raise HTTPException(500, detail=str(exc))
|
|
@@ -1809,12 +2087,18 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
1809
2087
|
_end_recall()
|
|
1810
2088
|
|
|
1811
2089
|
@application.post("/remember")
|
|
1812
|
-
async def remember(
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
2090
|
+
async def remember(
|
|
2091
|
+
req: RememberRequest,
|
|
2092
|
+
request: Request,
|
|
2093
|
+
wait: bool = False,
|
|
2094
|
+
):
|
|
2095
|
+
"""Persist through the durable canonical ingestion state machine.
|
|
2096
|
+
|
|
2097
|
+
The default path returns after the relational/FTS projection is
|
|
2098
|
+
queryable. ``wait=true`` materializes the same operation inline; the
|
|
2099
|
+
background worker handles all other queryable operations.
|
|
1817
2100
|
"""
|
|
2101
|
+
trusted_actor_id = _require_write_actor(request)
|
|
1818
2102
|
_update_activity()
|
|
1819
2103
|
engine = _get_engine_or_503()
|
|
1820
2104
|
|
|
@@ -1825,95 +2109,100 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
1825
2109
|
scope = req.scope or getattr(_scope_cfg, "default_scope", "personal")
|
|
1826
2110
|
shared_with = req.shared_with
|
|
1827
2111
|
|
|
1828
|
-
if wait:
|
|
1829
|
-
try:
|
|
1830
|
-
metadata = {"tags": req.tags} if req.tags else {}
|
|
1831
|
-
extra = getattr(req, "metadata", None)
|
|
1832
|
-
if isinstance(extra, dict):
|
|
1833
|
-
metadata.update(extra)
|
|
1834
|
-
fact_ids = engine.store(
|
|
1835
|
-
req.content, metadata=metadata,
|
|
1836
|
-
scope=scope, shared_with=shared_with,
|
|
1837
|
-
)
|
|
1838
|
-
_emit_event(
|
|
1839
|
-
"memory.stored",
|
|
1840
|
-
payload={
|
|
1841
|
-
"fact_ids": list(fact_ids) if fact_ids else [],
|
|
1842
|
-
"count": len(fact_ids) if fact_ids else 0,
|
|
1843
|
-
"path": "remember_sync",
|
|
1844
|
-
"content_preview": req.content[:120],
|
|
1845
|
-
},
|
|
1846
|
-
)
|
|
1847
|
-
return {"ok": True, "fact_ids": fact_ids, "count": len(fact_ids)}
|
|
1848
|
-
except Exception as exc:
|
|
1849
|
-
raise HTTPException(500, detail=str(exc))
|
|
1850
|
-
|
|
1851
2112
|
try:
|
|
1852
|
-
from superlocalmemory.
|
|
2113
|
+
from superlocalmemory.core.engine_ingestion import (
|
|
2114
|
+
build_engine_ingestion_command,
|
|
2115
|
+
)
|
|
2116
|
+
from superlocalmemory.core.ingestion_command import (
|
|
2117
|
+
IngestionRequest,
|
|
2118
|
+
IngestionState,
|
|
2119
|
+
)
|
|
2120
|
+
|
|
1853
2121
|
meta = {}
|
|
1854
2122
|
if req.tags:
|
|
1855
2123
|
meta["tags"] = req.tags
|
|
1856
2124
|
extra = getattr(req, "metadata", None)
|
|
1857
2125
|
if isinstance(extra, dict):
|
|
1858
2126
|
meta.update(extra)
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
meta
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
)
|
|
1878
|
-
except Exception as fexc:
|
|
1879
|
-
logger.warning("store_fast failed, falling back to pending-only: %s", fexc)
|
|
1880
|
-
# Enqueue for async enrichment (embedding + entities + graph). The
|
|
1881
|
-
# materializer detects the already-inserted verbatim fact and enriches
|
|
1882
|
-
# it in place rather than duplicating.
|
|
1883
|
-
pending_id = store_pending(
|
|
1884
|
-
req.content, tags=req.tags or "", metadata=meta,
|
|
1885
|
-
)
|
|
2127
|
+
command = build_engine_ingestion_command(engine)
|
|
2128
|
+
receipt = command.submit(IngestionRequest(
|
|
2129
|
+
content=req.content,
|
|
2130
|
+
profile_id=engine._profile_id,
|
|
2131
|
+
source_type="http",
|
|
2132
|
+
idempotency_key=req.idempotency_key or uuid.uuid4().hex,
|
|
2133
|
+
metadata=meta,
|
|
2134
|
+
scope=scope,
|
|
2135
|
+
shared_with=tuple(shared_with or ()),
|
|
2136
|
+
trusted_actor_id=trusted_actor_id,
|
|
2137
|
+
session_id=req.session_id,
|
|
2138
|
+
))
|
|
2139
|
+
|
|
2140
|
+
result = command.materialize(receipt.operation_id) if wait else receipt
|
|
2141
|
+
if result.state is IngestionState.FAILED:
|
|
2142
|
+
raise RuntimeError(result.last_error or "materialization failed")
|
|
2143
|
+
|
|
2144
|
+
fact_ids = list(result.fact_ids)
|
|
1886
2145
|
_emit_event(
|
|
1887
|
-
"memory.queued",
|
|
2146
|
+
"memory.stored" if wait else "memory.queued",
|
|
1888
2147
|
payload={
|
|
1889
|
-
"
|
|
2148
|
+
"operation_id": result.operation_id,
|
|
2149
|
+
"fact_ids": fact_ids,
|
|
1890
2150
|
"tags": req.tags or "",
|
|
1891
2151
|
"content_preview": req.content[:120],
|
|
2152
|
+
"path": "remember_sync" if wait else "remember_queryable",
|
|
1892
2153
|
},
|
|
1893
2154
|
)
|
|
1894
2155
|
return {
|
|
1895
2156
|
"ok": True,
|
|
1896
2157
|
"fact_ids": fact_ids,
|
|
1897
2158
|
"count": len(fact_ids),
|
|
1898
|
-
"
|
|
1899
|
-
|
|
1900
|
-
|
|
2159
|
+
"operation_id": result.operation_id,
|
|
2160
|
+
# One-release compatibility alias. The durable operation ID is
|
|
2161
|
+
# opaque and replaces the integer pending.db row identifier.
|
|
2162
|
+
"pending_id": result.operation_id,
|
|
2163
|
+
"status": "stored" if wait else "queryable",
|
|
2164
|
+
"materialization_state": result.state.value,
|
|
2165
|
+
"note": (
|
|
2166
|
+
"canonical ingestion complete"
|
|
2167
|
+
if wait
|
|
2168
|
+
else "queryable now; canonical enrichment pending"
|
|
2169
|
+
),
|
|
1901
2170
|
}
|
|
1902
2171
|
except Exception as exc:
|
|
1903
2172
|
raise HTTPException(500, detail=str(exc))
|
|
1904
2173
|
|
|
1905
2174
|
@application.post("/observe")
|
|
1906
|
-
async def observe(req: ObserveRequest):
|
|
2175
|
+
async def observe(req: ObserveRequest, request: Request):
|
|
1907
2176
|
_update_activity()
|
|
1908
|
-
|
|
2177
|
+
from superlocalmemory.server.write_identity import (
|
|
2178
|
+
authenticated_request_actor,
|
|
2179
|
+
)
|
|
2180
|
+
actor_id = authenticated_request_actor(
|
|
2181
|
+
request,
|
|
2182
|
+
getattr(application.state, "daemon_descriptor", None),
|
|
2183
|
+
actor_kind="http-observe",
|
|
2184
|
+
)
|
|
2185
|
+
result = _observe_buffer.enqueue(
|
|
2186
|
+
req.content,
|
|
2187
|
+
trusted_actor_id=actor_id,
|
|
2188
|
+
)
|
|
1909
2189
|
return result
|
|
1910
2190
|
|
|
1911
2191
|
# v3.4.26: CCQ consolidation via daemon so MCP clients don't need to
|
|
1912
2192
|
# import CognitiveConsolidator (which pulls sentence-transformers).
|
|
1913
2193
|
@application.post("/consolidate/cognitive")
|
|
1914
|
-
async def consolidate_cognitive_endpoint(body: dict):
|
|
2194
|
+
async def consolidate_cognitive_endpoint(body: dict, request: Request):
|
|
1915
2195
|
_update_activity()
|
|
1916
2196
|
engine = _get_engine_or_503()
|
|
2197
|
+
from superlocalmemory.server.route_mutations import (
|
|
2198
|
+
authorize_route_mutation,
|
|
2199
|
+
)
|
|
2200
|
+
authorization = authorize_route_mutation(
|
|
2201
|
+
request,
|
|
2202
|
+
operation="update",
|
|
2203
|
+
source_agent_id="http-cognitive-consolidation",
|
|
2204
|
+
profile_id=body.get("profile_id") or engine.profile_id,
|
|
2205
|
+
)
|
|
1917
2206
|
try:
|
|
1918
2207
|
pid = body.get("profile_id") or engine.profile_id
|
|
1919
2208
|
from superlocalmemory.encoding.cognitive_consolidator import (
|
|
@@ -1921,21 +2210,33 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
1921
2210
|
)
|
|
1922
2211
|
consolidator = CognitiveConsolidator(db=engine._db)
|
|
1923
2212
|
result = consolidator.run_pipeline(pid)
|
|
2213
|
+
authorization.complete()
|
|
1924
2214
|
return {
|
|
1925
2215
|
"ok": True,
|
|
1926
2216
|
"profile_id": pid,
|
|
1927
2217
|
"clusters_processed": result.clusters_processed,
|
|
1928
2218
|
"blocks_created": result.blocks_created,
|
|
1929
2219
|
}
|
|
2220
|
+
except HTTPException:
|
|
2221
|
+
raise
|
|
1930
2222
|
except Exception as exc:
|
|
1931
2223
|
raise HTTPException(500, detail=str(exc))
|
|
1932
2224
|
|
|
1933
2225
|
# v3.4.26: run_maintenance via daemon so MCP doesn't import
|
|
1934
2226
|
# EbbinghausCurve, ForgettingScheduler, or ConsolidationWorker.
|
|
1935
2227
|
@application.post("/maintenance/run")
|
|
1936
|
-
async def run_maintenance_endpoint(body: dict):
|
|
2228
|
+
async def run_maintenance_endpoint(body: dict, request: Request):
|
|
1937
2229
|
_update_activity()
|
|
1938
2230
|
engine = _get_engine_or_503()
|
|
2231
|
+
from superlocalmemory.server.route_mutations import (
|
|
2232
|
+
authorize_route_mutation,
|
|
2233
|
+
)
|
|
2234
|
+
authorization = authorize_route_mutation(
|
|
2235
|
+
request,
|
|
2236
|
+
operation="update",
|
|
2237
|
+
source_agent_id="http-maintenance",
|
|
2238
|
+
profile_id=body.get("profile_id") or engine.profile_id,
|
|
2239
|
+
)
|
|
1939
2240
|
try:
|
|
1940
2241
|
pid = body.get("profile_id") or engine.profile_id
|
|
1941
2242
|
results: dict = {}
|
|
@@ -1969,7 +2270,10 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
1969
2270
|
results["behavioral"] = {"patterns_mined": count}
|
|
1970
2271
|
except Exception as exc:
|
|
1971
2272
|
results["behavioral"] = {"error": str(exc)}
|
|
2273
|
+
authorization.complete()
|
|
1972
2274
|
return {"ok": True, "profile": pid, **results}
|
|
2275
|
+
except HTTPException:
|
|
2276
|
+
raise
|
|
1973
2277
|
except Exception as exc:
|
|
1974
2278
|
raise HTTPException(500, detail=str(exc))
|
|
1975
2279
|
|
|
@@ -1987,7 +2291,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
1987
2291
|
"mode": mode,
|
|
1988
2292
|
"fact_count": fact_count,
|
|
1989
2293
|
"idle_s": round(time.monotonic() - _last_activity),
|
|
1990
|
-
"port":
|
|
2294
|
+
"port": application.state.daemon_descriptor.port,
|
|
1991
2295
|
"legacy_port": _LEGACY_PORT,
|
|
1992
2296
|
}
|
|
1993
2297
|
|
|
@@ -2011,8 +2315,9 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
2011
2315
|
raise HTTPException(500, detail=str(exc))
|
|
2012
2316
|
|
|
2013
2317
|
@application.post("/stop")
|
|
2014
|
-
async def stop():
|
|
2015
|
-
"""
|
|
2318
|
+
async def stop(request: Request):
|
|
2319
|
+
"""Gracefully stop only the capability-bound process instance."""
|
|
2320
|
+
_require_daemon_actor(request)
|
|
2016
2321
|
logger.info("Stop requested via API")
|
|
2017
2322
|
_observe_buffer.flush_sync()
|
|
2018
2323
|
# Signal uvicorn to shut down gracefully
|
|
@@ -2020,7 +2325,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
2020
2325
|
return {"status": "stopping"}
|
|
2021
2326
|
|
|
2022
2327
|
@application.post("/session/open")
|
|
2023
|
-
async def session_open(req: SessionOpenRequest):
|
|
2328
|
+
async def session_open(req: SessionOpenRequest, request: Request):
|
|
2024
2329
|
"""#49: Open a session locally — warm recall context with no model
|
|
2025
2330
|
roundtrip, so a shell/session-start hook can call it directly
|
|
2026
2331
|
(`slm session open`) instead of going through the MCP tool.
|
|
@@ -2034,25 +2339,48 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
2034
2339
|
else:
|
|
2035
2340
|
query = "recent important decisions"
|
|
2036
2341
|
try:
|
|
2037
|
-
|
|
2342
|
+
from superlocalmemory.server.write_identity import (
|
|
2343
|
+
authenticated_request_actor,
|
|
2344
|
+
)
|
|
2345
|
+
actor_id = authenticated_request_actor(
|
|
2346
|
+
request,
|
|
2347
|
+
getattr(application.state, "daemon_descriptor", None),
|
|
2348
|
+
actor_kind="http-session-open",
|
|
2349
|
+
)
|
|
2350
|
+
resp = engine.recall(
|
|
2351
|
+
query,
|
|
2352
|
+
limit=req.max_results,
|
|
2353
|
+
agent_id=actor_id,
|
|
2354
|
+
)
|
|
2038
2355
|
results = (
|
|
2039
2356
|
getattr(resp, "results", None)
|
|
2040
2357
|
or getattr(resp, "memories", None)
|
|
2041
2358
|
or []
|
|
2042
2359
|
)
|
|
2043
2360
|
return {"ok": True, "query": query, "warmed": len(results)}
|
|
2361
|
+
except HTTPException:
|
|
2362
|
+
raise
|
|
2044
2363
|
except Exception as exc:
|
|
2045
2364
|
# Warming is best-effort — never fail the session-open hook.
|
|
2046
2365
|
return {"ok": True, "query": query, "warmed": 0, "warning": str(exc)}
|
|
2047
2366
|
|
|
2048
2367
|
@application.post("/session/close")
|
|
2049
|
-
async def session_close(req: SessionCloseRequest):
|
|
2368
|
+
async def session_close(req: SessionCloseRequest, request: Request):
|
|
2050
2369
|
"""#49: Close a session locally (e.g. a Claude /quit hook calling
|
|
2051
2370
|
`slm session close`). Creates per-entity temporal summary events.
|
|
2052
2371
|
An empty session_id closes the most recent real session.
|
|
2053
2372
|
"""
|
|
2054
2373
|
_update_activity()
|
|
2055
2374
|
engine = _get_engine_or_503()
|
|
2375
|
+
from superlocalmemory.server.route_mutations import (
|
|
2376
|
+
authorize_route_mutation,
|
|
2377
|
+
)
|
|
2378
|
+
authorization = authorize_route_mutation(
|
|
2379
|
+
request,
|
|
2380
|
+
operation="update",
|
|
2381
|
+
source_agent_id="http-session-close",
|
|
2382
|
+
profile_id=engine.profile_id,
|
|
2383
|
+
)
|
|
2056
2384
|
sid = req.session_id
|
|
2057
2385
|
if not sid:
|
|
2058
2386
|
# Fall back to the most recent session that has memories.
|
|
@@ -2073,8 +2401,11 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
2073
2401
|
"message": "no session to close"}
|
|
2074
2402
|
try:
|
|
2075
2403
|
created = engine.close_session(sid)
|
|
2404
|
+
authorization.complete()
|
|
2076
2405
|
return {"ok": True, "session_id": sid,
|
|
2077
2406
|
"summary_events_created": int(created)}
|
|
2407
|
+
except HTTPException:
|
|
2408
|
+
raise
|
|
2078
2409
|
except Exception as exc:
|
|
2079
2410
|
raise HTTPException(500, detail=str(exc))
|
|
2080
2411
|
|
|
@@ -2137,15 +2468,115 @@ _materializer_stop = threading.Event()
|
|
|
2137
2468
|
_materializer_thread: threading.Thread | None = None
|
|
2138
2469
|
|
|
2139
2470
|
|
|
2140
|
-
def
|
|
2141
|
-
"""
|
|
2471
|
+
def _materializer_actor_id() -> str:
|
|
2472
|
+
"""Return the process-owned actor identity used by background writes."""
|
|
2473
|
+
descriptor = _ACTIVE_DAEMON_DESCRIPTOR
|
|
2474
|
+
if descriptor is None:
|
|
2475
|
+
from superlocalmemory.server.routes.helpers import SLM_VERSION
|
|
2142
2476
|
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2477
|
+
descriptor = _process_descriptor(_DEFAULT_PORT, SLM_VERSION, "ready")
|
|
2478
|
+
return f"daemon-capability:{descriptor.capability_fingerprint}"
|
|
2479
|
+
|
|
2480
|
+
|
|
2481
|
+
def _materialize_ingestion_one_pass(
|
|
2482
|
+
engine,
|
|
2483
|
+
*,
|
|
2484
|
+
limit: int = 50,
|
|
2485
|
+
min_queryable_age_seconds: float = 1.0,
|
|
2486
|
+
) -> tuple[int, int]:
|
|
2487
|
+
"""Materialize durable M018 work once; return ``(complete, failed)``."""
|
|
2488
|
+
# The durable queue shares the embedder/LLM with foreground recall just
|
|
2489
|
+
# like the legacy pending queue. Yield before even constructing/claiming
|
|
2490
|
+
# work so an active user recall cannot suffer priority inversion.
|
|
2491
|
+
if _recalls_in_flight() > 0:
|
|
2492
|
+
return 0, 0
|
|
2493
|
+
|
|
2494
|
+
from superlocalmemory.core.engine_ingestion import build_engine_ingestion_command
|
|
2495
|
+
from superlocalmemory.core.ingestion_command import IngestionState
|
|
2496
|
+
|
|
2497
|
+
command = build_engine_ingestion_command(engine)
|
|
2498
|
+
completed = failed = 0
|
|
2499
|
+
for operation in command.repository.list_materializable(
|
|
2500
|
+
limit=limit,
|
|
2501
|
+
min_queryable_age_seconds=min_queryable_age_seconds,
|
|
2502
|
+
):
|
|
2503
|
+
try:
|
|
2504
|
+
result = command.materialize(operation.operation_id)
|
|
2505
|
+
except Exception as exc:
|
|
2506
|
+
failed += 1
|
|
2507
|
+
logger.warning(
|
|
2508
|
+
"Ingestion operation %s could not be materialized: %s",
|
|
2509
|
+
operation.operation_id,
|
|
2510
|
+
exc,
|
|
2511
|
+
)
|
|
2512
|
+
continue
|
|
2513
|
+
if result.state is IngestionState.COMPLETE:
|
|
2514
|
+
completed += 1
|
|
2515
|
+
_emit_event(
|
|
2516
|
+
"memory.stored",
|
|
2517
|
+
payload={
|
|
2518
|
+
"operation_id": result.operation_id,
|
|
2519
|
+
"fact_ids": list(result.fact_ids),
|
|
2520
|
+
"path": "canonical_materializer",
|
|
2521
|
+
"content_preview": result.raw_content[:120],
|
|
2522
|
+
},
|
|
2523
|
+
source_agent="materializer",
|
|
2524
|
+
)
|
|
2525
|
+
else:
|
|
2526
|
+
failed += 1
|
|
2527
|
+
logger.warning(
|
|
2528
|
+
"Ingestion operation %s failed: %s",
|
|
2529
|
+
result.operation_id,
|
|
2530
|
+
result.last_error,
|
|
2531
|
+
)
|
|
2532
|
+
return completed, failed
|
|
2533
|
+
|
|
2534
|
+
|
|
2535
|
+
def _materialize_legacy_pending_item(engine, item: dict) -> str:
|
|
2536
|
+
"""Backfill one pre-M018 pending.db row through canonical ingestion."""
|
|
2537
|
+
from superlocalmemory.core.engine_ingestion import build_engine_ingestion_command
|
|
2538
|
+
from superlocalmemory.core.ingestion_command import (
|
|
2539
|
+
IngestionRequest,
|
|
2540
|
+
IngestionState,
|
|
2541
|
+
)
|
|
2542
|
+
|
|
2543
|
+
metadata_value = item.get("metadata") or "{}"
|
|
2544
|
+
try:
|
|
2545
|
+
metadata = (
|
|
2546
|
+
json.loads(metadata_value)
|
|
2547
|
+
if isinstance(metadata_value, str)
|
|
2548
|
+
else dict(metadata_value)
|
|
2549
|
+
)
|
|
2550
|
+
except (TypeError, ValueError):
|
|
2551
|
+
metadata = {}
|
|
2552
|
+
if item.get("tags"):
|
|
2553
|
+
metadata.setdefault("tags", item["tags"])
|
|
2554
|
+
scope = metadata.pop("scope", None) or "personal"
|
|
2555
|
+
shared_with = tuple(metadata.pop("shared_with", None) or ())
|
|
2556
|
+
source_type = str(metadata.pop("_slm_source_type", "legacy-pending"))
|
|
2557
|
+
idempotency_key = str(
|
|
2558
|
+
metadata.pop("_slm_idempotency_key", f"pending:{item['id']}")
|
|
2559
|
+
)
|
|
2560
|
+
command = build_engine_ingestion_command(engine)
|
|
2561
|
+
receipt = command.submit(IngestionRequest(
|
|
2562
|
+
content=item["content"],
|
|
2563
|
+
profile_id=engine._profile_id,
|
|
2564
|
+
source_type=source_type,
|
|
2565
|
+
idempotency_key=idempotency_key,
|
|
2566
|
+
metadata=metadata,
|
|
2567
|
+
scope=scope,
|
|
2568
|
+
shared_with=shared_with,
|
|
2569
|
+
trusted_actor_id=_materializer_actor_id(),
|
|
2570
|
+
session_id=str(metadata.get("session_id") or ""),
|
|
2571
|
+
))
|
|
2572
|
+
result = command.materialize(receipt.operation_id)
|
|
2573
|
+
if result.state is not IngestionState.COMPLETE:
|
|
2574
|
+
raise RuntimeError(result.last_error or "legacy pending materialization failed")
|
|
2575
|
+
return result.operation_id
|
|
2576
|
+
|
|
2577
|
+
|
|
2578
|
+
def _start_pending_materializer() -> None:
|
|
2579
|
+
"""Drain M018 operations and backfill the legacy pending.db queue."""
|
|
2149
2580
|
global _materializer_thread
|
|
2150
2581
|
|
|
2151
2582
|
def _loop():
|
|
@@ -2172,11 +2603,20 @@ def _start_pending_materializer() -> None:
|
|
|
2172
2603
|
if not _engine_logged:
|
|
2173
2604
|
logger.info("Materializer: engine acquired, starting drain loop")
|
|
2174
2605
|
_engine_logged = True
|
|
2606
|
+
|
|
2607
|
+
durable_complete, durable_failed = _materialize_ingestion_one_pass(
|
|
2608
|
+
engine,
|
|
2609
|
+
limit=50,
|
|
2610
|
+
)
|
|
2175
2611
|
pending = get_pending(limit=50)
|
|
2176
|
-
if not pending:
|
|
2612
|
+
if not pending and not durable_complete and not durable_failed:
|
|
2177
2613
|
time.sleep(1.0)
|
|
2178
2614
|
continue
|
|
2179
|
-
|
|
2615
|
+
if pending:
|
|
2616
|
+
logger.info(
|
|
2617
|
+
"Materializer: backfilling %d legacy pending memories",
|
|
2618
|
+
len(pending),
|
|
2619
|
+
)
|
|
2180
2620
|
for item in pending:
|
|
2181
2621
|
if _materializer_stop.is_set():
|
|
2182
2622
|
break
|
|
@@ -2185,92 +2625,15 @@ def _start_pending_materializer() -> None:
|
|
|
2185
2625
|
time.sleep(0.5)
|
|
2186
2626
|
waits += 1
|
|
2187
2627
|
try:
|
|
2188
|
-
|
|
2189
|
-
content = item["content"]
|
|
2190
|
-
content_hash = hashlib.md5(content.encode()).hexdigest()
|
|
2191
|
-
# v3.5.5: the write-through path already inserted a
|
|
2192
|
-
# verbatim fact (recallable via BM25). If it lacks an
|
|
2193
|
-
# embedding, ENRICH it in place (compute embedding +
|
|
2194
|
-
# upsert vector store) rather than skipping — otherwise
|
|
2195
|
-
# the fact would never be semantically searchable.
|
|
2196
|
-
# v3.6.15: scope the dedup to THIS profile. Without the
|
|
2197
|
-
# profile_id filter, a memory whose verbatim text matches
|
|
2198
|
-
# another profile's fact was treated as a duplicate and
|
|
2199
|
-
# silently dropped — cross-profile data loss + leakage.
|
|
2200
|
-
dup = engine._db.execute(
|
|
2201
|
-
"SELECT fact_id, embedding FROM atomic_facts "
|
|
2202
|
-
"WHERE content = ? AND profile_id = ? LIMIT 1",
|
|
2203
|
-
(content, engine._profile_id),
|
|
2204
|
-
)
|
|
2205
|
-
if dup:
|
|
2206
|
-
try:
|
|
2207
|
-
row = dict(dup[0])
|
|
2208
|
-
if not row.get("embedding") and engine._embedder:
|
|
2209
|
-
emb = engine._embedder.embed(content)
|
|
2210
|
-
if emb:
|
|
2211
|
-
upd = {"embedding": emb}
|
|
2212
|
-
try:
|
|
2213
|
-
fm, fv = engine._embedder.compute_fisher_params(emb)
|
|
2214
|
-
upd["fisher_mean"] = fm
|
|
2215
|
-
upd["fisher_variance"] = fv
|
|
2216
|
-
except Exception:
|
|
2217
|
-
pass
|
|
2218
|
-
engine._db.update_fact(row["fact_id"], upd)
|
|
2219
|
-
vs = getattr(engine, "_vector_store", None)
|
|
2220
|
-
if vs and getattr(vs, "available", False):
|
|
2221
|
-
vs.upsert(row["fact_id"], engine._profile_id, emb)
|
|
2222
|
-
except Exception as eexc:
|
|
2223
|
-
logger.debug("enrichment of write-through fact failed: %s", eexc)
|
|
2224
|
-
mark_done(item["id"])
|
|
2225
|
-
continue
|
|
2226
|
-
import json as _json
|
|
2227
|
-
md_str = item.get("metadata") or "{}"
|
|
2228
|
-
try:
|
|
2229
|
-
md = _json.loads(md_str)
|
|
2230
|
-
except Exception:
|
|
2231
|
-
md = {}
|
|
2232
|
-
if item.get("tags"):
|
|
2233
|
-
md.setdefault("tags", item["tags"])
|
|
2234
|
-
# v3.6.15: replay the scope the async /remember path
|
|
2235
|
-
# stashed in metadata, so a queued non-personal write
|
|
2236
|
-
# materializes with the right visibility (not personal).
|
|
2237
|
-
_mscope = md.get("scope") or "personal"
|
|
2238
|
-
_mshared = md.get("shared_with")
|
|
2239
|
-
_shared_json = _json.dumps(_mshared) if _mshared else None
|
|
2240
|
-
# Create memory row (FK target for atomic_facts)
|
|
2241
|
-
from datetime import datetime, timezone
|
|
2242
|
-
from superlocalmemory.storage.models import (
|
|
2243
|
-
AtomicFact, FactType,
|
|
2244
|
-
)
|
|
2245
|
-
mem_id = content_hash[:16]
|
|
2246
|
-
engine._db.execute(
|
|
2247
|
-
"INSERT OR IGNORE INTO memories "
|
|
2248
|
-
"(memory_id, profile_id, content, "
|
|
2249
|
-
"session_id, speaker, role, created_at, "
|
|
2250
|
-
"metadata_json, scope, shared_with) "
|
|
2251
|
-
"VALUES (?,?,?,?,?,?,?,?,?,?)",
|
|
2252
|
-
(mem_id, engine._profile_id, content,
|
|
2253
|
-
"", "", "user",
|
|
2254
|
-
datetime.now(timezone.utc).isoformat(),
|
|
2255
|
-
_json.dumps(md), _mscope, _shared_json),
|
|
2256
|
-
)
|
|
2257
|
-
fact = AtomicFact(
|
|
2258
|
-
content=content,
|
|
2259
|
-
fact_type=FactType.EPISODIC,
|
|
2260
|
-
memory_id=mem_id,
|
|
2261
|
-
profile_id=engine._profile_id,
|
|
2262
|
-
scope=_mscope,
|
|
2263
|
-
shared_with=_mshared,
|
|
2264
|
-
)
|
|
2265
|
-
engine.store_fact_direct(fact)
|
|
2628
|
+
operation_id = _materialize_legacy_pending_item(engine, item)
|
|
2266
2629
|
mark_done(item["id"])
|
|
2267
2630
|
_emit_event(
|
|
2268
2631
|
"memory.stored",
|
|
2269
2632
|
payload={
|
|
2270
2633
|
"pending_id": item["id"],
|
|
2271
|
-
"
|
|
2272
|
-
"path": "
|
|
2273
|
-
"content_preview": content[:120],
|
|
2634
|
+
"operation_id": operation_id,
|
|
2635
|
+
"path": "legacy_pending_backfill",
|
|
2636
|
+
"content_preview": item["content"][:120],
|
|
2274
2637
|
},
|
|
2275
2638
|
source_agent="materializer",
|
|
2276
2639
|
)
|
|
@@ -2293,8 +2656,42 @@ def _start_pending_materializer() -> None:
|
|
|
2293
2656
|
def start_server(port: int = _DEFAULT_PORT) -> None:
|
|
2294
2657
|
"""Start the unified daemon. Blocks until stopped."""
|
|
2295
2658
|
global _start_time
|
|
2659
|
+
assert_no_durable_root_conflict()
|
|
2660
|
+
import socket
|
|
2296
2661
|
import uvicorn
|
|
2297
2662
|
|
|
2663
|
+
# Bind before any migration or engine work. A process which cannot own
|
|
2664
|
+
# the listener must not open the user's databases or publish lifecycle
|
|
2665
|
+
# state: otherwise a stale service and a manual restart can briefly run
|
|
2666
|
+
# two engines against one SQLite root.
|
|
2667
|
+
bind_host = (
|
|
2668
|
+
os.environ.get("SLM_DAEMON_HOST")
|
|
2669
|
+
or os.environ.get("SLM_HOST")
|
|
2670
|
+
or "127.0.0.1"
|
|
2671
|
+
)
|
|
2672
|
+
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
2673
|
+
# This handles a just-closed connection in TIME_WAIT. It is safe only
|
|
2674
|
+
# with the active-listener probe immediately below; without that guard,
|
|
2675
|
+
# macOS can permit a second SO_REUSEADDR listener on the same port.
|
|
2676
|
+
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
2677
|
+
try:
|
|
2678
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
|
2679
|
+
probe.settimeout(0.5)
|
|
2680
|
+
if probe.connect_ex(("127.0.0.1", port)) == 0:
|
|
2681
|
+
raise OSError("an active daemon listener already owns the port")
|
|
2682
|
+
listener.bind((bind_host, port))
|
|
2683
|
+
listener.listen(socket.SOMAXCONN)
|
|
2684
|
+
except OSError as exc:
|
|
2685
|
+
listener.close()
|
|
2686
|
+
logger.error(
|
|
2687
|
+
"SLM daemon will not start: %s:%d is already unavailable (%s)",
|
|
2688
|
+
bind_host, port, exc,
|
|
2689
|
+
)
|
|
2690
|
+
return
|
|
2691
|
+
# The lifespan uses the configured port for its identity and health
|
|
2692
|
+
# payload, so a CLI --port must be reflected there as well.
|
|
2693
|
+
os.environ["SLM_DAEMON_PORT"] = str(port)
|
|
2694
|
+
|
|
2298
2695
|
# v3.4.23: rotate oversized logs before anything else so both the CLI
|
|
2299
2696
|
# path (`slm serve`) and the LaunchAgent path (__main__) are covered.
|
|
2300
2697
|
try:
|
|
@@ -2302,17 +2699,16 @@ def start_server(port: int = _DEFAULT_PORT) -> None:
|
|
|
2302
2699
|
except Exception:
|
|
2303
2700
|
pass # never block startup on log housekeeping
|
|
2304
2701
|
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2702
|
+
from superlocalmemory.server.routes.helpers import SLM_VERSION
|
|
2703
|
+
|
|
2704
|
+
_publish_process_descriptor(port, SLM_VERSION, "starting")
|
|
2308
2705
|
_start_time = time.monotonic()
|
|
2309
2706
|
|
|
2310
2707
|
try:
|
|
2311
2708
|
from superlocalmemory.migrations.v3_4_25_to_v3_4_26 import (
|
|
2312
2709
|
is_ready as _is_ready, migrate as _migrate,
|
|
2313
2710
|
)
|
|
2314
|
-
_data =
|
|
2315
|
-
or Path.home() / ".superlocalmemory")
|
|
2711
|
+
_data = canonical_data_root()
|
|
2316
2712
|
if not _is_ready(_data):
|
|
2317
2713
|
_migrate(_data)
|
|
2318
2714
|
except Exception as exc:
|
|
@@ -2327,18 +2723,9 @@ def start_server(port: int = _DEFAULT_PORT) -> None:
|
|
|
2327
2723
|
# v3.4.32: Continuous pending-queue materializer with recall priority.
|
|
2328
2724
|
_start_pending_materializer()
|
|
2329
2725
|
|
|
2330
|
-
log_dir =
|
|
2726
|
+
log_dir = state_path("logs")
|
|
2331
2727
|
log_dir.mkdir(parents=True, exist_ok=True)
|
|
2332
2728
|
|
|
2333
|
-
# Bind address. `SLM_DAEMON_HOST` is the canonical name; `SLM_HOST` is
|
|
2334
|
-
# accepted as a shorter alias (issue #23). Set either to 0.0.0.0 to serve
|
|
2335
|
-
# a shared instance over a trusted private network (e.g. WireGuard mesh).
|
|
2336
|
-
bind_host = (
|
|
2337
|
-
os.environ.get("SLM_DAEMON_HOST")
|
|
2338
|
-
or os.environ.get("SLM_HOST")
|
|
2339
|
-
or "127.0.0.1"
|
|
2340
|
-
)
|
|
2341
|
-
|
|
2342
2729
|
config = uvicorn.Config(
|
|
2343
2730
|
app="superlocalmemory.server.unified_daemon:create_app",
|
|
2344
2731
|
factory=True,
|
|
@@ -2350,10 +2737,10 @@ def start_server(port: int = _DEFAULT_PORT) -> None:
|
|
|
2350
2737
|
server = uvicorn.Server(config)
|
|
2351
2738
|
|
|
2352
2739
|
try:
|
|
2353
|
-
server.run()
|
|
2740
|
+
server.run(sockets=[listener])
|
|
2354
2741
|
finally:
|
|
2355
|
-
|
|
2356
|
-
|
|
2742
|
+
listener.close()
|
|
2743
|
+
_cleanup_process_descriptor(_ACTIVE_DAEMON_DESCRIPTOR)
|
|
2357
2744
|
|
|
2358
2745
|
|
|
2359
2746
|
# ---------------------------------------------------------------------------
|
|
@@ -2383,7 +2770,7 @@ def rotate_oversized_logs(log_dir: Optional[Path] = None,
|
|
|
2383
2770
|
Keeps one rotated copy (.1). Safe under concurrent start attempts:
|
|
2384
2771
|
rename is atomic on POSIX, and truncation is idempotent.
|
|
2385
2772
|
"""
|
|
2386
|
-
log_dir = log_dir or (
|
|
2773
|
+
log_dir = log_dir or state_path("logs")
|
|
2387
2774
|
try:
|
|
2388
2775
|
log_dir.mkdir(parents=True, exist_ok=True)
|
|
2389
2776
|
except Exception:
|