superlocalmemory 3.8.2 → 3.8.5
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 +57 -0
- package/README.md +3 -2
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/rbac.py +68 -76
- package/src/superlocalmemory/cli/commands.py +19 -0
- package/src/superlocalmemory/cli/ingest_cmd.py +11 -1
- package/src/superlocalmemory/cli/main.py +30 -0
- package/src/superlocalmemory/cli/pending_store.py +39 -14
- package/src/superlocalmemory/core/backend_orchestrator.py +93 -0
- package/src/superlocalmemory/core/config.py +78 -0
- package/src/superlocalmemory/core/consolidation_engine.py +79 -73
- package/src/superlocalmemory/core/engine.py +92 -11
- package/src/superlocalmemory/core/fact_consolidator.py +148 -30
- package/src/superlocalmemory/core/graph_pruner.py +436 -39
- package/src/superlocalmemory/core/ingestion_command.py +160 -31
- package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
- package/src/superlocalmemory/core/recall_pipeline.py +3 -0
- package/src/superlocalmemory/core/registry.py +5 -1
- package/src/superlocalmemory/core/remote_mode.py +3 -1
- package/src/superlocalmemory/core/scale_engine.py +41 -18
- package/src/superlocalmemory/core/store_pipeline.py +18 -4
- package/src/superlocalmemory/encoding/entity_resolver.py +18 -11
- package/src/superlocalmemory/hooks/_outcome_common.py +9 -2
- package/src/superlocalmemory/hooks/adapter_base.py +58 -44
- package/src/superlocalmemory/hooks/ide_connector.py +26 -8
- package/src/superlocalmemory/hooks/portable_kit.py +105 -9
- package/src/superlocalmemory/hooks/prewarm_auth.py +21 -2
- package/src/superlocalmemory/infra/auth_middleware.py +3 -1
- package/src/superlocalmemory/infra/cloud_backup.py +26 -27
- package/src/superlocalmemory/infra/event_bus.py +250 -88
- package/src/superlocalmemory/learning/consolidation_cycle.py +33 -16
- package/src/superlocalmemory/learning/entity_compiler.py +148 -132
- package/src/superlocalmemory/learning/memory_merge.py +97 -82
- package/src/superlocalmemory/learning/reward_archive.py +98 -90
- package/src/superlocalmemory/learning/reward_boost.py +40 -30
- package/src/superlocalmemory/mcp/http_transport.py +335 -3
- package/src/superlocalmemory/retrieval/engine.py +7 -1
- package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
- package/src/superlocalmemory/retrieval/reranker.py +98 -15
- package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
- package/src/superlocalmemory/retrieval/vector_store.py +84 -69
- package/src/superlocalmemory/server/loopback.py +91 -0
- package/src/superlocalmemory/server/origin.py +9 -4
- package/src/superlocalmemory/server/routes/backup.py +6 -2
- package/src/superlocalmemory/server/routes/behavioral.py +6 -12
- package/src/superlocalmemory/server/routes/compliance.py +20 -23
- package/src/superlocalmemory/server/routes/config_api.py +83 -0
- package/src/superlocalmemory/server/routes/helpers.py +24 -13
- package/src/superlocalmemory/server/routes/memories.py +139 -91
- package/src/superlocalmemory/server/routes/mesh.py +7 -2
- package/src/superlocalmemory/server/routes/profiles.py +20 -21
- package/src/superlocalmemory/server/routes/rbac.py +0 -1
- package/src/superlocalmemory/server/routes/tiers.py +42 -30
- package/src/superlocalmemory/server/routes/v3_api.py +67 -77
- package/src/superlocalmemory/server/unified_daemon.py +283 -39
- package/src/superlocalmemory/server/write_identity.py +22 -4
- package/src/superlocalmemory/storage/database.py +109 -19
- package/src/superlocalmemory/storage/deferred_writes.py +153 -0
- package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
- package/src/superlocalmemory/storage/memory_write.py +119 -0
- package/src/superlocalmemory/storage/migration_runner.py +7 -0
- package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +113 -78
- package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +80 -0
- package/src/superlocalmemory/storage/write_lock.py +88 -0
- package/src/superlocalmemory/ui/js/core.js +6 -1
|
@@ -596,7 +596,9 @@ def _validate_provider_url(url: str, client_host: str) -> str | None:
|
|
|
596
596
|
host = p.hostname or ""
|
|
597
597
|
if host.lower() in ("169.254.169.254", "metadata.google.internal", "metadata"):
|
|
598
598
|
return "Cloud metadata endpoints are not allowed"
|
|
599
|
-
|
|
599
|
+
from superlocalmemory.server.loopback import is_loopback as _is_loopback_host
|
|
600
|
+
|
|
601
|
+
if _is_loopback_host(client_host):
|
|
600
602
|
return None # local dashboard may target its own local/LAN endpoints
|
|
601
603
|
# SLM_REMOTE residue (#40): an allowlisted LAN dashboard is trusted exactly
|
|
602
604
|
# like the loopback one and may probe its own LAN LLM endpoint. This does
|
|
@@ -1808,7 +1810,7 @@ async def update_core_memory_block(block_id: str, request: Request):
|
|
|
1808
1810
|
)
|
|
1809
1811
|
|
|
1810
1812
|
from superlocalmemory.server.routes.helpers import DB_PATH, get_active_profile
|
|
1811
|
-
import
|
|
1813
|
+
from superlocalmemory.storage.memory_write import memory_write
|
|
1812
1814
|
from datetime import datetime, timezone
|
|
1813
1815
|
|
|
1814
1816
|
if not DB_PATH.exists():
|
|
@@ -1827,46 +1829,38 @@ async def update_core_memory_block(block_id: str, request: Request):
|
|
|
1827
1829
|
content_preview=str(content),
|
|
1828
1830
|
)
|
|
1829
1831
|
|
|
1830
|
-
conn = sqlite3.connect(str(DB_PATH))
|
|
1831
|
-
conn.row_factory = sqlite3.Row
|
|
1832
|
-
|
|
1833
|
-
# Verify block exists
|
|
1834
|
-
existing = conn.execute(
|
|
1835
|
-
"SELECT block_id, profile_id, block_type, version "
|
|
1836
|
-
"FROM core_memory_blocks WHERE block_id = ? AND profile_id = ?",
|
|
1837
|
-
(block_id, pid),
|
|
1838
|
-
).fetchone()
|
|
1839
|
-
|
|
1840
|
-
if not existing:
|
|
1841
|
-
conn.close()
|
|
1842
|
-
return JSONResponse(
|
|
1843
|
-
{"error": f"Block {block_id} not found"},
|
|
1844
|
-
status_code=404,
|
|
1845
|
-
)
|
|
1846
|
-
|
|
1847
|
-
existing_dict = dict(existing)
|
|
1848
|
-
new_version = existing_dict["version"] + 1
|
|
1849
1832
|
now = datetime.now(timezone.utc).isoformat()
|
|
1833
|
+
# memory_write: process write lock + busy_timeout.
|
|
1834
|
+
# SELECT + UPDATE + read-back are atomic inside the same connection.
|
|
1835
|
+
with memory_write(DB_PATH) as conn:
|
|
1836
|
+
# Verify block exists
|
|
1837
|
+
existing = conn.execute(
|
|
1838
|
+
"SELECT block_id, profile_id, block_type, version "
|
|
1839
|
+
"FROM core_memory_blocks WHERE block_id = ? AND profile_id = ?",
|
|
1840
|
+
(block_id, pid),
|
|
1841
|
+
).fetchone()
|
|
1850
1842
|
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
"version = ?, compiled_by = 'manual', updated_at = ? "
|
|
1854
|
-
"WHERE block_id = ? AND profile_id = ?",
|
|
1855
|
-
(content, len(content), new_version, now, block_id, pid),
|
|
1856
|
-
)
|
|
1857
|
-
conn.commit()
|
|
1843
|
+
if not existing:
|
|
1844
|
+
raise HTTPException(status_code=404, detail=f"Block {block_id} not found")
|
|
1858
1845
|
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1846
|
+
new_version = dict(existing)["version"] + 1
|
|
1847
|
+
conn.execute(
|
|
1848
|
+
"UPDATE core_memory_blocks SET content = ?, char_count = ?, "
|
|
1849
|
+
"version = ?, compiled_by = 'manual', updated_at = ? "
|
|
1850
|
+
"WHERE block_id = ? AND profile_id = ?",
|
|
1851
|
+
(content, len(content), new_version, now, block_id, pid),
|
|
1852
|
+
)
|
|
1853
|
+
# Read back updated block while connection is still open.
|
|
1854
|
+
updated = conn.execute(
|
|
1855
|
+
"SELECT block_id, block_type, content, char_count, version, "
|
|
1856
|
+
"compiled_by, updated_at FROM core_memory_blocks "
|
|
1857
|
+
"WHERE block_id = ? AND profile_id = ?",
|
|
1858
|
+
(block_id, pid),
|
|
1859
|
+
).fetchone()
|
|
1860
|
+
updated_dict = dict(updated) if updated else {"block_id": block_id, "updated": True}
|
|
1867
1861
|
|
|
1868
1862
|
authorization.complete()
|
|
1869
|
-
return
|
|
1863
|
+
return updated_dict
|
|
1870
1864
|
except HTTPException:
|
|
1871
1865
|
raise
|
|
1872
1866
|
except Exception as e:
|
|
@@ -1990,7 +1984,7 @@ async def run_forgetting(request: Request):
|
|
|
1990
1984
|
profile = body.get("profile", "")
|
|
1991
1985
|
|
|
1992
1986
|
from superlocalmemory.server.routes.helpers import get_active_profile, DB_PATH
|
|
1993
|
-
import
|
|
1987
|
+
from superlocalmemory.storage.memory_write import memory_write as _memory_write
|
|
1994
1988
|
pid = _resolve_mutation_profile(profile)
|
|
1995
1989
|
_require_manage_for_profile(request, pid)
|
|
1996
1990
|
|
|
@@ -2003,59 +1997,55 @@ async def run_forgetting(request: Request):
|
|
|
2003
1997
|
source_agent_id="http-forgetting-run",
|
|
2004
1998
|
profile_id=pid,
|
|
2005
1999
|
)
|
|
2006
|
-
conn = _sqlite3.connect(str(DB_PATH))
|
|
2007
|
-
conn.row_factory = _sqlite3.Row
|
|
2008
2000
|
|
|
2001
|
+
# memory_write: process write lock + busy_timeout — all UPDATEs atomic.
|
|
2009
2002
|
updated = 0
|
|
2010
2003
|
try:
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
"UPDATE fact_retention "
|
|
2015
|
-
"SET retention_score = MAX(0.0, retention_score * 0.9), "
|
|
2016
|
-
" last_computed_at = datetime('now') "
|
|
2017
|
-
"WHERE profile_id = ? "
|
|
2018
|
-
"AND lifecycle_zone NOT IN ('archive', 'forgotten')",
|
|
2019
|
-
(pid,),
|
|
2020
|
-
)
|
|
2021
|
-
updated = conn.total_changes
|
|
2022
|
-
|
|
2023
|
-
# Transition zones based on new retention scores
|
|
2024
|
-
zone_thresholds = [
|
|
2025
|
-
("forgotten", 0.05),
|
|
2026
|
-
("archive", 0.15),
|
|
2027
|
-
("cold", 0.35),
|
|
2028
|
-
("warm", 0.65),
|
|
2029
|
-
]
|
|
2030
|
-
for zone, threshold in zone_thresholds:
|
|
2004
|
+
with _memory_write(DB_PATH) as conn:
|
|
2005
|
+
# Apply Ebbinghaus decay: reduce retention for facts not accessed recently
|
|
2006
|
+
# Formula: retention *= exp(-0.1) for each cycle (simplified batch decay)
|
|
2031
2007
|
conn.execute(
|
|
2032
2008
|
"UPDATE fact_retention "
|
|
2033
|
-
"SET
|
|
2009
|
+
"SET retention_score = MAX(0.0, retention_score * 0.9), "
|
|
2010
|
+
" last_computed_at = datetime('now') "
|
|
2034
2011
|
"WHERE profile_id = ? "
|
|
2035
|
-
"AND retention_score < ? "
|
|
2036
2012
|
"AND lifecycle_zone NOT IN ('archive', 'forgotten')",
|
|
2037
|
-
(
|
|
2013
|
+
(pid,),
|
|
2038
2014
|
)
|
|
2015
|
+
updated = conn.total_changes
|
|
2016
|
+
|
|
2017
|
+
# Transition zones based on new retention scores
|
|
2018
|
+
zone_thresholds = [
|
|
2019
|
+
("forgotten", 0.05),
|
|
2020
|
+
("archive", 0.15),
|
|
2021
|
+
("cold", 0.35),
|
|
2022
|
+
("warm", 0.65),
|
|
2023
|
+
]
|
|
2024
|
+
for zone, threshold in zone_thresholds:
|
|
2025
|
+
conn.execute(
|
|
2026
|
+
"UPDATE fact_retention "
|
|
2027
|
+
"SET lifecycle_zone = ? "
|
|
2028
|
+
"WHERE profile_id = ? "
|
|
2029
|
+
"AND retention_score < ? "
|
|
2030
|
+
"AND lifecycle_zone NOT IN ('archive', 'forgotten')",
|
|
2031
|
+
(zone, pid, threshold),
|
|
2032
|
+
)
|
|
2039
2033
|
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
from superlocalmemory.core.lifecycle_state import reconcile_profile_lifecycle
|
|
2050
|
-
reconcile_profile_lifecycle(conn, pid)
|
|
2034
|
+
# Ensure high-retention facts are active
|
|
2035
|
+
conn.execute(
|
|
2036
|
+
"UPDATE fact_retention "
|
|
2037
|
+
"SET lifecycle_zone = 'active' "
|
|
2038
|
+
"WHERE profile_id = ? AND retention_score >= 0.65 "
|
|
2039
|
+
"AND lifecycle_zone NOT IN ('archive', 'forgotten')",
|
|
2040
|
+
(pid,),
|
|
2041
|
+
)
|
|
2051
2042
|
|
|
2052
|
-
|
|
2043
|
+
from superlocalmemory.core.lifecycle_state import reconcile_profile_lifecycle
|
|
2044
|
+
reconcile_profile_lifecycle(conn, pid)
|
|
2053
2045
|
except Exception as exc:
|
|
2054
2046
|
logger.exception("run_forgetting decay failed")
|
|
2055
|
-
conn.close()
|
|
2056
2047
|
return {"success": False, "error": "internal error"}
|
|
2057
2048
|
|
|
2058
|
-
conn.close()
|
|
2059
2049
|
authorization.complete()
|
|
2060
2050
|
return {"success": True, "facts_decayed": updated, "profile": pid}
|
|
2061
2051
|
except HTTPException:
|
|
@@ -72,6 +72,22 @@ from superlocalmemory.learning.source_quality import (
|
|
|
72
72
|
repair_historical_source_quality,
|
|
73
73
|
)
|
|
74
74
|
|
|
75
|
+
# D-02 fix: import at module load so the reference survives interpreter teardown.
|
|
76
|
+
# A lazy import inside the shutdown path races the namespace-cleanup phase on
|
|
77
|
+
# CPython and can raise ImportError: "cannot import name 'trigram_index'".
|
|
78
|
+
try:
|
|
79
|
+
from superlocalmemory.learning import trigram_index as _trigram_index_mod
|
|
80
|
+
except ImportError: # pragma: no cover — defensive for stripped installs
|
|
81
|
+
_trigram_index_mod = None # type: ignore[assignment]
|
|
82
|
+
|
|
83
|
+
# D-03 fix: move this import to module top so the already-compiled .pyc object
|
|
84
|
+
# is used at shutdown time. A lazy import of a source .py during interpreter
|
|
85
|
+
# shutdown on macOS triggers TCC xattr checks and can raise PermissionError.
|
|
86
|
+
try:
|
|
87
|
+
from superlocalmemory.hooks._outcome_common import _perf_log_flush as _perf_log_flush_fn
|
|
88
|
+
except ImportError: # pragma: no cover — defensive for stripped installs
|
|
89
|
+
_perf_log_flush_fn = None # type: ignore[assignment]
|
|
90
|
+
|
|
75
91
|
logger = logging.getLogger("superlocalmemory.unified_daemon")
|
|
76
92
|
|
|
77
93
|
_DEFAULT_PORT = 8765
|
|
@@ -453,6 +469,61 @@ def _emit_event(
|
|
|
453
469
|
import asyncio as _asyncio
|
|
454
470
|
_recall_semaphore = _asyncio.Semaphore(3)
|
|
455
471
|
|
|
472
|
+
|
|
473
|
+
def _recall_budget_s() -> float:
|
|
474
|
+
"""Generous latency budget for a recall before the keyword fallback (v3.8.3).
|
|
475
|
+
|
|
476
|
+
SLM's value is quality recall under heavy multi-agent load, so semantic
|
|
477
|
+
recall is given ample time; the keyword fallback is a LAST-RESORT safety
|
|
478
|
+
net for a genuine hang (e.g. a wedged embedder), not a speed cutoff. Tune
|
|
479
|
+
with SLM_SEARCH_RECALL_TIMEOUT_S (shared with the dashboard search route).
|
|
480
|
+
"""
|
|
481
|
+
import os
|
|
482
|
+
try:
|
|
483
|
+
v = float(os.environ.get("SLM_SEARCH_RECALL_TIMEOUT_S", ""))
|
|
484
|
+
return v if v > 0 else 25.0
|
|
485
|
+
except (TypeError, ValueError):
|
|
486
|
+
return 25.0
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _recall_keyword_fallback(engine, query: str, limit: int) -> dict:
|
|
490
|
+
"""Fast profile-scoped keyword (LIKE) fallback for /recall.
|
|
491
|
+
|
|
492
|
+
Used only when semantic recall exceeds its budget, so CLI/MCP callers get
|
|
493
|
+
a bounded response instead of hanging. Mirrors the dashboard /api/search
|
|
494
|
+
fallback shape (retrieval_mode=degraded_lexical).
|
|
495
|
+
"""
|
|
496
|
+
results = []
|
|
497
|
+
try:
|
|
498
|
+
rows = engine._db.execute(
|
|
499
|
+
"SELECT fact_id, content, confidence FROM atomic_facts "
|
|
500
|
+
"WHERE profile_id = ? AND content LIKE ? "
|
|
501
|
+
"ORDER BY confidence DESC LIMIT ?",
|
|
502
|
+
(engine.profile_id, f"%{query}%", limit),
|
|
503
|
+
)
|
|
504
|
+
for pos, r in enumerate(rows, start=1):
|
|
505
|
+
d = dict(r)
|
|
506
|
+
results.append({
|
|
507
|
+
"fact_id": d.get("fact_id"),
|
|
508
|
+
"content": (d.get("content") or "")[:2400],
|
|
509
|
+
"score": None, "relevance_score": None, "ranking_score": None,
|
|
510
|
+
"confidence": d.get("confidence"),
|
|
511
|
+
"rank_position": pos,
|
|
512
|
+
})
|
|
513
|
+
except Exception as exc:
|
|
514
|
+
logger.warning("recall keyword fallback failed (non-fatal): %s", exc)
|
|
515
|
+
return {
|
|
516
|
+
"ok": True,
|
|
517
|
+
"query": query,
|
|
518
|
+
"query_type": "text_search",
|
|
519
|
+
"retrieval_mode": "degraded_lexical",
|
|
520
|
+
"degraded_reason": "recall_budget_exceeded",
|
|
521
|
+
"result_count": len(results),
|
|
522
|
+
"results": results,
|
|
523
|
+
"count": len(results),
|
|
524
|
+
"no_confident_match": True,
|
|
525
|
+
}
|
|
526
|
+
|
|
456
527
|
# v3.4.52: Embedding model warm state. Set to True by the async pre-warm
|
|
457
528
|
# thread once Ollama has loaded the embedding model. /health reports this
|
|
458
529
|
# so MCP clients can wait for warm state before issuing recall calls.
|
|
@@ -746,6 +817,29 @@ def _warm_spreading_activation(engine, runtime) -> bool:
|
|
|
746
817
|
sa.search(query_embedding, profile_id=active_pid, top_k=7)
|
|
747
818
|
else:
|
|
748
819
|
sa.search(query_embedding, profile_id=active_pid, top_k=7)
|
|
820
|
+
# v3.8.5: pre-load the per-profile graph-metrics cache (PageRank +
|
|
821
|
+
# community for every fact) here in the background, so the FIRST real
|
|
822
|
+
# recall does not pay the ~12k-row load on its hot path. Observed as a
|
|
823
|
+
# large chunk of the 8-13s "cold first query" spike: the metrics cache
|
|
824
|
+
# was loading lazily during the user's first recall instead of at boot.
|
|
825
|
+
try:
|
|
826
|
+
sa._load_graph_metrics_cache(active_pid)
|
|
827
|
+
except Exception:
|
|
828
|
+
pass
|
|
829
|
+
# v3.8.5: warm the EntityGraphChannel's in-memory adjacency cache (ALL
|
|
830
|
+
# ~208K edges + entity maps + graph metrics, ~18 MB) at boot. This was
|
|
831
|
+
# the dominant cause of the "cold first query" spike (8-13s): the cache
|
|
832
|
+
# loaded lazily on the FIRST real recall that routed to the entity
|
|
833
|
+
# channel, not during warmup. Force it here via the channel's own
|
|
834
|
+
# search entry so it is warm before any user query.
|
|
835
|
+
try:
|
|
836
|
+
entity_channel = getattr(retr, "_entity", None)
|
|
837
|
+
if entity_channel is not None:
|
|
838
|
+
entity_channel.search(
|
|
839
|
+
"memory graph adjacency warmup", active_pid, top_k=5,
|
|
840
|
+
)
|
|
841
|
+
except Exception:
|
|
842
|
+
pass
|
|
749
843
|
logger.info(
|
|
750
844
|
"Spreading-activation graph pre-warmed for profile %s", active_pid,
|
|
751
845
|
)
|
|
@@ -1307,11 +1401,15 @@ async def lifespan(application: FastAPI):
|
|
|
1307
1401
|
# full sort. Without them full recall takes 7-10s on
|
|
1308
1402
|
# >1M edges (the SpreadingActivation 4-UNION query disk-sorts every
|
|
1309
1403
|
# node's neighbor list on each call). With them: sub-second.
|
|
1404
|
+
#
|
|
1405
|
+
# Concurrency fix (v3.8.4): wrapped in memory_write() so the process
|
|
1406
|
+
# write lock is acquired before the connection is opened. Previously
|
|
1407
|
+
# the raw sqlite3.connect() bypassed get_write_lock() and had no
|
|
1408
|
+
# busy_timeout, which risked SQLITE_BUSY at daemon startup when other
|
|
1409
|
+
# writers (hook / CLI) were already active.
|
|
1310
1410
|
try:
|
|
1311
|
-
import
|
|
1312
|
-
|
|
1313
|
-
try:
|
|
1314
|
-
_idx_conn.execute("PRAGMA journal_mode=WAL")
|
|
1411
|
+
from superlocalmemory.storage.memory_write import memory_write as _mw
|
|
1412
|
+
with _mw(_memory_db) as _idx_conn:
|
|
1315
1413
|
_idx_conn.execute(
|
|
1316
1414
|
"CREATE INDEX IF NOT EXISTS idx_edges_source_weight "
|
|
1317
1415
|
"ON graph_edges(profile_id, source_id, weight DESC)"
|
|
@@ -1328,10 +1426,6 @@ async def lifespan(application: FastAPI):
|
|
|
1328
1426
|
"CREATE INDEX IF NOT EXISTS idx_assoc_target_weight "
|
|
1329
1427
|
"ON association_edges(profile_id, target_fact_id, weight DESC)"
|
|
1330
1428
|
)
|
|
1331
|
-
finally:
|
|
1332
|
-
# CP-09: close even if an execute() raises, so the connection
|
|
1333
|
-
# (and its file handle / shared DB lock) never leaks.
|
|
1334
|
-
_idx_conn.close()
|
|
1335
1429
|
except Exception as _idx_exc:
|
|
1336
1430
|
logger.debug("SpreadingActivation covering indexes skipped: %s", _idx_exc)
|
|
1337
1431
|
|
|
@@ -1358,14 +1452,36 @@ async def lifespan(application: FastAPI):
|
|
|
1358
1452
|
_embedding_warm = False
|
|
1359
1453
|
def _warmup_embedder():
|
|
1360
1454
|
global _embedding_warm
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1455
|
+
import time as _t
|
|
1456
|
+
# RETRY: the retrieval engine / embedder can be created lazily a
|
|
1457
|
+
# moment AFTER this thread starts. The old one-shot attempt often
|
|
1458
|
+
# captured a None embedder and left _embedding_warm stuck False
|
|
1459
|
+
# forever — so /health reported not-ready even though on-demand
|
|
1460
|
+
# embeds worked. A stuck not-ready can make an auto-start hook
|
|
1461
|
+
# believe the daemon is down and spawn a DUPLICATE daemon (two
|
|
1462
|
+
# processes writing memory.db → cross-process SQLITE_BUSY). Poll
|
|
1463
|
+
# until the real embedder (retrieval_eng._embedder, the same one
|
|
1464
|
+
# recall uses) is available, warm it, and flip the flag.
|
|
1465
|
+
for _attempt in range(240): # ~120s max at 0.5s steps
|
|
1466
|
+
try:
|
|
1467
|
+
_re = retrieval_eng or getattr(engine, '_retrieval_engine', None)
|
|
1468
|
+
embedder = getattr(_re, '_embedder', None) if _re else None
|
|
1469
|
+
if embedder is not None and hasattr(embedder, 'embed'):
|
|
1470
|
+
embedder.embed("warmup")
|
|
1471
|
+
_embedding_warm = True
|
|
1472
|
+
logger.info(
|
|
1473
|
+
"Embedding worker pre-warmed (model resident, "
|
|
1474
|
+
"keep_alive=-1)"
|
|
1475
|
+
)
|
|
1476
|
+
return
|
|
1477
|
+
except Exception as exc:
|
|
1478
|
+
logger.debug("Embedding warmup attempt %d failed: %s",
|
|
1479
|
+
_attempt, exc)
|
|
1480
|
+
_t.sleep(0.5)
|
|
1481
|
+
logger.warning(
|
|
1482
|
+
"Embedding warmup did not complete after retries; /health "
|
|
1483
|
+
"may report not-ready even though on-demand embeds work"
|
|
1484
|
+
)
|
|
1369
1485
|
|
|
1370
1486
|
def _warmup_recall():
|
|
1371
1487
|
"""v3.4.62: Fire a full recall after embedding warms up.
|
|
@@ -1407,6 +1523,20 @@ async def lifespan(application: FastAPI):
|
|
|
1407
1523
|
# v3.8: the --fast recalls above skip spreading activation; warm
|
|
1408
1524
|
# that channel directly so the first FULL recall is not cold.
|
|
1409
1525
|
_warm_spreading_activation(engine, profile_runtime)
|
|
1526
|
+
# v3.8.5: the fast recalls above do NOT exercise the full ranking
|
|
1527
|
+
# path or the agentic round, so the FIRST real user query that
|
|
1528
|
+
# takes the full path paid an 8-13s cold cost (ranking model +
|
|
1529
|
+
# graph-metrics load). Fire ONE full (fast=False) recall here in
|
|
1530
|
+
# the background so those load at boot, not on a user's query.
|
|
1531
|
+
# Best-effort; never blocks readiness.
|
|
1532
|
+
try:
|
|
1533
|
+
with profile_runtime.operation_nowait() as _fsnap:
|
|
1534
|
+
if _fsnap is not None:
|
|
1535
|
+
engine.recall(
|
|
1536
|
+
"memory recall performance", limit=5, fast=False,
|
|
1537
|
+
)
|
|
1538
|
+
except Exception as _fexc:
|
|
1539
|
+
logger.debug("Full-path warmup skipped (non-fatal): %s", _fexc)
|
|
1410
1540
|
elapsed = round((_t.monotonic() - t0) * 1000)
|
|
1411
1541
|
logger.info(
|
|
1412
1542
|
"Recall engine pre-warmed in %dms", elapsed,
|
|
@@ -1424,6 +1554,11 @@ async def lifespan(application: FastAPI):
|
|
|
1424
1554
|
"""
|
|
1425
1555
|
import time as _t
|
|
1426
1556
|
from pathlib import Path as _P
|
|
1557
|
+
if os.environ.get("SLM_DISABLE_VS_BACKFILL") == "1":
|
|
1558
|
+
logger.info(
|
|
1559
|
+
"VS backfill disabled via SLM_DISABLE_VS_BACKFILL=1"
|
|
1560
|
+
)
|
|
1561
|
+
return
|
|
1427
1562
|
for _ in range(120):
|
|
1428
1563
|
if _embedding_warm:
|
|
1429
1564
|
break
|
|
@@ -1455,7 +1590,42 @@ async def lifespan(application: FastAPI):
|
|
|
1455
1590
|
continue
|
|
1456
1591
|
if vs.count(pid) >= int(len(with_emb) * 0.98):
|
|
1457
1592
|
continue # already complete — no-op
|
|
1458
|
-
|
|
1593
|
+
# Fix: route each upsert through db._lock with cooperative
|
|
1594
|
+
# yield between facts. Previously called
|
|
1595
|
+
# vs.rebuild_from_facts() which opens its own sqlite3
|
|
1596
|
+
# connection (bypassing db._lock), causing concurrent
|
|
1597
|
+
# backfill_missing_embeddings writes to hit SQLITE_BUSY
|
|
1598
|
+
# while holding db._lock — starving user writes for ~50 s.
|
|
1599
|
+
import os as _selfheal_os
|
|
1600
|
+
# Stage 2 (write-queue plan): batch upserts so the write lock
|
|
1601
|
+
# is acquired a FEW times with a clear release window (pause)
|
|
1602
|
+
# between batches — instead of thousands of rapid
|
|
1603
|
+
# re-acquisitions. threading.RLock is NOT fair, so on a
|
|
1604
|
+
# fresh boot a waiting user /remember could be starved for
|
|
1605
|
+
# ~50 s behind the per-fact churn. A real pause between
|
|
1606
|
+
# bounded batches guarantees the waiting user write wins the
|
|
1607
|
+
# lock promptly. One-time backfill; steady state no-ops.
|
|
1608
|
+
_batch = max(1, int(
|
|
1609
|
+
_selfheal_os.environ.get("SLM_SELFHEAL_BATCH", "50")))
|
|
1610
|
+
_pause = max(0.0, float(
|
|
1611
|
+
_selfheal_os.environ.get("SLM_SELFHEAL_BATCH_PAUSE_S", "0.05")))
|
|
1612
|
+
n = 0
|
|
1613
|
+
for _i in range(0, len(with_emb), _batch):
|
|
1614
|
+
_chunk = with_emb[_i:_i + _batch]
|
|
1615
|
+
with db._lock:
|
|
1616
|
+
for _fact_id, _profile_id, _embedding in _chunk:
|
|
1617
|
+
try:
|
|
1618
|
+
if vs.upsert(_fact_id, _profile_id, _embedding):
|
|
1619
|
+
n += 1
|
|
1620
|
+
except Exception as _upsert_exc:
|
|
1621
|
+
logger.warning(
|
|
1622
|
+
"VS backfill[%s]: upsert failed for %s: %s",
|
|
1623
|
+
pid, str(_fact_id)[:16], _upsert_exc,
|
|
1624
|
+
)
|
|
1625
|
+
# Release window: let any waiting user write through
|
|
1626
|
+
# before grabbing the lock again.
|
|
1627
|
+
if _pause > 0:
|
|
1628
|
+
_t.sleep(_pause)
|
|
1459
1629
|
logger.info(
|
|
1460
1630
|
"VS backfill[%s]: indexed %d of %d embedded facts",
|
|
1461
1631
|
pid, n, len(with_emb),
|
|
@@ -2101,18 +2271,23 @@ async def lifespan(application: FastAPI):
|
|
|
2101
2271
|
logger.warning("evolution cost-conn cache close failed: %s", exc)
|
|
2102
2272
|
|
|
2103
2273
|
# Drop the trigram cache conn symmetrically.
|
|
2274
|
+
# D-02: use module-level reference (_trigram_index_mod) to avoid re-importing
|
|
2275
|
+
# during interpreter teardown when the learning namespace may be gone.
|
|
2104
2276
|
try:
|
|
2105
|
-
|
|
2106
|
-
|
|
2277
|
+
if _trigram_index_mod is not None:
|
|
2278
|
+
_trigram_index_mod._reset_cache_conn()
|
|
2107
2279
|
except Exception as exc: # pragma: no cover — defensive
|
|
2108
2280
|
logger.warning("trigram cache conn close failed: %s", exc)
|
|
2109
2281
|
|
|
2110
2282
|
# Flush the perf-log fd explicitly (the atexit hook still fires
|
|
2111
2283
|
# but explicit close here is cheap insurance against uvicorn
|
|
2112
2284
|
# killing the process before atexit runs).
|
|
2285
|
+
# D-03: use module-level reference (_perf_log_flush_fn) — a lazy import here
|
|
2286
|
+
# triggers macOS TCC xattr checks on the source .py at shutdown time, causing
|
|
2287
|
+
# PermissionError: "Operation not permitted: .../hooks/_outcome_common.py".
|
|
2113
2288
|
try:
|
|
2114
|
-
|
|
2115
|
-
|
|
2289
|
+
if _perf_log_flush_fn is not None:
|
|
2290
|
+
_perf_log_flush_fn()
|
|
2116
2291
|
except Exception as exc: # pragma: no cover — defensive
|
|
2117
2292
|
logger.warning("perf_log flush failed: %s", exc)
|
|
2118
2293
|
|
|
@@ -2418,7 +2593,33 @@ def _register_dashboard_routes(application: FastAPI) -> None:
|
|
|
2418
2593
|
|
|
2419
2594
|
Extracted from api.py's create_app() to avoid duplicate MemoryEngine.
|
|
2420
2595
|
"""
|
|
2421
|
-
from superlocalmemory.server.api import UI_DIR
|
|
2596
|
+
from superlocalmemory.server.api import UI_DIR as _source_ui_dir
|
|
2597
|
+
|
|
2598
|
+
# D-04: Copy UI assets to the data dir at daemon startup to avoid macOS
|
|
2599
|
+
# xattr/TCC PermissionError on source-tree files in editable installs.
|
|
2600
|
+
# The data dir has no quarantine attributes; the copy is idempotent (same
|
|
2601
|
+
# content from the same package) so concurrent daemon starts are safe.
|
|
2602
|
+
# Falls back to the source path with a WARNING — never crashes the daemon.
|
|
2603
|
+
import shutil as _shutil
|
|
2604
|
+
_data_ui_dir = state_path("ui")
|
|
2605
|
+
try:
|
|
2606
|
+
_data_ui_dir.mkdir(parents=True, exist_ok=True)
|
|
2607
|
+
if _source_ui_dir.is_dir():
|
|
2608
|
+
_shutil.copytree(
|
|
2609
|
+
str(_source_ui_dir),
|
|
2610
|
+
str(_data_ui_dir),
|
|
2611
|
+
dirs_exist_ok=True, # idempotent; safe under concurrent starts
|
|
2612
|
+
)
|
|
2613
|
+
UI_DIR = _data_ui_dir
|
|
2614
|
+
except Exception as _ui_copy_exc:
|
|
2615
|
+
# D-04 fallback: source path. Logged as WARNING (not DEBUG) so operators
|
|
2616
|
+
# can diagnose PermissionError on editable installs. Never silently hidden.
|
|
2617
|
+
logger.warning(
|
|
2618
|
+
"D-04: UI copy to data dir failed; serving from source path %s: %s",
|
|
2619
|
+
_source_ui_dir,
|
|
2620
|
+
_ui_copy_exc,
|
|
2621
|
+
)
|
|
2622
|
+
UI_DIR = _source_ui_dir
|
|
2422
2623
|
|
|
2423
2624
|
# Rate limiting (graceful)
|
|
2424
2625
|
try:
|
|
@@ -2468,7 +2669,8 @@ def _register_dashboard_routes(application: FastAPI) -> None:
|
|
|
2468
2669
|
async def rate_limit_middleware(request, call_next):
|
|
2469
2670
|
client_ip = request.client.host if request.client else "unknown"
|
|
2470
2671
|
is_write = request.method in ("POST", "PUT", "DELETE", "PATCH")
|
|
2471
|
-
loopback
|
|
2672
|
+
from superlocalmemory.server.loopback import is_loopback as _is_lb_rl
|
|
2673
|
+
loopback = _is_lb_rl(client_ip)
|
|
2472
2674
|
if not loopback and is_rate_limit_exempt(client_ip):
|
|
2473
2675
|
return await call_next(request)
|
|
2474
2676
|
if loopback:
|
|
@@ -2660,10 +2862,17 @@ def _register_dashboard_routes(application: FastAPI) -> None:
|
|
|
2660
2862
|
"fallback; non-loopback writes will be rejected.", _auth_exc,
|
|
2661
2863
|
)
|
|
2662
2864
|
try:
|
|
2663
|
-
from superlocalmemory.
|
|
2865
|
+
from superlocalmemory.server.loopback import is_loopback as _is_lb
|
|
2664
2866
|
except Exception:
|
|
2665
|
-
|
|
2666
|
-
|
|
2867
|
+
import ipaddress as _ipa_fb
|
|
2868
|
+
|
|
2869
|
+
def _is_lb(h: str) -> bool: # type: ignore[misc]
|
|
2870
|
+
if not h or h.lower() == "localhost":
|
|
2871
|
+
return bool(h)
|
|
2872
|
+
try:
|
|
2873
|
+
return _ipa_fb.ip_address(h).is_loopback
|
|
2874
|
+
except ValueError:
|
|
2875
|
+
return False
|
|
2667
2876
|
|
|
2668
2877
|
@application.middleware("http")
|
|
2669
2878
|
async def _failclosed_auth(request, call_next):
|
|
@@ -2679,9 +2888,14 @@ def _register_dashboard_routes(application: FastAPI) -> None:
|
|
|
2679
2888
|
)
|
|
2680
2889
|
return await call_next(request)
|
|
2681
2890
|
|
|
2682
|
-
# Static files
|
|
2891
|
+
# Static files — UI_DIR is already the effective path (data-dir or source
|
|
2892
|
+
# fallback) set by the D-04 copy block above; mkdir is a no-op for
|
|
2893
|
+
# the data-dir path (already created) and guarded in the source-fallback case.
|
|
2683
2894
|
from fastapi.staticfiles import StaticFiles
|
|
2684
|
-
|
|
2895
|
+
try:
|
|
2896
|
+
UI_DIR.mkdir(parents=True, exist_ok=True)
|
|
2897
|
+
except Exception:
|
|
2898
|
+
pass # source path may not be writable; StaticFiles reads, not writes
|
|
2685
2899
|
application.mount("/static", StaticFiles(directory=str(UI_DIR)), name="static")
|
|
2686
2900
|
|
|
2687
2901
|
# Route modules
|
|
@@ -2920,9 +3134,14 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
2920
3134
|
}
|
|
2921
3135
|
# request is None only for direct internal/test calls (no HTTP client),
|
|
2922
3136
|
# which are trusted; over HTTP FastAPI always injects the real Request.
|
|
3137
|
+
from superlocalmemory.server.loopback import is_loopback as _is_loopback_host
|
|
3138
|
+
from superlocalmemory.server.write_identity import _TEST_ISOLATION_ALLOWED
|
|
3139
|
+
|
|
2923
3140
|
client_host = request.client.host if (request and request.client) else ""
|
|
2924
|
-
_trusted =
|
|
2925
|
-
|
|
3141
|
+
_trusted = (
|
|
3142
|
+
request is None
|
|
3143
|
+
or _is_loopback_host(client_host)
|
|
3144
|
+
or (client_host == "testclient" and _TEST_ISOLATION_ALLOWED)
|
|
2926
3145
|
)
|
|
2927
3146
|
if not _trusted:
|
|
2928
3147
|
return public
|
|
@@ -3013,15 +3232,35 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
3013
3232
|
if not fast:
|
|
3014
3233
|
await _recall_semaphore.acquire()
|
|
3015
3234
|
try:
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3235
|
+
# v3.8.3: bound the recall so CLI/MCP callers never hang on a
|
|
3236
|
+
# wedged embedder. Poll the executor future (which cannot be
|
|
3237
|
+
# cancelled) without blocking the loop, and give quality recall a
|
|
3238
|
+
# GENEROUS budget; only if it is exceeded do we serve the fast
|
|
3239
|
+
# keyword fallback. The orphaned recall finishes in the background.
|
|
3240
|
+
loop = asyncio.get_running_loop()
|
|
3241
|
+
_rf = loop.run_in_executor(
|
|
3242
|
+
None,
|
|
3243
|
+
lambda: engine.recall(
|
|
3244
|
+
search_query, limit=limit, session_id=effective_sid,
|
|
3245
|
+
agent_id=recall_actor,
|
|
3246
|
+
fast=fast,
|
|
3247
|
+
include_global=include_global,
|
|
3248
|
+
include_shared=include_shared,
|
|
3249
|
+
window=window or None,
|
|
3250
|
+
),
|
|
3024
3251
|
)
|
|
3252
|
+
_budget = _recall_budget_s()
|
|
3253
|
+
_deadline = loop.time() + _budget
|
|
3254
|
+
while not _rf.done() and loop.time() < _deadline:
|
|
3255
|
+
await asyncio.sleep(0.05)
|
|
3256
|
+
if not _rf.done():
|
|
3257
|
+
_rf.add_done_callback(lambda f: (f.cancelled() or f.exception()))
|
|
3258
|
+
logger.warning(
|
|
3259
|
+
"recall: semantic recall exceeded %.0fs budget for %r — "
|
|
3260
|
+
"serving keyword fallback", _budget, (search_query or "")[:80],
|
|
3261
|
+
)
|
|
3262
|
+
return _recall_keyword_fallback(engine, search_query, limit)
|
|
3263
|
+
response = _rf.result()
|
|
3025
3264
|
# v3.4.26: return the same field shape as recall_worker so
|
|
3026
3265
|
# MCP processes proxying through the daemon get recall_trace-
|
|
3027
3266
|
# compatible data without a second round trip.
|
|
@@ -3932,7 +4171,12 @@ def start_server(port: int = _DEFAULT_PORT) -> None:
|
|
|
3932
4171
|
"reachable from the network but SLM_REQUIRE_CREDENTIALS is not set "
|
|
3933
4172
|
"and API-key auth may be off. A remote caller could write without "
|
|
3934
4173
|
"credentials. Set SLM_REQUIRE_CREDENTIALS=1 and configure an API "
|
|
3935
|
-
"key before exposing this instance."
|
|
4174
|
+
"key before exposing this instance. "
|
|
4175
|
+
"NOTE (issue #90): SLM 3.8.4 fixes IPv4-mapped loopback address "
|
|
4176
|
+
"normalization — if curl/dashboard writes fail with 403 from a "
|
|
4177
|
+
"container, upgrade to 3.8.4. Immediate workaround: set "
|
|
4178
|
+
"SLM_DAEMON_HOST=127.0.0.1 (IPv4 only) or configure an API key.",
|
|
4179
|
+
bind_host,
|
|
3936
4180
|
)
|
|
3937
4181
|
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
3938
4182
|
# This handles a just-closed connection in TIME_WAIT. It is safe only
|