superlocalmemory 4.0.9 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +245 -0
- package/README.md +7 -7
- package/package.json +4 -2
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +4 -4
- 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 +2 -2
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +5 -5
- package/plugin/skills/slm-recall/SKILL.md +102 -15
- package/plugin/skills/slm-remember/SKILL.md +35 -3
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +29 -3
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +16 -8
- 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-governance/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-loop/SKILL.md +2 -2
- package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
- package/plugin-src/skills/slm-profile/SKILL.md +5 -5
- package/plugin-src/skills/slm-recall/SKILL.md +102 -15
- package/plugin-src/skills/slm-remember/SKILL.md +35 -3
- package/plugin-src/skills/slm-scope/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +29 -3
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +308 -20
- package/src/superlocalmemory/cli/daemon.py +30 -0
- package/src/superlocalmemory/cli/db_migrate.py +71 -1
- package/src/superlocalmemory/cli/gdpr_cmd.py +15 -2
- package/src/superlocalmemory/cli/main.py +26 -4
- package/src/superlocalmemory/code_graph/bridge/maintenance.py +8 -0
- package/src/superlocalmemory/code_graph/database.py +44 -0
- package/src/superlocalmemory/compliance/gdpr.py +449 -39
- package/src/superlocalmemory/core/admission.py +231 -11
- package/src/superlocalmemory/core/backend_orchestrator.py +190 -84
- package/src/superlocalmemory/core/config.py +90 -11
- package/src/superlocalmemory/core/consolidation_engine.py +34 -0
- package/src/superlocalmemory/core/engine.py +140 -11
- package/src/superlocalmemory/core/fact_consolidator.py +316 -125
- package/src/superlocalmemory/core/graph_analyzer.py +76 -112
- package/src/superlocalmemory/core/graph_metrics.py +597 -0
- package/src/superlocalmemory/core/graph_pruner.py +121 -0
- package/src/superlocalmemory/core/maintenance.py +44 -6
- package/src/superlocalmemory/core/maintenance_scheduler.py +205 -0
- package/src/superlocalmemory/core/memory_health.py +266 -0
- package/src/superlocalmemory/core/mode_capability.py +111 -0
- package/src/superlocalmemory/core/ollama_validator.py +315 -0
- package/src/superlocalmemory/core/operation_policy_registry.py +1 -1
- package/src/superlocalmemory/core/operation_request.py +1 -1
- package/src/superlocalmemory/core/ops_remediation.py +2 -2
- package/src/superlocalmemory/core/projection_drain.py +380 -0
- package/src/superlocalmemory/core/recall_pipeline.py +390 -3
- package/src/superlocalmemory/core/recall_worker.py +6 -3
- package/src/superlocalmemory/core/scale_autopromote.py +196 -0
- package/src/superlocalmemory/core/scale_engine.py +16 -2
- package/src/superlocalmemory/core/score_contract.py +21 -1
- package/src/superlocalmemory/core/session_identity.py +85 -0
- package/src/superlocalmemory/core/status_contract.py +108 -0
- package/src/superlocalmemory/core/store_pipeline.py +78 -3
- package/src/superlocalmemory/core/worker_pool.py +4 -4
- package/src/superlocalmemory/core/working_memory.py +288 -0
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +51 -7
- package/src/superlocalmemory/encoding/context_generator.py +1 -1
- package/src/superlocalmemory/encoding/entity_resolver.py +38 -0
- package/src/superlocalmemory/encoding/fact_extractor.py +18 -14
- package/src/superlocalmemory/encoding/prospective_markers.py +262 -0
- package/src/superlocalmemory/encoding/type_router.py +12 -12
- package/src/superlocalmemory/evolution/mutation_generator.py +30 -4
- package/src/superlocalmemory/graph/cozo_adjacency.py +122 -0
- package/src/superlocalmemory/graph/cozo_backend.py +103 -138
- package/src/superlocalmemory/hooks/portable_kit.py +10 -2
- package/src/superlocalmemory/learning/bandit.py +43 -0
- package/src/superlocalmemory/learning/consolidation_worker.py +54 -0
- package/src/superlocalmemory/learning/database.py +60 -3
- package/src/superlocalmemory/learning/entity_compiler.py +21 -58
- package/src/superlocalmemory/learning/feedback.py +3 -1
- package/src/superlocalmemory/learning/outcomes.py +47 -16
- package/src/superlocalmemory/learning/pattern_miner.py +28 -3
- package/src/superlocalmemory/learning/pattern_miner_constants.py +43 -0
- package/src/superlocalmemory/learning/pcos.py +291 -0
- package/src/superlocalmemory/learning/reward_from_outcomes.py +365 -0
- package/src/superlocalmemory/learning/reward_proxy.py +100 -10
- package/src/superlocalmemory/learning/signal_kinds.py +79 -0
- package/src/superlocalmemory/mcp/profiles.py +14 -2
- package/src/superlocalmemory/mcp/server.py +1 -1
- package/src/superlocalmemory/mcp/session_binding.py +92 -0
- package/src/superlocalmemory/mcp/tools_active.py +2 -1
- package/src/superlocalmemory/mcp/tools_core.py +71 -42
- package/src/superlocalmemory/mcp/tools_ops.py +2 -2
- package/src/superlocalmemory/mcp/tools_v28.py +20 -1
- package/src/superlocalmemory/parameterization/pattern_extractor.py +14 -1
- package/src/superlocalmemory/parameterization/soft_prompt_generator.py +98 -0
- package/src/superlocalmemory/retrieval/bm25_channel.py +68 -11
- package/src/superlocalmemory/retrieval/channel_status.py +117 -0
- package/src/superlocalmemory/retrieval/engine.py +106 -11
- package/src/superlocalmemory/retrieval/entity_channel.py +217 -257
- package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
- package/src/superlocalmemory/retrieval/scope_policy.py +42 -1
- package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
- package/src/superlocalmemory/retrieval/spreading.py +288 -0
- package/src/superlocalmemory/retrieval/temporal_channel.py +13 -1
- package/src/superlocalmemory/retrieval/vector_store.py +63 -0
- package/src/superlocalmemory/server/api.py +26 -2
- package/src/superlocalmemory/server/asset_versions.py +171 -0
- package/src/superlocalmemory/server/bandit_loops.py +17 -1
- package/src/superlocalmemory/server/rbac_enforce.py +26 -6
- package/src/superlocalmemory/server/recall_serializer.py +9 -0
- package/src/superlocalmemory/server/routes/abstraction.py +201 -0
- package/src/superlocalmemory/server/routes/behavioral.py +75 -10
- package/src/superlocalmemory/server/routes/compliance.py +98 -18
- package/src/superlocalmemory/server/routes/config_api.py +186 -4
- package/src/superlocalmemory/server/routes/data_io.py +29 -1
- package/src/superlocalmemory/server/routes/entity.py +13 -1
- package/src/superlocalmemory/server/routes/evolution.py +178 -0
- package/src/superlocalmemory/server/routes/ingest.py +8 -0
- package/src/superlocalmemory/server/routes/learning_telemetry.py +2 -1
- package/src/superlocalmemory/server/routes/memories.py +49 -7
- package/src/superlocalmemory/server/routes/mesh.py +1 -1
- package/src/superlocalmemory/server/routes/timeline.py +4 -0
- package/src/superlocalmemory/server/routes/v3_api.py +193 -17
- package/src/superlocalmemory/server/ui.py +24 -1
- package/src/superlocalmemory/server/unified_daemon.py +292 -9
- package/src/superlocalmemory/storage/_migration_internals.py +35 -0
- package/src/superlocalmemory/storage/_schema_version.py +24 -3
- package/src/superlocalmemory/storage/database.py +598 -82
- package/src/superlocalmemory/storage/embedding_codec.py +71 -0
- package/src/superlocalmemory/storage/lineage_retention.py +236 -0
- package/src/superlocalmemory/storage/logical_edges.py +43 -2
- package/src/superlocalmemory/storage/migration_runner.py +130 -0
- package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +488 -0
- package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +127 -0
- package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +158 -0
- package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +620 -0
- package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +306 -0
- package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +207 -0
- package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +201 -0
- package/src/superlocalmemory/storage/migrations.py +18 -2
- package/src/superlocalmemory/storage/models.py +40 -1
- package/src/superlocalmemory/storage/projection_outbox.py +346 -0
- package/src/superlocalmemory/storage/retention_policy.py +860 -0
- package/src/superlocalmemory/storage/schema.py +110 -1
- package/src/superlocalmemory/storage/write_coordinator.py +19 -2
- package/src/superlocalmemory/summaries/base.py +1 -1
- package/src/superlocalmemory/summaries/non_answer.py +223 -0
- package/src/superlocalmemory/trust/scorer.py +43 -1
- package/src/superlocalmemory/ui/index.html +10 -19
- package/src/superlocalmemory/ui/js/event-delegation.js +12 -1
- package/src/superlocalmemory/ui/js/od-health.js +28 -6
- package/src/superlocalmemory/ui/js/od-memories.js +209 -1
- package/src/superlocalmemory/ui/js/od-ops-health.js +1 -1
- package/src/superlocalmemory/ui/js/od-settings.js +87 -1
- package/src/superlocalmemory/ui/js/recall-lab.js +78 -3
|
@@ -231,7 +231,30 @@ def create_app() -> FastAPI:
|
|
|
231
231
|
"<p><a href='/api/docs'>API Documentation</a></p>"
|
|
232
232
|
"</body></html>"
|
|
233
233
|
)
|
|
234
|
-
|
|
234
|
+
from superlocalmemory import __version__ as _v
|
|
235
|
+
|
|
236
|
+
# __SLM_VERSION__ was substituted only by the unified daemon, so the
|
|
237
|
+
# dashboard's upgrade detector did nothing when served from here.
|
|
238
|
+
# Asset versioning is cosmetic. It must never be why this page 500s.
|
|
239
|
+
#
|
|
240
|
+
# The import is deferred (house style, keeps startup lean), which means
|
|
241
|
+
# it resolves at REQUEST time — so when `pip install -e .` replaced the
|
|
242
|
+
# installed package underneath a running daemon, this route began
|
|
243
|
+
# answering "Internal Server Error" on the dashboard while every other
|
|
244
|
+
# endpoint was fine. A stale hand-written version string is a trifle; a
|
|
245
|
+
# blank page is not. Fall back to the file as written.
|
|
246
|
+
try:
|
|
247
|
+
from superlocalmemory.server.asset_versions import render_index
|
|
248
|
+
|
|
249
|
+
return render_index(
|
|
250
|
+
index_path, UI_DIR, substitutions={"__SLM_VERSION__": _v},
|
|
251
|
+
)
|
|
252
|
+
except Exception as exc: # noqa: BLE001 — serve the page regardless
|
|
253
|
+
logger.warning(
|
|
254
|
+
"asset version rewrite unavailable, serving index.html as "
|
|
255
|
+
"written: %s: %s", type(exc).__name__, exc,
|
|
256
|
+
)
|
|
257
|
+
return index_path.read_text().replace("__SLM_VERSION__", _v)
|
|
235
258
|
|
|
236
259
|
@application.get("/favicon.ico", include_in_schema=False)
|
|
237
260
|
async def favicon():
|
|
@@ -52,7 +52,7 @@ os.environ.setdefault("SLM_MCP_EMBEDDED", "1")
|
|
|
52
52
|
from fastapi import FastAPI, HTTPException, Request
|
|
53
53
|
from fastapi.middleware.cors import CORSMiddleware
|
|
54
54
|
from fastapi.middleware.gzip import GZipMiddleware
|
|
55
|
-
from pydantic import BaseModel
|
|
55
|
+
from pydantic import BaseModel, field_validator
|
|
56
56
|
|
|
57
57
|
from superlocalmemory.core.config import CANONICAL_RECALL_LIMIT
|
|
58
58
|
from superlocalmemory.infra.daemon_identity import (
|
|
@@ -587,6 +587,62 @@ class RememberRequest(BaseModel):
|
|
|
587
587
|
metadata: dict | None = None # v3.4.26: pass-through from MCP pool_store
|
|
588
588
|
idempotency_key: str | None = None
|
|
589
589
|
session_id: str = ""
|
|
590
|
+
#: WHEN this memory is about, as distinct from when it was written.
|
|
591
|
+
#:
|
|
592
|
+
#: The internal admission record has carried this field all along and this
|
|
593
|
+
#: model never had it, so every memory arriving over HTTP — which is every
|
|
594
|
+
#: memory, from the CLI, the tool interface and the dashboard alike — was
|
|
595
|
+
#: stamped with its ingestion date. Measured on the author's store: 200 of
|
|
596
|
+
#: the 200 most recent facts have an observation_date, and 196 of them are
|
|
597
|
+
#: the day they were written. A store that cannot be told "this happened in
|
|
598
|
+
#: March" cannot answer a question about March.
|
|
599
|
+
#:
|
|
600
|
+
#: Empty means "today", the previous behaviour. Format is YYYY-MM-DD or a
|
|
601
|
+
#: full ISO 8601 timestamp.
|
|
602
|
+
session_date: str = ""
|
|
603
|
+
|
|
604
|
+
@field_validator("session_date")
|
|
605
|
+
@classmethod
|
|
606
|
+
def _session_date_is_a_date(cls, value: str) -> str:
|
|
607
|
+
"""Reject a malformed date rather than ignore it.
|
|
608
|
+
|
|
609
|
+
Dropping it silently would leave the caller believing the date was
|
|
610
|
+
recorded while the memory quietly filed itself under today — which is
|
|
611
|
+
the exact failure this field exists to fix, reintroduced one layer up.
|
|
612
|
+
Rejecting is recoverable: the caller sees the error and resends.
|
|
613
|
+
|
|
614
|
+
ISO ONLY, DELIBERATELY, even though this system ships a parser that is
|
|
615
|
+
far more permissive. ``encoding/temporal_parser.parse_session_date``
|
|
616
|
+
accepts "May 8, 2026", "1:56 pm on 8 May, 2026", "14/03/2026", and also
|
|
617
|
+
"last tuesday" and "March 2026" — and those last two are why it is not
|
|
618
|
+
used here. Asked on 2026-08-21 it resolves "last tuesday" to
|
|
619
|
+
**2026-08-25**, four days into the future, and "March 2026" to the 21st,
|
|
620
|
+
a day it invents. Accepting either at this boundary would file a memory
|
|
621
|
+
under a confidently wrong date, silently, which is precisely the class
|
|
622
|
+
of defect this field was added to remove.
|
|
623
|
+
|
|
624
|
+
The caller here is a program — the command line, the tool interface, the
|
|
625
|
+
dashboard — not a person typing. ISO is the right contract for a
|
|
626
|
+
program, and a caller holding a human-typed date can run it through the
|
|
627
|
+
parser itself and send the result.
|
|
628
|
+
"""
|
|
629
|
+
if not value:
|
|
630
|
+
return value
|
|
631
|
+
from datetime import datetime as _dt
|
|
632
|
+
|
|
633
|
+
text = value.strip()
|
|
634
|
+
try:
|
|
635
|
+
_dt.fromisoformat(text.replace("Z", "+00:00"))
|
|
636
|
+
except ValueError:
|
|
637
|
+
raise ValueError(
|
|
638
|
+
"session_date must be YYYY-MM-DD or a full ISO 8601 timestamp; "
|
|
639
|
+
f"got {value!r}. To accept looser human phrasing, parse it "
|
|
640
|
+
"first with TemporalParser.parse_session_date and send the ISO "
|
|
641
|
+
"result — but check what it returns, because it resolves "
|
|
642
|
+
"relative phrases against today and can answer with a future "
|
|
643
|
+
"date."
|
|
644
|
+
) from None
|
|
645
|
+
return text
|
|
590
646
|
# v3.6.15 multi-scope: visibility of the new memory. ``None`` scope means
|
|
591
647
|
# "use the configured default_scope" (personal). shared_with is the list of
|
|
592
648
|
# profile_ids for scope='shared'.
|
|
@@ -1748,6 +1804,41 @@ def _stop_deployment_retention(application) -> bool:
|
|
|
1748
1804
|
return True
|
|
1749
1805
|
|
|
1750
1806
|
|
|
1807
|
+
def _start_embedder_warmup(engine: object) -> "threading.Thread | None":
|
|
1808
|
+
"""Load the embedding model in the background. Never blocks startup.
|
|
1809
|
+
|
|
1810
|
+
Returns the thread so a test can join it; ``None`` when there is nothing to
|
|
1811
|
+
warm. Every failure is "not warmed", never a failed startup: a daemon that
|
|
1812
|
+
cannot embed still serves keyword recall and still stores memories, and the
|
|
1813
|
+
materializer fills the vectors in afterwards either way.
|
|
1814
|
+
"""
|
|
1815
|
+
embedder = getattr(engine, "_embedder", None)
|
|
1816
|
+
if embedder is None or not hasattr(embedder, "embed"):
|
|
1817
|
+
return None
|
|
1818
|
+
|
|
1819
|
+
from superlocalmemory.core.engine import _is_remote_embedder
|
|
1820
|
+
|
|
1821
|
+
if _is_remote_embedder(embedder):
|
|
1822
|
+
# A hosted embedder has no model to load and warming it would spend a
|
|
1823
|
+
# request, and money, on a sentence nobody asked about.
|
|
1824
|
+
return None
|
|
1825
|
+
|
|
1826
|
+
def _warm() -> None:
|
|
1827
|
+
started = time.time()
|
|
1828
|
+
try:
|
|
1829
|
+
embedder.embed("slm embedder warm-up")
|
|
1830
|
+
except Exception as exc: # pragma: no cover — warming is best effort
|
|
1831
|
+
logger.debug("embedder warm-up failed (%s) — writes will defer", exc)
|
|
1832
|
+
return
|
|
1833
|
+
logger.info(
|
|
1834
|
+
"Embedding model warm and ready (%.1fs)", time.time() - started,
|
|
1835
|
+
)
|
|
1836
|
+
|
|
1837
|
+
thread = threading.Thread(target=_warm, daemon=True, name="slm-embed-warmup")
|
|
1838
|
+
thread.start()
|
|
1839
|
+
return thread
|
|
1840
|
+
|
|
1841
|
+
|
|
1751
1842
|
@asynccontextmanager
|
|
1752
1843
|
async def lifespan(application: FastAPI):
|
|
1753
1844
|
"""Initialize engine, workers, and optional services on startup."""
|
|
@@ -2032,6 +2123,40 @@ async def lifespan(application: FastAPI):
|
|
|
2032
2123
|
engine = MemoryEngine(config)
|
|
2033
2124
|
engine.initialize()
|
|
2034
2125
|
|
|
2126
|
+
# Load the embedding model now, off the request path, the way the
|
|
2127
|
+
# cross-encoder is already warmed at startup.
|
|
2128
|
+
#
|
|
2129
|
+
# A write embeds inline so the memory it stores can be found by asking a
|
|
2130
|
+
# question rather than only by quoting its own words, and it gives that
|
|
2131
|
+
# one second before deferring to the materializer. Loading the model
|
|
2132
|
+
# takes 9.9-11.0 s here; once loaded an embed is 42 ms. So on a daemon
|
|
2133
|
+
# that had not embedded yet, the first writes each waited the full second
|
|
2134
|
+
# and stored no vector regardless -- and the model only ever loaded
|
|
2135
|
+
# because some *recall* eventually paid for it. Whoever recalled first
|
|
2136
|
+
# wore the cold start.
|
|
2137
|
+
#
|
|
2138
|
+
# This belongs to the daemon and not to engine wiring: the worker is a
|
|
2139
|
+
# subprocess per engine, so warming from wiring would have every `slm
|
|
2140
|
+
# status` spawn one and load a model it will never use.
|
|
2141
|
+
_start_embedder_warmup(engine)
|
|
2142
|
+
|
|
2143
|
+
# Tell the hook subprocesses that skill evolution is on. The hook reads
|
|
2144
|
+
# this env var as its fast-path signal and nothing ever set it, so the
|
|
2145
|
+
# feature was off for everyone who had switched it on in config: the
|
|
2146
|
+
# hook checked, found nothing, and returned False. Hooks are launched as
|
|
2147
|
+
# children of this process and inherit its environment, which is the
|
|
2148
|
+
# only channel between the two.
|
|
2149
|
+
try:
|
|
2150
|
+
if getattr(getattr(config, "evolution", None), "enabled", False):
|
|
2151
|
+
os.environ["SLM_EVOLUTION_ENABLED"] = "1"
|
|
2152
|
+
else:
|
|
2153
|
+
# Cleared as well as set: a daemon restarted with the setting
|
|
2154
|
+
# turned off must not leave the previous run's answer behind in
|
|
2155
|
+
# an environment the next hook inherits.
|
|
2156
|
+
os.environ.pop("SLM_EVOLUTION_ENABLED", None)
|
|
2157
|
+
except Exception as _evo_exc: # pragma: no cover — never block startup
|
|
2158
|
+
logger.debug("evolution flag not exported: %s", _evo_exc)
|
|
2159
|
+
|
|
2035
2160
|
# Refresh migration state now that the engine is initialised. Any
|
|
2036
2161
|
# schema work the engine's own bootstrap may have applied (e.g.
|
|
2037
2162
|
# runtime-table creation) is captured here so the dashboard and
|
|
@@ -2119,6 +2244,30 @@ async def lifespan(application: FastAPI):
|
|
|
2119
2244
|
"deferred migration runner crashed (non-fatal): %s", _dexc,
|
|
2120
2245
|
)
|
|
2121
2246
|
|
|
2247
|
+
# Move an existing store onto the graph and vector backends on the
|
|
2248
|
+
# first start after an upgrade. They have shipped as required
|
|
2249
|
+
# dependencies since 3.7 and sat unused, because building the
|
|
2250
|
+
# projections was three manual commands almost nobody ran. Runs after
|
|
2251
|
+
# the deferred migrations so it projects the converted store, and never
|
|
2252
|
+
# fatal: if the libraries will not import or the projection does not
|
|
2253
|
+
# match, the daemon serves from SQLite and says so.
|
|
2254
|
+
try:
|
|
2255
|
+
from superlocalmemory.core.scale_autopromote import (
|
|
2256
|
+
auto_promote_scale_backends,
|
|
2257
|
+
)
|
|
2258
|
+
_promotion = auto_promote_scale_backends(config)
|
|
2259
|
+
application.state.scale_autopromotion = _promotion.as_dict()
|
|
2260
|
+
if _promotion.promoted and _promotion.restart_required:
|
|
2261
|
+
logger.info(
|
|
2262
|
+
"graph and vector backends are promoted and serve after the "
|
|
2263
|
+
"next restart",
|
|
2264
|
+
)
|
|
2265
|
+
except Exception as _pexc: # pragma: no cover — defensive
|
|
2266
|
+
logger.warning("automatic backend promotion crashed (non-fatal): %s", _pexc)
|
|
2267
|
+
application.state.scale_autopromotion = {
|
|
2268
|
+
"attempted": True, "promoted": False, "reason": str(_pexc),
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2122
2271
|
# S9-DASH-02: start the outcome-queue worker so recall →
|
|
2123
2272
|
# pending_outcomes is actually produced. Before v3.4.22 this
|
|
2124
2273
|
# producer had zero callers and the closed-loop pipeline was
|
|
@@ -2399,6 +2548,10 @@ async def lifespan(application: FastAPI):
|
|
|
2399
2548
|
"state": "checking_components", "embeddings_backfilled": 0,
|
|
2400
2549
|
"expansion_backfilled": 0, "null_remaining": None,
|
|
2401
2550
|
"components": None,
|
|
2551
|
+
# Declared here so /status carries the same keys whatever
|
|
2552
|
+
# happens; a field that only appears on failure is a field
|
|
2553
|
+
# nobody's dashboard renders.
|
|
2554
|
+
"incomplete_reason": None,
|
|
2402
2555
|
"started_at": _t.time(), "finished_at": None,
|
|
2403
2556
|
}
|
|
2404
2557
|
# Step 0 (v3.8.2 "whole self-healer"): repair components that
|
|
@@ -2507,9 +2660,42 @@ async def lifespan(application: FastAPI):
|
|
|
2507
2660
|
_backfill_vector_store()
|
|
2508
2661
|
except Exception as exc:
|
|
2509
2662
|
logger.warning("Self-heal vector index failed (non-fatal): %s", exc)
|
|
2510
|
-
|
|
2663
|
+
|
|
2664
|
+
# "complete" has to mean complete.
|
|
2665
|
+
#
|
|
2666
|
+
# This line used to set "complete" unconditionally, so a
|
|
2667
|
+
# backfill that gave up after five no-progress attempts, or ran
|
|
2668
|
+
# out its 500-iteration budget, reported success with facts
|
|
2669
|
+
# still unembedded — and an unembedded fact cannot be found by
|
|
2670
|
+
# meaning at all. That is how a machine sat at 56.3% of its
|
|
2671
|
+
# memory reachable while its own status endpoint said the heal
|
|
2672
|
+
# had finished. Nobody was going to look past a green light.
|
|
2673
|
+
#
|
|
2674
|
+
# Now the state is derived from the remaining count, and the
|
|
2675
|
+
# gap is named in plain language for the dashboard, so an
|
|
2676
|
+
# incomplete heal is visible and gets retried on next start
|
|
2677
|
+
# instead of being declared done forever.
|
|
2678
|
+
_remaining = _SELF_HEAL_STATUS.get("null_remaining")
|
|
2511
2679
|
_SELF_HEAL_STATUS["finished_at"] = _t.time()
|
|
2512
|
-
|
|
2680
|
+
if _remaining is None:
|
|
2681
|
+
_SELF_HEAL_STATUS["state"] = "complete"
|
|
2682
|
+
elif int(_remaining) > 0:
|
|
2683
|
+
_SELF_HEAL_STATUS["state"] = "incomplete"
|
|
2684
|
+
_SELF_HEAL_STATUS["incomplete_reason"] = (
|
|
2685
|
+
f"{int(_remaining)} memories still have no meaning "
|
|
2686
|
+
f"vector, so they cannot be found by asking a question. "
|
|
2687
|
+
f"This retries automatically next time the service "
|
|
2688
|
+
f"starts. It usually means the embedding model was "
|
|
2689
|
+
f"unavailable."
|
|
2690
|
+
)
|
|
2691
|
+
logger.warning(
|
|
2692
|
+
"Self-heal INCOMPLETE: %d facts still unembedded and "
|
|
2693
|
+
"therefore unreachable by meaning; will retry on next "
|
|
2694
|
+
"start. %s", int(_remaining), _SELF_HEAL_STATUS,
|
|
2695
|
+
)
|
|
2696
|
+
else:
|
|
2697
|
+
_SELF_HEAL_STATUS["state"] = "complete"
|
|
2698
|
+
logger.info("Self-heal complete: %s", _SELF_HEAL_STATUS)
|
|
2513
2699
|
except Exception as exc:
|
|
2514
2700
|
_SELF_HEAL_STATUS["state"] = "error"
|
|
2515
2701
|
logger.warning("Self-heal failed (non-fatal): %s", exc)
|
|
@@ -2948,6 +3134,18 @@ async def lifespan(application: FastAPI):
|
|
|
2948
3134
|
await _cancel_fact_entity_association_repair(application)
|
|
2949
3135
|
await _cancel_source_quality_repair(application)
|
|
2950
3136
|
|
|
3137
|
+
# Stop the projection drain. Its queue is durable, so an interrupted pass
|
|
3138
|
+
# costs a repeat of idempotent work and nothing else — but a live worker
|
|
3139
|
+
# writing into RocksDB and Lance while the process tears down around it has
|
|
3140
|
+
# no upside.
|
|
3141
|
+
try:
|
|
3142
|
+
from superlocalmemory.core.backend_orchestrator import get_orchestrator
|
|
3143
|
+
_orch = get_orchestrator()
|
|
3144
|
+
if _orch is not None:
|
|
3145
|
+
_orch.stop()
|
|
3146
|
+
except Exception as exc: # pragma: no cover — defensive
|
|
3147
|
+
logger.warning("projection drain shutdown failed: %s", exc)
|
|
3148
|
+
|
|
2951
3149
|
# Cancel the cross-platform sync loop (H-CONC-2) so adapter file I/O does
|
|
2952
3150
|
# not outlive the daemon.
|
|
2953
3151
|
try:
|
|
@@ -3975,8 +4173,30 @@ def _register_dashboard_routes(application: FastAPI) -> None:
|
|
|
3975
4173
|
# v3.4.23: substitute version placeholder so the dashboard can detect
|
|
3976
4174
|
# upgrades and auto-reload. Read fresh each request (daemon uptime is
|
|
3977
4175
|
# days, but we want zero caching surprises during development).
|
|
3978
|
-
|
|
3979
|
-
|
|
4176
|
+
#
|
|
4177
|
+
# 4.0.10: asset ?v= strings are now derived from file content instead of
|
|
4178
|
+
# being hand-written literals that tracked nothing. See
|
|
4179
|
+
# server/asset_versions.py — including what that does and does not fix.
|
|
4180
|
+
# Asset versioning is cosmetic. It must never be why this page 500s.
|
|
4181
|
+
#
|
|
4182
|
+
# The import is deferred (house style, keeps startup lean), which means
|
|
4183
|
+
# it resolves at REQUEST time — so when `pip install -e .` replaced the
|
|
4184
|
+
# installed package underneath a running daemon, this route began
|
|
4185
|
+
# answering "Internal Server Error" on the dashboard while every other
|
|
4186
|
+
# endpoint was fine. A stale hand-written version string is a trifle; a
|
|
4187
|
+
# blank page is not. Fall back to the file as written.
|
|
4188
|
+
try:
|
|
4189
|
+
from superlocalmemory.server.asset_versions import render_index
|
|
4190
|
+
|
|
4191
|
+
return render_index(
|
|
4192
|
+
index_path, UI_DIR, substitutions={"__SLM_VERSION__": _SLM_VERSION},
|
|
4193
|
+
)
|
|
4194
|
+
except Exception as exc: # noqa: BLE001 — serve the page regardless
|
|
4195
|
+
logger.warning(
|
|
4196
|
+
"asset version rewrite unavailable, serving index.html as "
|
|
4197
|
+
"written: %s: %s", type(exc).__name__, exc,
|
|
4198
|
+
)
|
|
4199
|
+
return index_path.read_text().replace("__SLM_VERSION__", _SLM_VERSION)
|
|
3980
4200
|
|
|
3981
4201
|
@application.get("/favicon.ico", include_in_schema=False)
|
|
3982
4202
|
async def favicon():
|
|
@@ -4076,6 +4296,20 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
4076
4296
|
"embedding": embedding_ready,
|
|
4077
4297
|
"recall_health": recall_health.get("recall_healthy") is True,
|
|
4078
4298
|
"migration_failures": migration_failures,
|
|
4299
|
+
# WHY each one failed, not just which. The runner already produces
|
|
4300
|
+
# a precise sentence per migration -- "safe repair did not restore
|
|
4301
|
+
# M043_...", "schema verification failed ... : <sqlite error>" --
|
|
4302
|
+
# and this endpoint computed it and then dropped it on the floor.
|
|
4303
|
+
# A migration recorded ``complete`` in migration_log can still be
|
|
4304
|
+
# reported failed here, because a completed migration is re-checked
|
|
4305
|
+
# by its own verify() on every start; with only a name to go on,
|
|
4306
|
+
# that reads as the health check contradicting the database. It is
|
|
4307
|
+
# not: they are answering different questions, and this is the
|
|
4308
|
+
# sentence that says which. Reported as #125.
|
|
4309
|
+
"migration_failure_reasons": {
|
|
4310
|
+
name: str(migration_details.get(name, "(no detail recorded)"))
|
|
4311
|
+
for name in migration_failures
|
|
4312
|
+
},
|
|
4079
4313
|
}
|
|
4080
4314
|
readiness["retrieval"] = bool(
|
|
4081
4315
|
readiness["embedding"] and readiness["recall_health"]
|
|
@@ -4146,7 +4380,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
4146
4380
|
"runtime_state": runtime_state,
|
|
4147
4381
|
"active_profile": profile_snapshot.profile_id,
|
|
4148
4382
|
"profile_generation": profile_snapshot.generation,
|
|
4149
|
-
#
|
|
4383
|
+
# operational failure counts (visible to all team members)
|
|
4150
4384
|
**_ops_failure_counts(engine, application),
|
|
4151
4385
|
# issue #107: does this daemon's *imported* code still match the
|
|
4152
4386
|
# installed distribution? ``version`` above reports what this
|
|
@@ -4154,6 +4388,12 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
4154
4388
|
# reveal staleness on its own. Loopback-only, alongside the other
|
|
4155
4389
|
# operational metadata.
|
|
4156
4390
|
"version_integrity": _version_integrity_payload(),
|
|
4391
|
+
# How far behind the second graph store is. A drain that stops
|
|
4392
|
+
# advancing is the failure that does not announce itself: every
|
|
4393
|
+
# other signal here stays green while the graph quietly diverges
|
|
4394
|
+
# from the record, and the only symptom is answers that are subtly
|
|
4395
|
+
# worse. A depth that does not fall is the thing to alert on.
|
|
4396
|
+
"projection": _projection_health(),
|
|
4157
4397
|
}
|
|
4158
4398
|
|
|
4159
4399
|
@application.get("/recall")
|
|
@@ -4513,6 +4753,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
4513
4753
|
shared_with=tuple(shared_with or ()),
|
|
4514
4754
|
trusted_actor_id=trusted_actor_id,
|
|
4515
4755
|
session_id=req.session_id,
|
|
4756
|
+
session_date=req.session_date,
|
|
4516
4757
|
)
|
|
4517
4758
|
actor = Actor(
|
|
4518
4759
|
principal_id=trusted_actor_id,
|
|
@@ -4807,6 +5048,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
4807
5048
|
fact_count = 0
|
|
4808
5049
|
entity_count = 0
|
|
4809
5050
|
edge_count = 0
|
|
5051
|
+
projection_queue_depth = 0
|
|
4810
5052
|
if engine is not None:
|
|
4811
5053
|
try:
|
|
4812
5054
|
fact_count = engine._db.get_fact_count(profile_snapshot.profile_id)
|
|
@@ -4823,6 +5065,11 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
4823
5065
|
edge_count = int(dict(edges[0])["c"]) if edges else 0
|
|
4824
5066
|
except Exception:
|
|
4825
5067
|
logger.debug("daemon status count query failed", exc_info=True)
|
|
5068
|
+
try:
|
|
5069
|
+
from superlocalmemory.storage import projection_outbox
|
|
5070
|
+
projection_queue_depth = projection_outbox.depth(engine._db)
|
|
5071
|
+
except Exception:
|
|
5072
|
+
logger.debug("projection queue depth unavailable", exc_info=True)
|
|
4826
5073
|
db_path = getattr(config, "db_path", None)
|
|
4827
5074
|
db_size_mb = (
|
|
4828
5075
|
round(db_path.stat().st_size / 1024 / 1024, 2)
|
|
@@ -4848,6 +5095,10 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
4848
5095
|
"legacy_port": _LEGACY_PORT,
|
|
4849
5096
|
"profile": profile_snapshot.profile_id,
|
|
4850
5097
|
"profile_generation": profile_snapshot.generation,
|
|
5098
|
+
# Facts stored but not yet in the graph and vector projections. The
|
|
5099
|
+
# CLI and MCP read their own status from here, so this is where the
|
|
5100
|
+
# number has to be for all three surfaces to agree.
|
|
5101
|
+
"projection_queue_depth": projection_queue_depth,
|
|
4851
5102
|
# F2 fix: expose M028 backfill progress so operators can monitor
|
|
4852
5103
|
# the post-upgrade fact/entity association repair state.
|
|
4853
5104
|
"m028_backfill": getattr(
|
|
@@ -4859,7 +5110,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
4859
5110
|
# index backfill after an upgrade). Dashboard renders a plain
|
|
4860
5111
|
# "Optimizing memory…" line from this. Defaults to idle before start.
|
|
4861
5112
|
"self_heal": globals().get("_SELF_HEAL_STATUS", {"state": "idle"}),
|
|
4862
|
-
#
|
|
5113
|
+
# operational failure counts (dead-letter, degraded, stalled)
|
|
4863
5114
|
**_ops_failure_counts(engine, application),
|
|
4864
5115
|
}
|
|
4865
5116
|
|
|
@@ -4913,7 +5164,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
|
|
|
4913
5164
|
return {"status": "started"}
|
|
4914
5165
|
|
|
4915
5166
|
# ------------------------------------------------------------------
|
|
4916
|
-
#
|
|
5167
|
+
# Operational Recovery & Admin Remediation (V4 resilience slice)
|
|
4917
5168
|
# ------------------------------------------------------------------
|
|
4918
5169
|
|
|
4919
5170
|
@application.get("/operations/failed")
|
|
@@ -5459,8 +5710,40 @@ def _terminalize_orphan_operation(engine, operation_id: str) -> None:
|
|
|
5459
5710
|
)
|
|
5460
5711
|
|
|
5461
5712
|
|
|
5713
|
+
def _projection_health() -> dict:
|
|
5714
|
+
"""Queue depth, stall count, and whether the worker is running.
|
|
5715
|
+
|
|
5716
|
+
Never raises: a health endpoint that fails because one of its fields could
|
|
5717
|
+
not be computed is worse than the missing field.
|
|
5718
|
+
|
|
5719
|
+
Takes no application. It used to accept one and never read it -- the
|
|
5720
|
+
orchestrator is a process singleton -- which made the signature claim a
|
|
5721
|
+
dependency the body did not have, and made a test that handed it a broken
|
|
5722
|
+
application look like it was exercising the failure path when it was only
|
|
5723
|
+
observing whatever the process had already built. Compare
|
|
5724
|
+
``_ops_failure_counts`` directly below, which takes an application because it
|
|
5725
|
+
genuinely reads one.
|
|
5726
|
+
"""
|
|
5727
|
+
try:
|
|
5728
|
+
from superlocalmemory.core.backend_orchestrator import get_orchestrator
|
|
5729
|
+
|
|
5730
|
+
orchestrator = get_orchestrator()
|
|
5731
|
+
if orchestrator is None:
|
|
5732
|
+
return {"available": False}
|
|
5733
|
+
health = dict(orchestrator.outbox_health())
|
|
5734
|
+
health["available"] = True
|
|
5735
|
+
# One boolean an alert can key on without knowing what a healthy depth
|
|
5736
|
+
# looks like on this store.
|
|
5737
|
+
health["behind"] = bool(health.get("depth", 0)) or bool(
|
|
5738
|
+
health.get("stalled", 0)
|
|
5739
|
+
)
|
|
5740
|
+
return health
|
|
5741
|
+
except Exception as exc: # noqa: BLE001
|
|
5742
|
+
return {"available": False, "error": str(exc)[:120]}
|
|
5743
|
+
|
|
5744
|
+
|
|
5462
5745
|
def _ops_failure_counts(engine, application) -> dict:
|
|
5463
|
-
"""Return
|
|
5746
|
+
"""Return operational failure counts for /status and /health.
|
|
5464
5747
|
|
|
5465
5748
|
Always returns a dict (never raises). Counts default to 0 on any error.
|
|
5466
5749
|
Includes: dead_letter_count, degraded_operations, exhausted_obligations,
|
|
@@ -153,6 +153,19 @@ from superlocalmemory.storage.migrations import (
|
|
|
153
153
|
from superlocalmemory.storage.migrations import (
|
|
154
154
|
M042_correction_case_ledger as _M042,
|
|
155
155
|
)
|
|
156
|
+
from superlocalmemory.storage.migrations import (
|
|
157
|
+
M043_quarantine_display_summaries as _M043,
|
|
158
|
+
)
|
|
159
|
+
from superlocalmemory.storage.migrations import (
|
|
160
|
+
M044_play_carries_its_own_evidence as _M044,
|
|
161
|
+
)
|
|
162
|
+
from superlocalmemory.storage.migrations import (
|
|
163
|
+
M045_fact_outcome_score as _M045,
|
|
164
|
+
M046_prospective_memory_has_its_own_name as _M046,
|
|
165
|
+
M047_fisher_vectors_are_stored_like_every_other_vector as _M047,
|
|
166
|
+
M048_upcoming_holds_only_what_is_upcoming as _M048,
|
|
167
|
+
M049_a_schema_version_marker_is_one_row as _M049,
|
|
168
|
+
)
|
|
156
169
|
|
|
157
170
|
# Emit under the runner's logger name so operational log filters that key on
|
|
158
171
|
# "superlocalmemory.storage.migration_runner" keep matching after this split.
|
|
@@ -203,6 +216,13 @@ _MODULES = {
|
|
|
203
216
|
_M040.NAME: _M040,
|
|
204
217
|
_M041.NAME: _M041,
|
|
205
218
|
_M042.NAME: _M042,
|
|
219
|
+
_M043.NAME: _M043,
|
|
220
|
+
_M044.NAME: _M044,
|
|
221
|
+
_M045.NAME: _M045,
|
|
222
|
+
_M046.NAME: _M046,
|
|
223
|
+
_M047.NAME: _M047,
|
|
224
|
+
_M048.NAME: _M048,
|
|
225
|
+
_M049.NAME: _M049,
|
|
206
226
|
}
|
|
207
227
|
|
|
208
228
|
# Exact historical DDL fingerprints whose resulting schema is intentionally
|
|
@@ -241,9 +261,24 @@ def _ddl_hash(ddl: str) -> str:
|
|
|
241
261
|
return hashlib.sha256(ddl.encode("utf-8")).hexdigest()
|
|
242
262
|
|
|
243
263
|
|
|
264
|
+
#: How long a migration waits for a database another process is holding.
|
|
265
|
+
#:
|
|
266
|
+
#: Without this, SQLite raises SQLITE_BUSY the instant a write lock is taken,
|
|
267
|
+
#: and a migration that runs while the daemon happens to be writing is recorded
|
|
268
|
+
#: as failed rather than retried. A migration that rebuilds a table takes an
|
|
269
|
+
#: exclusive lock, so it is exactly the one most likely to collide — and its
|
|
270
|
+
#: failure leaves an upgrade wedged until someone notices.
|
|
271
|
+
#:
|
|
272
|
+
#: Fifteen seconds is longer than any single write this codebase performs and
|
|
273
|
+
#: short enough that a genuinely stuck lock still surfaces as a failure rather
|
|
274
|
+
#: than a hang.
|
|
275
|
+
_MIGRATION_BUSY_TIMEOUT_MS = 15_000
|
|
276
|
+
|
|
277
|
+
|
|
244
278
|
def _connect(db_path: Path) -> sqlite3.Connection:
|
|
245
279
|
# isolation_level=None → we manage transactions explicitly via DDL.
|
|
246
280
|
conn = sqlite3.connect(db_path, isolation_level=None)
|
|
281
|
+
conn.execute(f"PRAGMA busy_timeout = {_MIGRATION_BUSY_TIMEOUT_MS};")
|
|
247
282
|
conn.execute("PRAGMA foreign_keys = OFF;")
|
|
248
283
|
return conn
|
|
249
284
|
|
|
@@ -20,10 +20,31 @@ from __future__ import annotations
|
|
|
20
20
|
import sqlite3
|
|
21
21
|
from pathlib import Path
|
|
22
22
|
|
|
23
|
-
#: Highest schema_version this runner can write. Matches the trailing serial
|
|
24
|
-
#:
|
|
23
|
+
#: Highest schema_version this runner can write. Matches the trailing serial of
|
|
24
|
+
#: the latest migration (M049). Increment when adding new migrations or
|
|
25
25
|
#: table-level breaking changes.
|
|
26
|
-
|
|
26
|
+
#:
|
|
27
|
+
#: This sat at 42 while M043, M044 and M045 shipped, so for three migrations the
|
|
28
|
+
#: ceiling did not move and nothing prevented an older build from opening a
|
|
29
|
+
#: newer store. That was tolerable only because those three were additive: a
|
|
30
|
+
#: build that did not know about a new column or table simply never read it.
|
|
31
|
+
#:
|
|
32
|
+
#: M046 is not additive. It rebuilds ``atomic_facts`` with a constraint that
|
|
33
|
+
#: rejects the value an older build classifies planned events as, so an older
|
|
34
|
+
#: writer against a migrated store fails its INSERT. The ceiling is what turns
|
|
35
|
+
#: that from a lost memory into a refusal to start, which is why it moves here
|
|
36
|
+
#: and why it moves to the trailing serial rather than to 43.
|
|
37
|
+
#:
|
|
38
|
+
#: M049 is additive — a unique index on ``schema_version`` plus the removal of
|
|
39
|
+
#: the duplicate rows that index could not otherwise be created over — so an
|
|
40
|
+
#: older build could in principle read a store it has touched. The ceiling
|
|
41
|
+
#: still moves, because the convention is "trailing serial, always": the cost of
|
|
42
|
+
#: moving it for an additive migration is an older build declining a store it
|
|
43
|
+
#: could have read, and the cost of NOT moving it for one that turns out not to
|
|
44
|
+
#: be additive is a silent bad write. Those are not comparable, and judging
|
|
45
|
+
#: additivity per migration is exactly the judgement that let it fall three
|
|
46
|
+
#: behind.
|
|
47
|
+
SUPPORTED_SCHEMA_VERSION: int = 49
|
|
27
48
|
|
|
28
49
|
|
|
29
50
|
class SchemaVersionError(RuntimeError):
|