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.
Files changed (94) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +3 -2
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +1 -1
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +1 -1
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +1 -1
  24. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  25. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  31. package/pyproject.toml +1 -1
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/access/rbac.py +68 -76
  34. package/src/superlocalmemory/cli/commands.py +19 -0
  35. package/src/superlocalmemory/cli/ingest_cmd.py +11 -1
  36. package/src/superlocalmemory/cli/main.py +30 -0
  37. package/src/superlocalmemory/cli/pending_store.py +39 -14
  38. package/src/superlocalmemory/core/backend_orchestrator.py +93 -0
  39. package/src/superlocalmemory/core/config.py +78 -0
  40. package/src/superlocalmemory/core/consolidation_engine.py +79 -73
  41. package/src/superlocalmemory/core/engine.py +92 -11
  42. package/src/superlocalmemory/core/fact_consolidator.py +148 -30
  43. package/src/superlocalmemory/core/graph_pruner.py +436 -39
  44. package/src/superlocalmemory/core/ingestion_command.py +160 -31
  45. package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
  46. package/src/superlocalmemory/core/recall_pipeline.py +3 -0
  47. package/src/superlocalmemory/core/registry.py +5 -1
  48. package/src/superlocalmemory/core/remote_mode.py +3 -1
  49. package/src/superlocalmemory/core/scale_engine.py +41 -18
  50. package/src/superlocalmemory/core/store_pipeline.py +18 -4
  51. package/src/superlocalmemory/encoding/entity_resolver.py +18 -11
  52. package/src/superlocalmemory/hooks/_outcome_common.py +9 -2
  53. package/src/superlocalmemory/hooks/adapter_base.py +58 -44
  54. package/src/superlocalmemory/hooks/ide_connector.py +26 -8
  55. package/src/superlocalmemory/hooks/portable_kit.py +105 -9
  56. package/src/superlocalmemory/hooks/prewarm_auth.py +21 -2
  57. package/src/superlocalmemory/infra/auth_middleware.py +3 -1
  58. package/src/superlocalmemory/infra/cloud_backup.py +26 -27
  59. package/src/superlocalmemory/infra/event_bus.py +250 -88
  60. package/src/superlocalmemory/learning/consolidation_cycle.py +33 -16
  61. package/src/superlocalmemory/learning/entity_compiler.py +148 -132
  62. package/src/superlocalmemory/learning/memory_merge.py +97 -82
  63. package/src/superlocalmemory/learning/reward_archive.py +98 -90
  64. package/src/superlocalmemory/learning/reward_boost.py +40 -30
  65. package/src/superlocalmemory/mcp/http_transport.py +335 -3
  66. package/src/superlocalmemory/retrieval/engine.py +7 -1
  67. package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
  68. package/src/superlocalmemory/retrieval/reranker.py +98 -15
  69. package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
  70. package/src/superlocalmemory/retrieval/vector_store.py +84 -69
  71. package/src/superlocalmemory/server/loopback.py +91 -0
  72. package/src/superlocalmemory/server/origin.py +9 -4
  73. package/src/superlocalmemory/server/routes/backup.py +6 -2
  74. package/src/superlocalmemory/server/routes/behavioral.py +6 -12
  75. package/src/superlocalmemory/server/routes/compliance.py +20 -23
  76. package/src/superlocalmemory/server/routes/config_api.py +83 -0
  77. package/src/superlocalmemory/server/routes/helpers.py +24 -13
  78. package/src/superlocalmemory/server/routes/memories.py +139 -91
  79. package/src/superlocalmemory/server/routes/mesh.py +7 -2
  80. package/src/superlocalmemory/server/routes/profiles.py +20 -21
  81. package/src/superlocalmemory/server/routes/rbac.py +0 -1
  82. package/src/superlocalmemory/server/routes/tiers.py +42 -30
  83. package/src/superlocalmemory/server/routes/v3_api.py +67 -77
  84. package/src/superlocalmemory/server/unified_daemon.py +283 -39
  85. package/src/superlocalmemory/server/write_identity.py +22 -4
  86. package/src/superlocalmemory/storage/database.py +109 -19
  87. package/src/superlocalmemory/storage/deferred_writes.py +153 -0
  88. package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
  89. package/src/superlocalmemory/storage/memory_write.py +119 -0
  90. package/src/superlocalmemory/storage/migration_runner.py +7 -0
  91. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +113 -78
  92. package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +80 -0
  93. package/src/superlocalmemory/storage/write_lock.py +88 -0
  94. 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
- if client_host in ("127.0.0.1", "::1", "localhost"):
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 sqlite3
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
- conn.execute(
1852
- "UPDATE core_memory_blocks SET content = ?, char_count = ?, "
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
- # Read back updated block
1860
- updated = conn.execute(
1861
- "SELECT block_id, block_type, content, char_count, version, "
1862
- "compiled_by, updated_at FROM core_memory_blocks "
1863
- "WHERE block_id = ? AND profile_id = ?",
1864
- (block_id, pid),
1865
- ).fetchone()
1866
- conn.close()
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 dict(updated) if updated else {"block_id": block_id, "updated": True}
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 sqlite3 as _sqlite3
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
- # Apply Ebbinghaus decay: reduce retention for facts not accessed recently
2012
- # Formula: retention *= exp(-0.1) for each cycle (simplified batch decay)
2013
- conn.execute(
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 lifecycle_zone = ? "
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
- (zone, pid, threshold),
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
- # Ensure high-retention facts are active
2041
- conn.execute(
2042
- "UPDATE fact_retention "
2043
- "SET lifecycle_zone = 'active' "
2044
- "WHERE profile_id = ? AND retention_score >= 0.65 "
2045
- "AND lifecycle_zone NOT IN ('archive', 'forgotten')",
2046
- (pid,),
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
- conn.commit()
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 sqlite3 as _sqlite3
1312
- _idx_conn = _sqlite3.connect(str(_memory_db))
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
- try:
1362
- embedder = getattr(retrieval_eng, '_embedder', None) if retrieval_eng else None
1363
- if embedder and hasattr(embedder, 'embed'):
1364
- embedder.embed("warmup")
1365
- _embedding_warm = True
1366
- logger.info("Embedding worker pre-warmed (model resident, keep_alive=-1)")
1367
- except Exception as exc:
1368
- logger.warning("Embedding warmup failed: %s", exc)
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
- n = vs.rebuild_from_facts(with_emb)
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
- from superlocalmemory.learning import trigram_index as _ti
2106
- _ti._reset_cache_conn()
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
- from superlocalmemory.hooks._outcome_common import _perf_log_flush
2115
- _perf_log_flush()
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 = client_ip in ("127.0.0.1", "::1")
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.hooks.prewarm_auth import is_loopback as _is_lb
2865
+ from superlocalmemory.server.loopback import is_loopback as _is_lb
2664
2866
  except Exception:
2665
- def _is_lb(h: str) -> bool:
2666
- return h in ("127.0.0.1", "::1", "localhost")
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
- UI_DIR.mkdir(exist_ok=True)
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 = request is None or client_host in (
2925
- "127.0.0.1", "::1", "localhost", "testclient",
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
- response = await asyncio.to_thread(
3017
- engine.recall,
3018
- search_query, limit=limit, session_id=effective_sid,
3019
- agent_id=recall_actor,
3020
- fast=fast,
3021
- include_global=include_global,
3022
- include_shared=include_shared,
3023
- window=window or None,
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.", bind_host,
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