superlocalmemory 3.8.5 → 3.8.7
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 +47 -0
- package/README.md +3 -3
- 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 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +139 -404
- package/src/superlocalmemory/core/backend_orchestrator.py +7 -1
- package/src/superlocalmemory/core/component_registry.py +4 -2
- package/src/superlocalmemory/core/embeddings.py +33 -6
- package/src/superlocalmemory/core/engine.py +94 -49
- package/src/superlocalmemory/core/engine_ingestion.py +150 -63
- package/src/superlocalmemory/core/ingestion_command.py +133 -21
- package/src/superlocalmemory/core/mutations.py +32 -10
- package/src/superlocalmemory/core/recall_pipeline.py +111 -77
- package/src/superlocalmemory/core/remember_admission.py +152 -0
- package/src/superlocalmemory/core/remember_runtime.py +712 -0
- package/src/superlocalmemory/graph/cozo_backend.py +5 -5
- package/src/superlocalmemory/learning/bandit.py +50 -1
- package/src/superlocalmemory/learning/source_quality.py +38 -35
- package/src/superlocalmemory/mcp/_daemon_proxy.py +38 -15
- 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 +8 -3
- package/src/superlocalmemory/retrieval/reranker.py +35 -10
- package/src/superlocalmemory/server/loopback.py +7 -13
- 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/behavioral.py +5 -13
- package/src/superlocalmemory/server/routes/brain.py +6 -9
- 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 +44 -23
- 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 +122 -100
- package/src/superlocalmemory/server/routes/tiers.py +3 -22
- package/src/superlocalmemory/server/routes/timeline.py +2 -4
- package/src/superlocalmemory/server/routes/v3_api.py +18 -16
- package/src/superlocalmemory/server/unified_daemon.py +200 -109
- package/src/superlocalmemory/storage/admission_codec.py +119 -0
- package/src/superlocalmemory/storage/admission_journal.py +728 -0
- package/src/superlocalmemory/storage/database.py +59 -0
- package/src/superlocalmemory/storage/deferred_writes.py +67 -11
- package/src/superlocalmemory/storage/memory_write.py +8 -12
- package/src/superlocalmemory/storage/migration_runner.py +37 -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/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
|
@@ -11,7 +11,7 @@ import logging
|
|
|
11
11
|
import os
|
|
12
12
|
from fastapi import APIRouter, HTTPException, Request
|
|
13
13
|
from fastapi.responses import JSONResponse
|
|
14
|
-
from superlocalmemory.server.routes.helpers import SLM_VERSION
|
|
14
|
+
from superlocalmemory.server.routes.helpers import SLM_VERSION, get_read_connection
|
|
15
15
|
from superlocalmemory.server.route_mutations import authorize_route_mutation
|
|
16
16
|
|
|
17
17
|
logger = logging.getLogger(__name__)
|
|
@@ -101,13 +101,12 @@ async def dashboard(request: Request):
|
|
|
101
101
|
active_profile = get_profile_runtime(request.app.state).snapshot.profile_id
|
|
102
102
|
|
|
103
103
|
# Read stats directly from SQLite (dashboard doesn't load engine)
|
|
104
|
-
import sqlite3
|
|
105
104
|
memory_count = 0
|
|
106
105
|
fact_count = 0
|
|
107
106
|
db_path = config.base_dir / "memory.db"
|
|
108
107
|
if db_path.exists():
|
|
109
108
|
try:
|
|
110
|
-
conn =
|
|
109
|
+
conn = get_read_connection(db_path)
|
|
111
110
|
cursor = conn.cursor()
|
|
112
111
|
try:
|
|
113
112
|
cursor.execute(
|
|
@@ -358,7 +357,10 @@ async def get_embedding_config(request: Request):
|
|
|
358
357
|
"""Return current embedding configuration."""
|
|
359
358
|
try:
|
|
360
359
|
from superlocalmemory.core.config import SLMConfig
|
|
361
|
-
config
|
|
360
|
+
# The daemon may already be running a freshly hot-swapped config while
|
|
361
|
+
# a profile update is still being persisted. The dashboard must show
|
|
362
|
+
# that live truth, never silently replace it with disk defaults.
|
|
363
|
+
config = getattr(request.app.state, "config", None) or SLMConfig.load()
|
|
362
364
|
emb = config.embedding
|
|
363
365
|
return {
|
|
364
366
|
"provider": emb.provider,
|
|
@@ -382,7 +384,7 @@ async def set_embedding_config(request: Request):
|
|
|
382
384
|
try:
|
|
383
385
|
body = await request.json()
|
|
384
386
|
from superlocalmemory.core.config import SLMConfig, EmbeddingConfig
|
|
385
|
-
config = SLMConfig.load()
|
|
387
|
+
config = getattr(request.app.state, "config", None) or SLMConfig.load()
|
|
386
388
|
|
|
387
389
|
new_provider = body.get("provider", config.embedding.provider)
|
|
388
390
|
new_model = body.get("model_name", config.embedding.model_name)
|
|
@@ -1422,7 +1424,7 @@ async def get_associations(
|
|
|
1422
1424
|
if not DB_PATH.exists():
|
|
1423
1425
|
return {"edges": [], "total": 0}
|
|
1424
1426
|
|
|
1425
|
-
conn =
|
|
1427
|
+
conn = get_read_connection(DB_PATH)
|
|
1426
1428
|
conn.row_factory = sqlite3.Row
|
|
1427
1429
|
|
|
1428
1430
|
# Build query with optional type filter (parameterized)
|
|
@@ -1495,7 +1497,7 @@ async def get_association_stats(request: Request, profile: str = ""):
|
|
|
1495
1497
|
"top_connected_facts": [],
|
|
1496
1498
|
}
|
|
1497
1499
|
|
|
1498
|
-
conn =
|
|
1500
|
+
conn = get_read_connection(DB_PATH)
|
|
1499
1501
|
conn.row_factory = sqlite3.Row
|
|
1500
1502
|
|
|
1501
1503
|
# Total edges
|
|
@@ -1607,7 +1609,7 @@ async def get_consolidation_status(request: Request, profile: str = ""):
|
|
|
1607
1609
|
if not DB_PATH.exists():
|
|
1608
1610
|
return result
|
|
1609
1611
|
|
|
1610
|
-
conn =
|
|
1612
|
+
conn = get_read_connection(DB_PATH)
|
|
1611
1613
|
conn.row_factory = sqlite3.Row
|
|
1612
1614
|
|
|
1613
1615
|
# Last consolidation log entry
|
|
@@ -1759,7 +1761,7 @@ async def get_core_memory(request: Request, profile: str = ""):
|
|
|
1759
1761
|
if not DB_PATH.exists():
|
|
1760
1762
|
return {"blocks": [], "total_chars": 0, "char_limit": 2000}
|
|
1761
1763
|
|
|
1762
|
-
conn =
|
|
1764
|
+
conn = get_read_connection(DB_PATH)
|
|
1763
1765
|
conn.row_factory = sqlite3.Row
|
|
1764
1766
|
|
|
1765
1767
|
rows = conn.execute(
|
|
@@ -1901,7 +1903,7 @@ async def get_vector_store_status(request: Request, profile: str = ""):
|
|
|
1901
1903
|
# Count vectors in embedding_metadata
|
|
1902
1904
|
if DB_PATH.exists():
|
|
1903
1905
|
try:
|
|
1904
|
-
conn =
|
|
1906
|
+
conn = get_read_connection(DB_PATH)
|
|
1905
1907
|
count = conn.execute(
|
|
1906
1908
|
"SELECT COUNT(*) FROM embedding_metadata WHERE profile_id = ?",
|
|
1907
1909
|
(pid,),
|
|
@@ -1945,7 +1947,7 @@ async def forgetting_stats(request: Request, profile: str = ""):
|
|
|
1945
1947
|
if not DB_PATH.exists():
|
|
1946
1948
|
return {"total": total, "zones": zones}
|
|
1947
1949
|
|
|
1948
|
-
conn =
|
|
1950
|
+
conn = get_read_connection(DB_PATH)
|
|
1949
1951
|
conn.row_factory = _sqlite3.Row
|
|
1950
1952
|
|
|
1951
1953
|
try:
|
|
@@ -2071,7 +2073,7 @@ async def quantization_stats(request: Request, profile: str = ""):
|
|
|
2071
2073
|
if not DB_PATH.exists():
|
|
2072
2074
|
return {"total": total, "tiers": tiers, "compression_ratio": compression_ratio}
|
|
2073
2075
|
|
|
2074
|
-
conn =
|
|
2076
|
+
conn = get_read_connection(DB_PATH)
|
|
2075
2077
|
conn.row_factory = _sqlite3.Row
|
|
2076
2078
|
|
|
2077
2079
|
try:
|
|
@@ -2130,7 +2132,7 @@ async def ccq_blocks(request: Request, profile: str = "", limit: int = 50):
|
|
|
2130
2132
|
if not DB_PATH.exists():
|
|
2131
2133
|
return {"blocks": [], "total": 0}
|
|
2132
2134
|
|
|
2133
|
-
conn =
|
|
2135
|
+
conn = get_read_connection(DB_PATH)
|
|
2134
2136
|
conn.row_factory = _sqlite3.Row
|
|
2135
2137
|
|
|
2136
2138
|
blocks = []
|
|
@@ -2190,7 +2192,7 @@ async def get_soft_prompts(request: Request, profile: str = ""):
|
|
|
2190
2192
|
if not DB_PATH.exists():
|
|
2191
2193
|
return {"prompts": [], "total": 0, "total_tokens": 0}
|
|
2192
2194
|
|
|
2193
|
-
conn =
|
|
2195
|
+
conn = get_read_connection(DB_PATH)
|
|
2194
2196
|
conn.row_factory = _sqlite3.Row
|
|
2195
2197
|
|
|
2196
2198
|
prompts = []
|
|
@@ -2298,7 +2300,7 @@ async def get_graph_communities(request: Request, profile: str = ""):
|
|
|
2298
2300
|
if not DB_PATH.exists():
|
|
2299
2301
|
return {"communities": [], "total": 0}
|
|
2300
2302
|
|
|
2301
|
-
conn =
|
|
2303
|
+
conn = get_read_connection(DB_PATH)
|
|
2302
2304
|
conn.row_factory = sqlite3.Row
|
|
2303
2305
|
|
|
2304
2306
|
# Get community member counts and average pagerank
|
|
@@ -2465,7 +2467,7 @@ async def v33_overview(request: Request, profile: str = ""):
|
|
|
2465
2467
|
if not DB_PATH.exists():
|
|
2466
2468
|
return overview
|
|
2467
2469
|
|
|
2468
|
-
conn =
|
|
2470
|
+
conn = get_read_connection(DB_PATH)
|
|
2469
2471
|
conn.row_factory = _sqlite3.Row
|
|
2470
2472
|
|
|
2471
2473
|
# Forgetting stats
|
|
@@ -383,6 +383,18 @@ def _hot_reconfigure_engine(application, new_config, *, mode_change: bool) -> No
|
|
|
383
383
|
new_engine.close()
|
|
384
384
|
raise
|
|
385
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
|
+
|
|
386
398
|
# The profile transition barrier is exclusive here. Publish every
|
|
387
399
|
# long-lived reference before closing the former engine.
|
|
388
400
|
application.state.engine = new_engine
|
|
@@ -1205,6 +1217,25 @@ async def _cancel_source_quality_repair(application) -> None:
|
|
|
1205
1217
|
pass
|
|
1206
1218
|
|
|
1207
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
|
+
|
|
1208
1239
|
@asynccontextmanager
|
|
1209
1240
|
async def lifespan(application: FastAPI):
|
|
1210
1241
|
"""Initialize engine, workers, and optional services on startup."""
|
|
@@ -1212,6 +1243,7 @@ async def lifespan(application: FastAPI):
|
|
|
1212
1243
|
|
|
1213
1244
|
engine = None
|
|
1214
1245
|
config = None
|
|
1246
|
+
canonical_remember_runtime = None
|
|
1215
1247
|
|
|
1216
1248
|
# The local dashboard obtains its short-lived browser credential from
|
|
1217
1249
|
# ``/internal/token`` before its first write or token-gated read. A
|
|
@@ -1327,6 +1359,17 @@ async def lifespan(application: FastAPI):
|
|
|
1327
1359
|
engine = MemoryEngine(config)
|
|
1328
1360
|
engine.initialize()
|
|
1329
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
|
+
|
|
1330
1373
|
# WAL is already established at DB creation (DatabaseManager._enable_wal
|
|
1331
1374
|
# / schema init). Re-asserting PRAGMA journal_mode=WAL here is a
|
|
1332
1375
|
# schema-level write on the shared connection that raced in-flight
|
|
@@ -1872,6 +1915,9 @@ async def lifespan(application: FastAPI):
|
|
|
1872
1915
|
|
|
1873
1916
|
except Exception:
|
|
1874
1917
|
logger.exception("Engine init failed") # auto-includes traceback
|
|
1918
|
+
_release_canonical_remember_runtime(
|
|
1919
|
+
application, canonical_remember_runtime,
|
|
1920
|
+
)
|
|
1875
1921
|
application.state.engine = None
|
|
1876
1922
|
application.state.config = None
|
|
1877
1923
|
|
|
@@ -2292,9 +2338,10 @@ async def lifespan(application: FastAPI):
|
|
|
2292
2338
|
logger.warning("perf_log flush failed: %s", exc)
|
|
2293
2339
|
|
|
2294
2340
|
materializer_stopped = _stop_pending_materializer()
|
|
2341
|
+
canonical_writer_stopped = _release_canonical_remember_runtime(application)
|
|
2295
2342
|
_profile_runtime = None
|
|
2296
2343
|
_engine = None
|
|
2297
|
-
if engine is not None and materializer_stopped:
|
|
2344
|
+
if engine is not None and materializer_stopped and canonical_writer_stopped:
|
|
2298
2345
|
try:
|
|
2299
2346
|
engine.close()
|
|
2300
2347
|
except Exception:
|
|
@@ -2304,7 +2351,8 @@ async def lifespan(application: FastAPI):
|
|
|
2304
2351
|
# object still owned by an admitted background operation; OS process
|
|
2305
2352
|
# teardown is safer than racing that writer with engine.close().
|
|
2306
2353
|
logger.warning(
|
|
2307
|
-
"Engine close deferred because
|
|
2354
|
+
"Engine close deferred because a write-capable background component "
|
|
2355
|
+
"is still active"
|
|
2308
2356
|
)
|
|
2309
2357
|
_cleanup_process_descriptor(
|
|
2310
2358
|
getattr(application.state, "daemon_descriptor", None),
|
|
@@ -3087,6 +3135,12 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
3087
3135
|
@application.get("/health")
|
|
3088
3136
|
async def health(request: Request = None):
|
|
3089
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}
|
|
3090
3144
|
# Non-blocking peek: report status without forcing a re-init.
|
|
3091
3145
|
engine = getattr(application.state, "engine", None)
|
|
3092
3146
|
migration_result = getattr(application.state, "migration_result", None)
|
|
@@ -3097,24 +3151,40 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
3097
3151
|
migrations_ready = bool(migration_result) and not migration_failures
|
|
3098
3152
|
if migration_details.get("_crash"):
|
|
3099
3153
|
migrations_ready = False
|
|
3154
|
+
writer_runtime = getattr(
|
|
3155
|
+
application.state,
|
|
3156
|
+
"canonical_remember_runtime",
|
|
3157
|
+
None,
|
|
3158
|
+
)
|
|
3100
3159
|
readiness = {
|
|
3101
3160
|
"engine": engine is not None,
|
|
3102
3161
|
"migrations": migrations_ready,
|
|
3103
|
-
"
|
|
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,
|
|
3104
3168
|
"migration_failures": migration_failures,
|
|
3105
3169
|
}
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
runtime_state = (
|
|
3109
|
-
"ready" if fully_ready else "warming" if base_ready else "not_ready"
|
|
3170
|
+
readiness["retrieval"] = bool(
|
|
3171
|
+
readiness["embedding"] and readiness["recall_health"]
|
|
3110
3172
|
)
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
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"
|
|
3118
3188
|
identity = getattr(application.state, "daemon_descriptor", None)
|
|
3119
3189
|
from superlocalmemory.server.profile_runtime import get_profile_runtime
|
|
3120
3190
|
|
|
@@ -3129,7 +3199,8 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
3129
3199
|
"ready": fully_ready,
|
|
3130
3200
|
# Runtime readiness is more precise than descriptor lifecycle.
|
|
3131
3201
|
# A process can be alive and identity-valid while retrieval warms.
|
|
3132
|
-
"state":
|
|
3202
|
+
"state": lifecycle_state,
|
|
3203
|
+
"runtime_state": runtime_state,
|
|
3133
3204
|
"version": getattr(application, 'version', 'unknown'),
|
|
3134
3205
|
}
|
|
3135
3206
|
# request is None only for direct internal/test calls (no HTTP client),
|
|
@@ -3157,12 +3228,12 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
3157
3228
|
"embedding_warm": _embedding_warm,
|
|
3158
3229
|
# v3.6.8: True iff the semantic channel actually fired on the last
|
|
3159
3230
|
# health probe; includes self-heal counters.
|
|
3160
|
-
"recall_health":
|
|
3231
|
+
"recall_health": recall_health,
|
|
3161
3232
|
**(identity.public_health_fields() if identity is not None else {}),
|
|
3162
|
-
#
|
|
3163
|
-
#
|
|
3164
|
-
|
|
3165
|
-
"
|
|
3233
|
+
# Lifecycle state remains backward compatible for daemon
|
|
3234
|
+
# discovery; channel degradation is exposed separately.
|
|
3235
|
+
"state": lifecycle_state,
|
|
3236
|
+
"runtime_state": runtime_state,
|
|
3166
3237
|
"active_profile": profile_snapshot.profile_id,
|
|
3167
3238
|
"profile_generation": profile_snapshot.generation,
|
|
3168
3239
|
}
|
|
@@ -3327,11 +3398,11 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
3327
3398
|
request: Request,
|
|
3328
3399
|
wait: bool = False,
|
|
3329
3400
|
):
|
|
3330
|
-
"""
|
|
3401
|
+
"""Journal and commit a bounded, immediately-queryable receipt.
|
|
3331
3402
|
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
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.
|
|
3335
3406
|
"""
|
|
3336
3407
|
trusted_actor_id = _require_write_actor(request)
|
|
3337
3408
|
_update_activity()
|
|
@@ -3344,14 +3415,29 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
3344
3415
|
scope = req.scope or getattr(_scope_cfg, "default_scope", "personal")
|
|
3345
3416
|
shared_with = req.shared_with
|
|
3346
3417
|
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
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",
|
|
3350
3434
|
)
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3435
|
+
|
|
3436
|
+
try:
|
|
3437
|
+
from superlocalmemory.core.remember_runtime import (
|
|
3438
|
+
validate_deterministic_admission,
|
|
3354
3439
|
)
|
|
3440
|
+
from superlocalmemory.storage.admission_journal import Actor, RememberRequest
|
|
3355
3441
|
|
|
3356
3442
|
meta = {}
|
|
3357
3443
|
if req.tags:
|
|
@@ -3359,8 +3445,32 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
3359
3445
|
extra = getattr(req, "metadata", None)
|
|
3360
3446
|
if isinstance(extra, dict):
|
|
3361
3447
|
meta.update(extra)
|
|
3362
|
-
|
|
3363
|
-
|
|
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(
|
|
3364
3474
|
content=req.content,
|
|
3365
3475
|
profile_id=engine._profile_id,
|
|
3366
3476
|
source_type="http",
|
|
@@ -3371,93 +3481,63 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
3371
3481
|
trusted_actor_id=trusted_actor_id,
|
|
3372
3482
|
session_id=req.session_id,
|
|
3373
3483
|
)
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
result = receipt
|
|
3379
|
-
wait_budget_exhausted = False
|
|
3380
|
-
if wait:
|
|
3381
|
-
materialization_task = asyncio.create_task(
|
|
3382
|
-
asyncio.to_thread(command.materialize, receipt.operation_id)
|
|
3383
|
-
)
|
|
3384
|
-
try:
|
|
3385
|
-
result = await asyncio.wait_for(
|
|
3386
|
-
asyncio.shield(materialization_task),
|
|
3387
|
-
timeout=_REMEMBER_ENRICHMENT_WAIT_SECONDS,
|
|
3388
|
-
)
|
|
3389
|
-
except TimeoutError:
|
|
3390
|
-
# The task retains the M018 lease and continues outside
|
|
3391
|
-
# this request. Return the durable receipt honestly;
|
|
3392
|
-
# the normal materializer can also reclaim it after a
|
|
3393
|
-
# lease expiry if the request-owned worker dies.
|
|
3394
|
-
wait_budget_exhausted = True
|
|
3395
|
-
|
|
3396
|
-
def _log_background_materialization(task):
|
|
3397
|
-
try:
|
|
3398
|
-
task.result()
|
|
3399
|
-
except Exception as exc:
|
|
3400
|
-
logger.warning(
|
|
3401
|
-
"bounded remember enrichment failed for %s: %s",
|
|
3402
|
-
receipt.operation_id,
|
|
3403
|
-
exc,
|
|
3404
|
-
)
|
|
3405
|
-
|
|
3406
|
-
materialization_task.add_done_callback(
|
|
3407
|
-
_log_background_materialization
|
|
3408
|
-
)
|
|
3409
|
-
fact_ids = list(result.fact_ids)
|
|
3410
|
-
# The queryable write is a separate durable transaction. A cold
|
|
3411
|
-
# optional enrichment dependency (most often the local embedding
|
|
3412
|
-
# worker) may need its bounded retry window, but must not turn an
|
|
3413
|
-
# already-admitted fact into an HTTP 500. Keep the operation's
|
|
3414
|
-
# failed state truthful so the daemon materializer retries it; the
|
|
3415
|
-
# response communicates that the fact is queryable, not complete.
|
|
3416
|
-
enrichment_deferred = (
|
|
3417
|
-
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}),
|
|
3418
3488
|
)
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
payload={
|
|
3425
|
-
"operation_id": result.operation_id,
|
|
3426
|
-
"fact_ids": fact_ids,
|
|
3427
|
-
"tags": req.tags or "",
|
|
3428
|
-
"content_preview": req.content[:120],
|
|
3429
|
-
"path": (
|
|
3430
|
-
"remember_sync"
|
|
3431
|
-
if completed
|
|
3432
|
-
else "remember_sync_deferred"
|
|
3433
|
-
if enrichment_deferred
|
|
3434
|
-
else "remember_queryable"
|
|
3435
|
-
),
|
|
3436
|
-
},
|
|
3489
|
+
receipt = await asyncio.to_thread(
|
|
3490
|
+
runtime.remember,
|
|
3491
|
+
admission,
|
|
3492
|
+
actor,
|
|
3493
|
+
deadline_ms=2_000,
|
|
3437
3494
|
)
|
|
3495
|
+
payload = dict(receipt.payload)
|
|
3496
|
+
fact_ids = list(payload.get("fact_ids") or [])
|
|
3438
3497
|
return {
|
|
3439
3498
|
"ok": True,
|
|
3440
3499
|
"fact_ids": fact_ids,
|
|
3441
3500
|
"count": len(fact_ids),
|
|
3442
|
-
"operation_id":
|
|
3501
|
+
"operation_id": payload["operation_id"],
|
|
3443
3502
|
# One-release compatibility alias. The durable operation ID is
|
|
3444
3503
|
# opaque and replaces the integer pending.db row identifier.
|
|
3445
|
-
"pending_id":
|
|
3446
|
-
"status": "
|
|
3447
|
-
"materialization_state":
|
|
3448
|
-
"
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
else "queryable now; enrichment continues after the wait budget"
|
|
3452
|
-
if wait_budget_exhausted
|
|
3453
|
-
else "queryable now; canonical enrichment will retry"
|
|
3454
|
-
if enrichment_deferred
|
|
3455
|
-
else "queryable now; canonical enrichment pending"
|
|
3456
|
-
),
|
|
3457
|
-
"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),
|
|
3458
3510
|
}
|
|
3459
3511
|
except Exception as exc:
|
|
3460
|
-
|
|
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
|
|
3461
3541
|
|
|
3462
3542
|
@application.post("/observe")
|
|
3463
3543
|
async def observe(req: ObserveRequest, request: Request):
|
|
@@ -3945,6 +4025,17 @@ def _materialize_ingestion_one_pass(
|
|
|
3945
4025
|
from superlocalmemory.core.ingestion_command import IngestionState
|
|
3946
4026
|
|
|
3947
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
|
+
)
|
|
3948
4039
|
completed = failed = 0
|
|
3949
4040
|
for operation in command.repository.list_materializable(
|
|
3950
4041
|
limit=limit,
|