superlocalmemory 3.8.3 → 3.8.6
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 +76 -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 +9 -4
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/rbac.py +68 -76
- package/src/superlocalmemory/cli/commands.py +158 -404
- 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/component_registry.py +4 -2
- package/src/superlocalmemory/core/config.py +78 -0
- package/src/superlocalmemory/core/consolidation_engine.py +79 -73
- package/src/superlocalmemory/core/embeddings.py +33 -6
- package/src/superlocalmemory/core/engine.py +186 -60
- package/src/superlocalmemory/core/engine_ingestion.py +150 -63
- 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 +273 -32
- package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
- package/src/superlocalmemory/core/mutations.py +32 -10
- package/src/superlocalmemory/core/recall_pipeline.py +111 -74
- package/src/superlocalmemory/core/registry.py +5 -1
- package/src/superlocalmemory/core/remember_admission.py +152 -0
- package/src/superlocalmemory/core/remember_runtime.py +712 -0
- 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/graph/cozo_backend.py +5 -5
- 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/bandit.py +50 -1
- 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/learning/source_quality.py +38 -35
- package/src/superlocalmemory/mcp/_daemon_proxy.py +38 -15
- package/src/superlocalmemory/mcp/http_transport.py +335 -3
- package/src/superlocalmemory/mcp/tools_active.py +4 -41
- package/src/superlocalmemory/mcp/tools_core.py +26 -87
- package/src/superlocalmemory/mcp/tools_evolution.py +5 -10
- package/src/superlocalmemory/optimize/proxy/capture.py +196 -8
- package/src/superlocalmemory/retrieval/engine.py +15 -4
- package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
- package/src/superlocalmemory/retrieval/reranker.py +130 -22
- package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
- package/src/superlocalmemory/retrieval/vector_store.py +84 -69
- package/src/superlocalmemory/server/loopback.py +85 -0
- package/src/superlocalmemory/server/origin.py +9 -4
- package/src/superlocalmemory/server/profile_runtime.py +14 -0
- package/src/superlocalmemory/server/routes/abstraction.py +2 -4
- package/src/superlocalmemory/server/routes/agents.py +3 -5
- package/src/superlocalmemory/server/routes/backup.py +6 -2
- package/src/superlocalmemory/server/routes/behavioral.py +11 -25
- package/src/superlocalmemory/server/routes/brain.py +6 -9
- package/src/superlocalmemory/server/routes/compliance.py +20 -23
- package/src/superlocalmemory/server/routes/config_api.py +83 -0
- package/src/superlocalmemory/server/routes/entity.py +3 -7
- package/src/superlocalmemory/server/routes/evolution.py +3 -5
- package/src/superlocalmemory/server/routes/helpers.py +57 -25
- package/src/superlocalmemory/server/routes/insights.py +2 -4
- package/src/superlocalmemory/server/routes/learning.py +2 -5
- package/src/superlocalmemory/server/routes/lifecycle.py +2 -4
- package/src/superlocalmemory/server/routes/memories.py +119 -98
- 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 +28 -35
- package/src/superlocalmemory/server/routes/timeline.py +2 -4
- package/src/superlocalmemory/server/routes/v3_api.py +85 -93
- package/src/superlocalmemory/server/unified_daemon.py +400 -140
- package/src/superlocalmemory/server/write_identity.py +22 -4
- package/src/superlocalmemory/storage/admission_codec.py +119 -0
- package/src/superlocalmemory/storage/admission_journal.py +728 -0
- package/src/superlocalmemory/storage/database.py +168 -19
- package/src/superlocalmemory/storage/deferred_writes.py +209 -0
- package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
- package/src/superlocalmemory/storage/memory_write.py +115 -0
- package/src/superlocalmemory/storage/migration_runner.py +44 -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/migrations/M032_write_coordinator_admission.py +188 -0
- package/src/superlocalmemory/storage/read_connection.py +115 -0
- package/src/superlocalmemory/storage/write_coordinator.py +756 -0
- package/src/superlocalmemory/storage/write_lock.py +88 -0
- package/src/superlocalmemory/ui/index.html +1 -1
- package/src/superlocalmemory/ui/js/auto-settings.js +14 -1
- package/src/superlocalmemory/ui/js/od-settings.js +9 -3
|
@@ -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
|
|
@@ -367,6 +383,18 @@ def _hot_reconfigure_engine(application, new_config, *, mode_change: bool) -> No
|
|
|
367
383
|
new_engine.close()
|
|
368
384
|
raise
|
|
369
385
|
|
|
386
|
+
canonical_remember = getattr(
|
|
387
|
+
application.state,
|
|
388
|
+
"canonical_remember_runtime",
|
|
389
|
+
None,
|
|
390
|
+
)
|
|
391
|
+
if canonical_remember is not None:
|
|
392
|
+
try:
|
|
393
|
+
canonical_remember.rebind_engine(new_engine)
|
|
394
|
+
except BaseException:
|
|
395
|
+
new_engine.close()
|
|
396
|
+
raise
|
|
397
|
+
|
|
370
398
|
# The profile transition barrier is exclusive here. Publish every
|
|
371
399
|
# long-lived reference before closing the former engine.
|
|
372
400
|
application.state.engine = new_engine
|
|
@@ -801,6 +829,29 @@ def _warm_spreading_activation(engine, runtime) -> bool:
|
|
|
801
829
|
sa.search(query_embedding, profile_id=active_pid, top_k=7)
|
|
802
830
|
else:
|
|
803
831
|
sa.search(query_embedding, profile_id=active_pid, top_k=7)
|
|
832
|
+
# v3.8.5: pre-load the per-profile graph-metrics cache (PageRank +
|
|
833
|
+
# community for every fact) here in the background, so the FIRST real
|
|
834
|
+
# recall does not pay the ~12k-row load on its hot path. Observed as a
|
|
835
|
+
# large chunk of the 8-13s "cold first query" spike: the metrics cache
|
|
836
|
+
# was loading lazily during the user's first recall instead of at boot.
|
|
837
|
+
try:
|
|
838
|
+
sa._load_graph_metrics_cache(active_pid)
|
|
839
|
+
except Exception:
|
|
840
|
+
pass
|
|
841
|
+
# v3.8.5: warm the EntityGraphChannel's in-memory adjacency cache (ALL
|
|
842
|
+
# ~208K edges + entity maps + graph metrics, ~18 MB) at boot. This was
|
|
843
|
+
# the dominant cause of the "cold first query" spike (8-13s): the cache
|
|
844
|
+
# loaded lazily on the FIRST real recall that routed to the entity
|
|
845
|
+
# channel, not during warmup. Force it here via the channel's own
|
|
846
|
+
# search entry so it is warm before any user query.
|
|
847
|
+
try:
|
|
848
|
+
entity_channel = getattr(retr, "_entity", None)
|
|
849
|
+
if entity_channel is not None:
|
|
850
|
+
entity_channel.search(
|
|
851
|
+
"memory graph adjacency warmup", active_pid, top_k=5,
|
|
852
|
+
)
|
|
853
|
+
except Exception:
|
|
854
|
+
pass
|
|
804
855
|
logger.info(
|
|
805
856
|
"Spreading-activation graph pre-warmed for profile %s", active_pid,
|
|
806
857
|
)
|
|
@@ -1166,6 +1217,25 @@ async def _cancel_source_quality_repair(application) -> None:
|
|
|
1166
1217
|
pass
|
|
1167
1218
|
|
|
1168
1219
|
|
|
1220
|
+
def _release_canonical_remember_runtime(application, runtime=None) -> bool:
|
|
1221
|
+
"""Release the writer lease without discarding a still-running runtime."""
|
|
1222
|
+
runtime = (
|
|
1223
|
+
runtime
|
|
1224
|
+
if runtime is not None
|
|
1225
|
+
else getattr(application.state, "canonical_remember_runtime", None)
|
|
1226
|
+
)
|
|
1227
|
+
if runtime is None:
|
|
1228
|
+
return True
|
|
1229
|
+
try:
|
|
1230
|
+
runtime.stop()
|
|
1231
|
+
except Exception as exc: # pragma: no cover - cleanup must continue
|
|
1232
|
+
logger.warning("canonical remember writer shutdown failed: %s", exc)
|
|
1233
|
+
return False
|
|
1234
|
+
if getattr(application.state, "canonical_remember_runtime", None) is runtime:
|
|
1235
|
+
application.state.canonical_remember_runtime = None
|
|
1236
|
+
return True
|
|
1237
|
+
|
|
1238
|
+
|
|
1169
1239
|
@asynccontextmanager
|
|
1170
1240
|
async def lifespan(application: FastAPI):
|
|
1171
1241
|
"""Initialize engine, workers, and optional services on startup."""
|
|
@@ -1173,6 +1243,7 @@ async def lifespan(application: FastAPI):
|
|
|
1173
1243
|
|
|
1174
1244
|
engine = None
|
|
1175
1245
|
config = None
|
|
1246
|
+
canonical_remember_runtime = None
|
|
1176
1247
|
|
|
1177
1248
|
# The local dashboard obtains its short-lived browser credential from
|
|
1178
1249
|
# ``/internal/token`` before its first write or token-gated read. A
|
|
@@ -1288,6 +1359,17 @@ async def lifespan(application: FastAPI):
|
|
|
1288
1359
|
engine = MemoryEngine(config)
|
|
1289
1360
|
engine.initialize()
|
|
1290
1361
|
|
|
1362
|
+
# 3.8.6 canonical remember boundary. Migrations have already created
|
|
1363
|
+
# the immutable receipt ledger and engine init has created the runtime
|
|
1364
|
+
# tables. Claim the daemon's writer lease, install the typed admission
|
|
1365
|
+
# handler, and replay crash-surviving journal entries *before* any
|
|
1366
|
+
# background writer or ready descriptor is published.
|
|
1367
|
+
from superlocalmemory.core.remember_runtime import CanonicalRememberRuntime
|
|
1368
|
+
|
|
1369
|
+
canonical_remember_runtime = CanonicalRememberRuntime.for_engine(engine)
|
|
1370
|
+
canonical_remember_runtime.start()
|
|
1371
|
+
application.state.canonical_remember_runtime = canonical_remember_runtime
|
|
1372
|
+
|
|
1291
1373
|
# WAL is already established at DB creation (DatabaseManager._enable_wal
|
|
1292
1374
|
# / schema init). Re-asserting PRAGMA journal_mode=WAL here is a
|
|
1293
1375
|
# schema-level write on the shared connection that raced in-flight
|
|
@@ -1362,11 +1444,15 @@ async def lifespan(application: FastAPI):
|
|
|
1362
1444
|
# full sort. Without them full recall takes 7-10s on
|
|
1363
1445
|
# >1M edges (the SpreadingActivation 4-UNION query disk-sorts every
|
|
1364
1446
|
# node's neighbor list on each call). With them: sub-second.
|
|
1447
|
+
#
|
|
1448
|
+
# Concurrency fix (v3.8.4): wrapped in memory_write() so the process
|
|
1449
|
+
# write lock is acquired before the connection is opened. Previously
|
|
1450
|
+
# the raw sqlite3.connect() bypassed get_write_lock() and had no
|
|
1451
|
+
# busy_timeout, which risked SQLITE_BUSY at daemon startup when other
|
|
1452
|
+
# writers (hook / CLI) were already active.
|
|
1365
1453
|
try:
|
|
1366
|
-
import
|
|
1367
|
-
|
|
1368
|
-
try:
|
|
1369
|
-
_idx_conn.execute("PRAGMA journal_mode=WAL")
|
|
1454
|
+
from superlocalmemory.storage.memory_write import memory_write as _mw
|
|
1455
|
+
with _mw(_memory_db) as _idx_conn:
|
|
1370
1456
|
_idx_conn.execute(
|
|
1371
1457
|
"CREATE INDEX IF NOT EXISTS idx_edges_source_weight "
|
|
1372
1458
|
"ON graph_edges(profile_id, source_id, weight DESC)"
|
|
@@ -1383,10 +1469,6 @@ async def lifespan(application: FastAPI):
|
|
|
1383
1469
|
"CREATE INDEX IF NOT EXISTS idx_assoc_target_weight "
|
|
1384
1470
|
"ON association_edges(profile_id, target_fact_id, weight DESC)"
|
|
1385
1471
|
)
|
|
1386
|
-
finally:
|
|
1387
|
-
# CP-09: close even if an execute() raises, so the connection
|
|
1388
|
-
# (and its file handle / shared DB lock) never leaks.
|
|
1389
|
-
_idx_conn.close()
|
|
1390
1472
|
except Exception as _idx_exc:
|
|
1391
1473
|
logger.debug("SpreadingActivation covering indexes skipped: %s", _idx_exc)
|
|
1392
1474
|
|
|
@@ -1413,14 +1495,36 @@ async def lifespan(application: FastAPI):
|
|
|
1413
1495
|
_embedding_warm = False
|
|
1414
1496
|
def _warmup_embedder():
|
|
1415
1497
|
global _embedding_warm
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1498
|
+
import time as _t
|
|
1499
|
+
# RETRY: the retrieval engine / embedder can be created lazily a
|
|
1500
|
+
# moment AFTER this thread starts. The old one-shot attempt often
|
|
1501
|
+
# captured a None embedder and left _embedding_warm stuck False
|
|
1502
|
+
# forever — so /health reported not-ready even though on-demand
|
|
1503
|
+
# embeds worked. A stuck not-ready can make an auto-start hook
|
|
1504
|
+
# believe the daemon is down and spawn a DUPLICATE daemon (two
|
|
1505
|
+
# processes writing memory.db → cross-process SQLITE_BUSY). Poll
|
|
1506
|
+
# until the real embedder (retrieval_eng._embedder, the same one
|
|
1507
|
+
# recall uses) is available, warm it, and flip the flag.
|
|
1508
|
+
for _attempt in range(240): # ~120s max at 0.5s steps
|
|
1509
|
+
try:
|
|
1510
|
+
_re = retrieval_eng or getattr(engine, '_retrieval_engine', None)
|
|
1511
|
+
embedder = getattr(_re, '_embedder', None) if _re else None
|
|
1512
|
+
if embedder is not None and hasattr(embedder, 'embed'):
|
|
1513
|
+
embedder.embed("warmup")
|
|
1514
|
+
_embedding_warm = True
|
|
1515
|
+
logger.info(
|
|
1516
|
+
"Embedding worker pre-warmed (model resident, "
|
|
1517
|
+
"keep_alive=-1)"
|
|
1518
|
+
)
|
|
1519
|
+
return
|
|
1520
|
+
except Exception as exc:
|
|
1521
|
+
logger.debug("Embedding warmup attempt %d failed: %s",
|
|
1522
|
+
_attempt, exc)
|
|
1523
|
+
_t.sleep(0.5)
|
|
1524
|
+
logger.warning(
|
|
1525
|
+
"Embedding warmup did not complete after retries; /health "
|
|
1526
|
+
"may report not-ready even though on-demand embeds work"
|
|
1527
|
+
)
|
|
1424
1528
|
|
|
1425
1529
|
def _warmup_recall():
|
|
1426
1530
|
"""v3.4.62: Fire a full recall after embedding warms up.
|
|
@@ -1462,6 +1566,20 @@ async def lifespan(application: FastAPI):
|
|
|
1462
1566
|
# v3.8: the --fast recalls above skip spreading activation; warm
|
|
1463
1567
|
# that channel directly so the first FULL recall is not cold.
|
|
1464
1568
|
_warm_spreading_activation(engine, profile_runtime)
|
|
1569
|
+
# v3.8.5: the fast recalls above do NOT exercise the full ranking
|
|
1570
|
+
# path or the agentic round, so the FIRST real user query that
|
|
1571
|
+
# takes the full path paid an 8-13s cold cost (ranking model +
|
|
1572
|
+
# graph-metrics load). Fire ONE full (fast=False) recall here in
|
|
1573
|
+
# the background so those load at boot, not on a user's query.
|
|
1574
|
+
# Best-effort; never blocks readiness.
|
|
1575
|
+
try:
|
|
1576
|
+
with profile_runtime.operation_nowait() as _fsnap:
|
|
1577
|
+
if _fsnap is not None:
|
|
1578
|
+
engine.recall(
|
|
1579
|
+
"memory recall performance", limit=5, fast=False,
|
|
1580
|
+
)
|
|
1581
|
+
except Exception as _fexc:
|
|
1582
|
+
logger.debug("Full-path warmup skipped (non-fatal): %s", _fexc)
|
|
1465
1583
|
elapsed = round((_t.monotonic() - t0) * 1000)
|
|
1466
1584
|
logger.info(
|
|
1467
1585
|
"Recall engine pre-warmed in %dms", elapsed,
|
|
@@ -1479,6 +1597,11 @@ async def lifespan(application: FastAPI):
|
|
|
1479
1597
|
"""
|
|
1480
1598
|
import time as _t
|
|
1481
1599
|
from pathlib import Path as _P
|
|
1600
|
+
if os.environ.get("SLM_DISABLE_VS_BACKFILL") == "1":
|
|
1601
|
+
logger.info(
|
|
1602
|
+
"VS backfill disabled via SLM_DISABLE_VS_BACKFILL=1"
|
|
1603
|
+
)
|
|
1604
|
+
return
|
|
1482
1605
|
for _ in range(120):
|
|
1483
1606
|
if _embedding_warm:
|
|
1484
1607
|
break
|
|
@@ -1510,7 +1633,42 @@ async def lifespan(application: FastAPI):
|
|
|
1510
1633
|
continue
|
|
1511
1634
|
if vs.count(pid) >= int(len(with_emb) * 0.98):
|
|
1512
1635
|
continue # already complete — no-op
|
|
1513
|
-
|
|
1636
|
+
# Fix: route each upsert through db._lock with cooperative
|
|
1637
|
+
# yield between facts. Previously called
|
|
1638
|
+
# vs.rebuild_from_facts() which opens its own sqlite3
|
|
1639
|
+
# connection (bypassing db._lock), causing concurrent
|
|
1640
|
+
# backfill_missing_embeddings writes to hit SQLITE_BUSY
|
|
1641
|
+
# while holding db._lock — starving user writes for ~50 s.
|
|
1642
|
+
import os as _selfheal_os
|
|
1643
|
+
# Stage 2 (write-queue plan): batch upserts so the write lock
|
|
1644
|
+
# is acquired a FEW times with a clear release window (pause)
|
|
1645
|
+
# between batches — instead of thousands of rapid
|
|
1646
|
+
# re-acquisitions. threading.RLock is NOT fair, so on a
|
|
1647
|
+
# fresh boot a waiting user /remember could be starved for
|
|
1648
|
+
# ~50 s behind the per-fact churn. A real pause between
|
|
1649
|
+
# bounded batches guarantees the waiting user write wins the
|
|
1650
|
+
# lock promptly. One-time backfill; steady state no-ops.
|
|
1651
|
+
_batch = max(1, int(
|
|
1652
|
+
_selfheal_os.environ.get("SLM_SELFHEAL_BATCH", "50")))
|
|
1653
|
+
_pause = max(0.0, float(
|
|
1654
|
+
_selfheal_os.environ.get("SLM_SELFHEAL_BATCH_PAUSE_S", "0.05")))
|
|
1655
|
+
n = 0
|
|
1656
|
+
for _i in range(0, len(with_emb), _batch):
|
|
1657
|
+
_chunk = with_emb[_i:_i + _batch]
|
|
1658
|
+
with db._lock:
|
|
1659
|
+
for _fact_id, _profile_id, _embedding in _chunk:
|
|
1660
|
+
try:
|
|
1661
|
+
if vs.upsert(_fact_id, _profile_id, _embedding):
|
|
1662
|
+
n += 1
|
|
1663
|
+
except Exception as _upsert_exc:
|
|
1664
|
+
logger.warning(
|
|
1665
|
+
"VS backfill[%s]: upsert failed for %s: %s",
|
|
1666
|
+
pid, str(_fact_id)[:16], _upsert_exc,
|
|
1667
|
+
)
|
|
1668
|
+
# Release window: let any waiting user write through
|
|
1669
|
+
# before grabbing the lock again.
|
|
1670
|
+
if _pause > 0:
|
|
1671
|
+
_t.sleep(_pause)
|
|
1514
1672
|
logger.info(
|
|
1515
1673
|
"VS backfill[%s]: indexed %d of %d embedded facts",
|
|
1516
1674
|
pid, n, len(with_emb),
|
|
@@ -1757,6 +1915,9 @@ async def lifespan(application: FastAPI):
|
|
|
1757
1915
|
|
|
1758
1916
|
except Exception:
|
|
1759
1917
|
logger.exception("Engine init failed") # auto-includes traceback
|
|
1918
|
+
_release_canonical_remember_runtime(
|
|
1919
|
+
application, canonical_remember_runtime,
|
|
1920
|
+
)
|
|
1760
1921
|
application.state.engine = None
|
|
1761
1922
|
application.state.config = None
|
|
1762
1923
|
|
|
@@ -2156,25 +2317,31 @@ async def lifespan(application: FastAPI):
|
|
|
2156
2317
|
logger.warning("evolution cost-conn cache close failed: %s", exc)
|
|
2157
2318
|
|
|
2158
2319
|
# Drop the trigram cache conn symmetrically.
|
|
2320
|
+
# D-02: use module-level reference (_trigram_index_mod) to avoid re-importing
|
|
2321
|
+
# during interpreter teardown when the learning namespace may be gone.
|
|
2159
2322
|
try:
|
|
2160
|
-
|
|
2161
|
-
|
|
2323
|
+
if _trigram_index_mod is not None:
|
|
2324
|
+
_trigram_index_mod._reset_cache_conn()
|
|
2162
2325
|
except Exception as exc: # pragma: no cover — defensive
|
|
2163
2326
|
logger.warning("trigram cache conn close failed: %s", exc)
|
|
2164
2327
|
|
|
2165
2328
|
# Flush the perf-log fd explicitly (the atexit hook still fires
|
|
2166
2329
|
# but explicit close here is cheap insurance against uvicorn
|
|
2167
2330
|
# killing the process before atexit runs).
|
|
2331
|
+
# D-03: use module-level reference (_perf_log_flush_fn) — a lazy import here
|
|
2332
|
+
# triggers macOS TCC xattr checks on the source .py at shutdown time, causing
|
|
2333
|
+
# PermissionError: "Operation not permitted: .../hooks/_outcome_common.py".
|
|
2168
2334
|
try:
|
|
2169
|
-
|
|
2170
|
-
|
|
2335
|
+
if _perf_log_flush_fn is not None:
|
|
2336
|
+
_perf_log_flush_fn()
|
|
2171
2337
|
except Exception as exc: # pragma: no cover — defensive
|
|
2172
2338
|
logger.warning("perf_log flush failed: %s", exc)
|
|
2173
2339
|
|
|
2174
2340
|
materializer_stopped = _stop_pending_materializer()
|
|
2341
|
+
canonical_writer_stopped = _release_canonical_remember_runtime(application)
|
|
2175
2342
|
_profile_runtime = None
|
|
2176
2343
|
_engine = None
|
|
2177
|
-
if engine is not None and materializer_stopped:
|
|
2344
|
+
if engine is not None and materializer_stopped and canonical_writer_stopped:
|
|
2178
2345
|
try:
|
|
2179
2346
|
engine.close()
|
|
2180
2347
|
except Exception:
|
|
@@ -2184,7 +2351,8 @@ async def lifespan(application: FastAPI):
|
|
|
2184
2351
|
# object still owned by an admitted background operation; OS process
|
|
2185
2352
|
# teardown is safer than racing that writer with engine.close().
|
|
2186
2353
|
logger.warning(
|
|
2187
|
-
"Engine close deferred because
|
|
2354
|
+
"Engine close deferred because a write-capable background component "
|
|
2355
|
+
"is still active"
|
|
2188
2356
|
)
|
|
2189
2357
|
_cleanup_process_descriptor(
|
|
2190
2358
|
getattr(application.state, "daemon_descriptor", None),
|
|
@@ -2473,7 +2641,33 @@ def _register_dashboard_routes(application: FastAPI) -> None:
|
|
|
2473
2641
|
|
|
2474
2642
|
Extracted from api.py's create_app() to avoid duplicate MemoryEngine.
|
|
2475
2643
|
"""
|
|
2476
|
-
from superlocalmemory.server.api import UI_DIR
|
|
2644
|
+
from superlocalmemory.server.api import UI_DIR as _source_ui_dir
|
|
2645
|
+
|
|
2646
|
+
# D-04: Copy UI assets to the data dir at daemon startup to avoid macOS
|
|
2647
|
+
# xattr/TCC PermissionError on source-tree files in editable installs.
|
|
2648
|
+
# The data dir has no quarantine attributes; the copy is idempotent (same
|
|
2649
|
+
# content from the same package) so concurrent daemon starts are safe.
|
|
2650
|
+
# Falls back to the source path with a WARNING — never crashes the daemon.
|
|
2651
|
+
import shutil as _shutil
|
|
2652
|
+
_data_ui_dir = state_path("ui")
|
|
2653
|
+
try:
|
|
2654
|
+
_data_ui_dir.mkdir(parents=True, exist_ok=True)
|
|
2655
|
+
if _source_ui_dir.is_dir():
|
|
2656
|
+
_shutil.copytree(
|
|
2657
|
+
str(_source_ui_dir),
|
|
2658
|
+
str(_data_ui_dir),
|
|
2659
|
+
dirs_exist_ok=True, # idempotent; safe under concurrent starts
|
|
2660
|
+
)
|
|
2661
|
+
UI_DIR = _data_ui_dir
|
|
2662
|
+
except Exception as _ui_copy_exc:
|
|
2663
|
+
# D-04 fallback: source path. Logged as WARNING (not DEBUG) so operators
|
|
2664
|
+
# can diagnose PermissionError on editable installs. Never silently hidden.
|
|
2665
|
+
logger.warning(
|
|
2666
|
+
"D-04: UI copy to data dir failed; serving from source path %s: %s",
|
|
2667
|
+
_source_ui_dir,
|
|
2668
|
+
_ui_copy_exc,
|
|
2669
|
+
)
|
|
2670
|
+
UI_DIR = _source_ui_dir
|
|
2477
2671
|
|
|
2478
2672
|
# Rate limiting (graceful)
|
|
2479
2673
|
try:
|
|
@@ -2523,7 +2717,8 @@ def _register_dashboard_routes(application: FastAPI) -> None:
|
|
|
2523
2717
|
async def rate_limit_middleware(request, call_next):
|
|
2524
2718
|
client_ip = request.client.host if request.client else "unknown"
|
|
2525
2719
|
is_write = request.method in ("POST", "PUT", "DELETE", "PATCH")
|
|
2526
|
-
loopback
|
|
2720
|
+
from superlocalmemory.server.loopback import is_loopback as _is_lb_rl
|
|
2721
|
+
loopback = _is_lb_rl(client_ip)
|
|
2527
2722
|
if not loopback and is_rate_limit_exempt(client_ip):
|
|
2528
2723
|
return await call_next(request)
|
|
2529
2724
|
if loopback:
|
|
@@ -2715,10 +2910,17 @@ def _register_dashboard_routes(application: FastAPI) -> None:
|
|
|
2715
2910
|
"fallback; non-loopback writes will be rejected.", _auth_exc,
|
|
2716
2911
|
)
|
|
2717
2912
|
try:
|
|
2718
|
-
from superlocalmemory.
|
|
2913
|
+
from superlocalmemory.server.loopback import is_loopback as _is_lb
|
|
2719
2914
|
except Exception:
|
|
2720
|
-
|
|
2721
|
-
|
|
2915
|
+
import ipaddress as _ipa_fb
|
|
2916
|
+
|
|
2917
|
+
def _is_lb(h: str) -> bool: # type: ignore[misc]
|
|
2918
|
+
if not h or h.lower() == "localhost":
|
|
2919
|
+
return bool(h)
|
|
2920
|
+
try:
|
|
2921
|
+
return _ipa_fb.ip_address(h).is_loopback
|
|
2922
|
+
except ValueError:
|
|
2923
|
+
return False
|
|
2722
2924
|
|
|
2723
2925
|
@application.middleware("http")
|
|
2724
2926
|
async def _failclosed_auth(request, call_next):
|
|
@@ -2734,9 +2936,14 @@ def _register_dashboard_routes(application: FastAPI) -> None:
|
|
|
2734
2936
|
)
|
|
2735
2937
|
return await call_next(request)
|
|
2736
2938
|
|
|
2737
|
-
# Static files
|
|
2939
|
+
# Static files — UI_DIR is already the effective path (data-dir or source
|
|
2940
|
+
# fallback) set by the D-04 copy block above; mkdir is a no-op for
|
|
2941
|
+
# the data-dir path (already created) and guarded in the source-fallback case.
|
|
2738
2942
|
from fastapi.staticfiles import StaticFiles
|
|
2739
|
-
|
|
2943
|
+
try:
|
|
2944
|
+
UI_DIR.mkdir(parents=True, exist_ok=True)
|
|
2945
|
+
except Exception:
|
|
2946
|
+
pass # source path may not be writable; StaticFiles reads, not writes
|
|
2740
2947
|
application.mount("/static", StaticFiles(directory=str(UI_DIR)), name="static")
|
|
2741
2948
|
|
|
2742
2949
|
# Route modules
|
|
@@ -2928,6 +3135,12 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
2928
3135
|
@application.get("/health")
|
|
2929
3136
|
async def health(request: Request = None):
|
|
2930
3137
|
_update_activity()
|
|
3138
|
+
try:
|
|
3139
|
+
from superlocalmemory.server.recall_health import get_recall_health
|
|
3140
|
+
|
|
3141
|
+
recall_health = get_recall_health()
|
|
3142
|
+
except Exception:
|
|
3143
|
+
recall_health = {"recall_healthy": None}
|
|
2931
3144
|
# Non-blocking peek: report status without forcing a re-init.
|
|
2932
3145
|
engine = getattr(application.state, "engine", None)
|
|
2933
3146
|
migration_result = getattr(application.state, "migration_result", None)
|
|
@@ -2938,24 +3151,40 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
2938
3151
|
migrations_ready = bool(migration_result) and not migration_failures
|
|
2939
3152
|
if migration_details.get("_crash"):
|
|
2940
3153
|
migrations_ready = False
|
|
3154
|
+
writer_runtime = getattr(
|
|
3155
|
+
application.state,
|
|
3156
|
+
"canonical_remember_runtime",
|
|
3157
|
+
None,
|
|
3158
|
+
)
|
|
2941
3159
|
readiness = {
|
|
2942
3160
|
"engine": engine is not None,
|
|
2943
3161
|
"migrations": migrations_ready,
|
|
2944
|
-
"
|
|
3162
|
+
"writer": bool(
|
|
3163
|
+
writer_runtime is not None
|
|
3164
|
+
and getattr(writer_runtime, "ready", False)
|
|
3165
|
+
),
|
|
3166
|
+
"embedding": bool(_embedding_warm),
|
|
3167
|
+
"recall_health": recall_health.get("recall_healthy") is True,
|
|
2945
3168
|
"migration_failures": migration_failures,
|
|
2946
3169
|
}
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
runtime_state = (
|
|
2950
|
-
"ready" if fully_ready else "warming" if base_ready else "not_ready"
|
|
3170
|
+
readiness["retrieval"] = bool(
|
|
3171
|
+
readiness["embedding"] and readiness["recall_health"]
|
|
2951
3172
|
)
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
3173
|
+
base_ready = all((
|
|
3174
|
+
readiness["engine"],
|
|
3175
|
+
readiness["migrations"],
|
|
3176
|
+
readiness["writer"],
|
|
3177
|
+
))
|
|
3178
|
+
fully_ready = base_ready and readiness["retrieval"]
|
|
3179
|
+
if fully_ready:
|
|
3180
|
+
runtime_state = "serving_full"
|
|
3181
|
+
elif base_ready and readiness["embedding"]:
|
|
3182
|
+
runtime_state = "serving_degraded"
|
|
3183
|
+
elif base_ready:
|
|
3184
|
+
runtime_state = "warming"
|
|
3185
|
+
else:
|
|
3186
|
+
runtime_state = "not_ready"
|
|
3187
|
+
lifecycle_state = "ready" if base_ready else "starting"
|
|
2959
3188
|
identity = getattr(application.state, "daemon_descriptor", None)
|
|
2960
3189
|
from superlocalmemory.server.profile_runtime import get_profile_runtime
|
|
2961
3190
|
|
|
@@ -2970,14 +3199,20 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
2970
3199
|
"ready": fully_ready,
|
|
2971
3200
|
# Runtime readiness is more precise than descriptor lifecycle.
|
|
2972
3201
|
# A process can be alive and identity-valid while retrieval warms.
|
|
2973
|
-
"state":
|
|
3202
|
+
"state": lifecycle_state,
|
|
3203
|
+
"runtime_state": runtime_state,
|
|
2974
3204
|
"version": getattr(application, 'version', 'unknown'),
|
|
2975
3205
|
}
|
|
2976
3206
|
# request is None only for direct internal/test calls (no HTTP client),
|
|
2977
3207
|
# which are trusted; over HTTP FastAPI always injects the real Request.
|
|
3208
|
+
from superlocalmemory.server.loopback import is_loopback as _is_loopback_host
|
|
3209
|
+
from superlocalmemory.server.write_identity import _TEST_ISOLATION_ALLOWED
|
|
3210
|
+
|
|
2978
3211
|
client_host = request.client.host if (request and request.client) else ""
|
|
2979
|
-
_trusted =
|
|
2980
|
-
|
|
3212
|
+
_trusted = (
|
|
3213
|
+
request is None
|
|
3214
|
+
or _is_loopback_host(client_host)
|
|
3215
|
+
or (client_host == "testclient" and _TEST_ISOLATION_ALLOWED)
|
|
2981
3216
|
)
|
|
2982
3217
|
if not _trusted:
|
|
2983
3218
|
return public
|
|
@@ -2993,12 +3228,12 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
2993
3228
|
"embedding_warm": _embedding_warm,
|
|
2994
3229
|
# v3.6.8: True iff the semantic channel actually fired on the last
|
|
2995
3230
|
# health probe; includes self-heal counters.
|
|
2996
|
-
"recall_health":
|
|
3231
|
+
"recall_health": recall_health,
|
|
2997
3232
|
**(identity.public_health_fields() if identity is not None else {}),
|
|
2998
|
-
#
|
|
2999
|
-
#
|
|
3000
|
-
|
|
3001
|
-
"
|
|
3233
|
+
# Lifecycle state remains backward compatible for daemon
|
|
3234
|
+
# discovery; channel degradation is exposed separately.
|
|
3235
|
+
"state": lifecycle_state,
|
|
3236
|
+
"runtime_state": runtime_state,
|
|
3002
3237
|
"active_profile": profile_snapshot.profile_id,
|
|
3003
3238
|
"profile_generation": profile_snapshot.generation,
|
|
3004
3239
|
}
|
|
@@ -3163,11 +3398,11 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
3163
3398
|
request: Request,
|
|
3164
3399
|
wait: bool = False,
|
|
3165
3400
|
):
|
|
3166
|
-
"""
|
|
3401
|
+
"""Journal and commit a bounded, immediately-queryable receipt.
|
|
3167
3402
|
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3403
|
+
``wait`` remains accepted for compatibility, but never permits inline
|
|
3404
|
+
enrichment on this path. The daemon materializer owns all model, graph,
|
|
3405
|
+
vector, and post-hook work after this response.
|
|
3171
3406
|
"""
|
|
3172
3407
|
trusted_actor_id = _require_write_actor(request)
|
|
3173
3408
|
_update_activity()
|
|
@@ -3180,14 +3415,29 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
3180
3415
|
scope = req.scope or getattr(_scope_cfg, "default_scope", "personal")
|
|
3181
3416
|
shared_with = req.shared_with
|
|
3182
3417
|
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3418
|
+
# Keep the daemon compatibility route behind the exact RBAC/session
|
|
3419
|
+
# boundary used by dashboard mutations. Machine authentication proves
|
|
3420
|
+
# the caller may reach this daemon; WRITE permission proves the
|
|
3421
|
+
# authenticated dashboard user may mutate this profile. This must run
|
|
3422
|
+
# before either the trust pre-hook or the durable admission journal.
|
|
3423
|
+
from superlocalmemory.access.rbac import Permission
|
|
3424
|
+
from superlocalmemory.server.rbac_enforce import require_permission
|
|
3425
|
+
|
|
3426
|
+
require_permission(request, Permission.WRITE, profile=engine._profile_id)
|
|
3427
|
+
if scope in {"shared", "global"}:
|
|
3428
|
+
require_permission(request, Permission.SHARE, profile=engine._profile_id)
|
|
3429
|
+
runtime = getattr(application.state, "canonical_remember_runtime", None)
|
|
3430
|
+
if runtime is None:
|
|
3431
|
+
raise HTTPException(
|
|
3432
|
+
503,
|
|
3433
|
+
detail="canonical remember writer is not ready; retry shortly",
|
|
3186
3434
|
)
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3435
|
+
|
|
3436
|
+
try:
|
|
3437
|
+
from superlocalmemory.core.remember_runtime import (
|
|
3438
|
+
validate_deterministic_admission,
|
|
3190
3439
|
)
|
|
3440
|
+
from superlocalmemory.storage.admission_journal import Actor, RememberRequest
|
|
3191
3441
|
|
|
3192
3442
|
meta = {}
|
|
3193
3443
|
if req.tags:
|
|
@@ -3195,8 +3445,32 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
3195
3445
|
extra = getattr(req, "metadata", None)
|
|
3196
3446
|
if isinstance(extra, dict):
|
|
3197
3447
|
meta.update(extra)
|
|
3198
|
-
|
|
3199
|
-
|
|
3448
|
+
|
|
3449
|
+
store_config = getattr(engine._config, "store", None)
|
|
3450
|
+
validate_deterministic_admission(
|
|
3451
|
+
req.content,
|
|
3452
|
+
max_verbatim_chars=getattr(
|
|
3453
|
+
store_config,
|
|
3454
|
+
"max_verbatim_chars",
|
|
3455
|
+
24_000,
|
|
3456
|
+
),
|
|
3457
|
+
max_ingest_bytes=getattr(
|
|
3458
|
+
store_config,
|
|
3459
|
+
"max_ingest_bytes",
|
|
3460
|
+
1_048_576,
|
|
3461
|
+
),
|
|
3462
|
+
)
|
|
3463
|
+
|
|
3464
|
+
# Trust policy is intentionally outside both the journal and the
|
|
3465
|
+
# coordinator transaction. It can reject or audit a caller, but
|
|
3466
|
+
# cannot hold SQLite's sole writer while hooks do their work.
|
|
3467
|
+
engine._hooks.run_pre("store", {
|
|
3468
|
+
"operation": "store",
|
|
3469
|
+
"agent_id": trusted_actor_id,
|
|
3470
|
+
"profile_id": engine._profile_id,
|
|
3471
|
+
"content_preview": req.content[:100],
|
|
3472
|
+
})
|
|
3473
|
+
admission = RememberRequest(
|
|
3200
3474
|
content=req.content,
|
|
3201
3475
|
profile_id=engine._profile_id,
|
|
3202
3476
|
source_type="http",
|
|
@@ -3207,93 +3481,63 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
3207
3481
|
trusted_actor_id=trusted_actor_id,
|
|
3208
3482
|
session_id=req.session_id,
|
|
3209
3483
|
)
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
result = receipt
|
|
3215
|
-
wait_budget_exhausted = False
|
|
3216
|
-
if wait:
|
|
3217
|
-
materialization_task = asyncio.create_task(
|
|
3218
|
-
asyncio.to_thread(command.materialize, receipt.operation_id)
|
|
3219
|
-
)
|
|
3220
|
-
try:
|
|
3221
|
-
result = await asyncio.wait_for(
|
|
3222
|
-
asyncio.shield(materialization_task),
|
|
3223
|
-
timeout=_REMEMBER_ENRICHMENT_WAIT_SECONDS,
|
|
3224
|
-
)
|
|
3225
|
-
except TimeoutError:
|
|
3226
|
-
# The task retains the M018 lease and continues outside
|
|
3227
|
-
# this request. Return the durable receipt honestly;
|
|
3228
|
-
# the normal materializer can also reclaim it after a
|
|
3229
|
-
# lease expiry if the request-owned worker dies.
|
|
3230
|
-
wait_budget_exhausted = True
|
|
3231
|
-
|
|
3232
|
-
def _log_background_materialization(task):
|
|
3233
|
-
try:
|
|
3234
|
-
task.result()
|
|
3235
|
-
except Exception as exc:
|
|
3236
|
-
logger.warning(
|
|
3237
|
-
"bounded remember enrichment failed for %s: %s",
|
|
3238
|
-
receipt.operation_id,
|
|
3239
|
-
exc,
|
|
3240
|
-
)
|
|
3241
|
-
|
|
3242
|
-
materialization_task.add_done_callback(
|
|
3243
|
-
_log_background_materialization
|
|
3244
|
-
)
|
|
3245
|
-
fact_ids = list(result.fact_ids)
|
|
3246
|
-
# The queryable write is a separate durable transaction. A cold
|
|
3247
|
-
# optional enrichment dependency (most often the local embedding
|
|
3248
|
-
# worker) may need its bounded retry window, but must not turn an
|
|
3249
|
-
# already-admitted fact into an HTTP 500. Keep the operation's
|
|
3250
|
-
# failed state truthful so the daemon materializer retries it; the
|
|
3251
|
-
# response communicates that the fact is queryable, not complete.
|
|
3252
|
-
enrichment_deferred = (
|
|
3253
|
-
result.state is IngestionState.FAILED and bool(fact_ids)
|
|
3484
|
+
actor = Actor(
|
|
3485
|
+
principal_id=trusted_actor_id,
|
|
3486
|
+
allowed_profiles=frozenset({engine._profile_id}),
|
|
3487
|
+
allowed_scopes=frozenset({scope}),
|
|
3254
3488
|
)
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
payload={
|
|
3261
|
-
"operation_id": result.operation_id,
|
|
3262
|
-
"fact_ids": fact_ids,
|
|
3263
|
-
"tags": req.tags or "",
|
|
3264
|
-
"content_preview": req.content[:120],
|
|
3265
|
-
"path": (
|
|
3266
|
-
"remember_sync"
|
|
3267
|
-
if completed
|
|
3268
|
-
else "remember_sync_deferred"
|
|
3269
|
-
if enrichment_deferred
|
|
3270
|
-
else "remember_queryable"
|
|
3271
|
-
),
|
|
3272
|
-
},
|
|
3489
|
+
receipt = await asyncio.to_thread(
|
|
3490
|
+
runtime.remember,
|
|
3491
|
+
admission,
|
|
3492
|
+
actor,
|
|
3493
|
+
deadline_ms=2_000,
|
|
3273
3494
|
)
|
|
3495
|
+
payload = dict(receipt.payload)
|
|
3496
|
+
fact_ids = list(payload.get("fact_ids") or [])
|
|
3274
3497
|
return {
|
|
3275
3498
|
"ok": True,
|
|
3276
3499
|
"fact_ids": fact_ids,
|
|
3277
3500
|
"count": len(fact_ids),
|
|
3278
|
-
"operation_id":
|
|
3501
|
+
"operation_id": payload["operation_id"],
|
|
3279
3502
|
# One-release compatibility alias. The durable operation ID is
|
|
3280
3503
|
# opaque and replaces the integer pending.db row identifier.
|
|
3281
|
-
"pending_id":
|
|
3282
|
-
"status": "
|
|
3283
|
-
"materialization_state":
|
|
3284
|
-
"
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
else "queryable now; enrichment continues after the wait budget"
|
|
3288
|
-
if wait_budget_exhausted
|
|
3289
|
-
else "queryable now; canonical enrichment will retry"
|
|
3290
|
-
if enrichment_deferred
|
|
3291
|
-
else "queryable now; canonical enrichment pending"
|
|
3292
|
-
),
|
|
3293
|
-
"wait_budget_exhausted": wait_budget_exhausted,
|
|
3504
|
+
"pending_id": payload["pending_id"],
|
|
3505
|
+
"status": "queryable",
|
|
3506
|
+
"materialization_state": payload["materialization_state"],
|
|
3507
|
+
"commit_sequence": payload.get("commit_sequence"),
|
|
3508
|
+
"note": "queryable now; canonical enrichment continues in the background",
|
|
3509
|
+
"wait_ignored": bool(wait),
|
|
3294
3510
|
}
|
|
3295
3511
|
except Exception as exc:
|
|
3296
|
-
|
|
3512
|
+
from superlocalmemory.core.remember_admission import AdmissionRejected
|
|
3513
|
+
from superlocalmemory.core.remember_runtime import CanonicalRememberUnavailable
|
|
3514
|
+
from superlocalmemory.storage.admission_journal import (
|
|
3515
|
+
AdmissionAuthorizationError,
|
|
3516
|
+
AdmissionPayloadError,
|
|
3517
|
+
IdempotencyConflict,
|
|
3518
|
+
)
|
|
3519
|
+
|
|
3520
|
+
if isinstance(exc, CanonicalRememberUnavailable) or (
|
|
3521
|
+
isinstance(exc, AdmissionRejected) and exc.retryable
|
|
3522
|
+
):
|
|
3523
|
+
raise HTTPException(
|
|
3524
|
+
503,
|
|
3525
|
+
detail="canonical remember is temporarily unavailable; retry shortly",
|
|
3526
|
+
) from exc
|
|
3527
|
+
if isinstance(exc, AdmissionRejected):
|
|
3528
|
+
raise HTTPException(
|
|
3529
|
+
422,
|
|
3530
|
+
detail="remember admission was rejected by deterministic policy",
|
|
3531
|
+
) from exc
|
|
3532
|
+
if isinstance(exc, (AdmissionAuthorizationError, PermissionError)):
|
|
3533
|
+
raise HTTPException(403, detail="remember admission is not authorized") from exc
|
|
3534
|
+
if isinstance(
|
|
3535
|
+
exc,
|
|
3536
|
+
(AdmissionPayloadError, IdempotencyConflict),
|
|
3537
|
+
):
|
|
3538
|
+
raise HTTPException(422, detail=str(exc)) from exc
|
|
3539
|
+
logger.exception("canonical remember admission failed")
|
|
3540
|
+
raise HTTPException(500, detail="canonical remember admission failed") from exc
|
|
3297
3541
|
|
|
3298
3542
|
@application.post("/observe")
|
|
3299
3543
|
async def observe(req: ObserveRequest, request: Request):
|
|
@@ -3781,6 +4025,17 @@ def _materialize_ingestion_one_pass(
|
|
|
3781
4025
|
from superlocalmemory.core.ingestion_command import IngestionState
|
|
3782
4026
|
|
|
3783
4027
|
command = build_engine_ingestion_command(engine)
|
|
4028
|
+
reap = getattr(command.repository, "reap_stuck_enriching", None)
|
|
4029
|
+
try:
|
|
4030
|
+
reaped = reap() if callable(reap) else []
|
|
4031
|
+
except Exception as exc:
|
|
4032
|
+
logger.warning("ingestion reaper failed; materializer pass continues: %s", exc)
|
|
4033
|
+
reaped = []
|
|
4034
|
+
if reaped:
|
|
4035
|
+
logger.warning(
|
|
4036
|
+
"Materializer terminalized %d exhausted ingestion operation(s)",
|
|
4037
|
+
len(reaped),
|
|
4038
|
+
)
|
|
3784
4039
|
completed = failed = 0
|
|
3785
4040
|
for operation in command.repository.list_materializable(
|
|
3786
4041
|
limit=limit,
|
|
@@ -4007,7 +4262,12 @@ def start_server(port: int = _DEFAULT_PORT) -> None:
|
|
|
4007
4262
|
"reachable from the network but SLM_REQUIRE_CREDENTIALS is not set "
|
|
4008
4263
|
"and API-key auth may be off. A remote caller could write without "
|
|
4009
4264
|
"credentials. Set SLM_REQUIRE_CREDENTIALS=1 and configure an API "
|
|
4010
|
-
"key before exposing this instance."
|
|
4265
|
+
"key before exposing this instance. "
|
|
4266
|
+
"NOTE (issue #90): SLM 3.8.4 fixes IPv4-mapped loopback address "
|
|
4267
|
+
"normalization — if curl/dashboard writes fail with 403 from a "
|
|
4268
|
+
"container, upgrade to 3.8.4. Immediate workaround: set "
|
|
4269
|
+
"SLM_DAEMON_HOST=127.0.0.1 (IPv4 only) or configure an API key.",
|
|
4270
|
+
bind_host,
|
|
4011
4271
|
)
|
|
4012
4272
|
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
4013
4273
|
# This handles a just-closed connection in TIME_WAIT. It is safe only
|