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
|
@@ -416,6 +416,29 @@ class VectorStore:
|
|
|
416
416
|
logger.debug("upsert failed for fact_id=%s: %s", fact_id, exc)
|
|
417
417
|
return False
|
|
418
418
|
|
|
419
|
+
def _has_quarantine_column(self) -> bool:
|
|
420
|
+
"""Whether atomic_facts carries ``quarantined`` in this database.
|
|
421
|
+
|
|
422
|
+
Cached once True; re-probed while absent so a schema pass that lands
|
|
423
|
+
later is picked up. Mirrors DatabaseManager._has_quarantine_column --
|
|
424
|
+
an unmigrated store must degrade to the old query rather than raise on
|
|
425
|
+
every semantic search.
|
|
426
|
+
"""
|
|
427
|
+
cached = getattr(self, "_quarantine_col", None)
|
|
428
|
+
if cached is True:
|
|
429
|
+
return True
|
|
430
|
+
try:
|
|
431
|
+
with self._managed_connection() as conn:
|
|
432
|
+
present = any(
|
|
433
|
+
row[1] == "quarantined"
|
|
434
|
+
for row in conn.execute("PRAGMA table_info(atomic_facts)")
|
|
435
|
+
)
|
|
436
|
+
except Exception: # noqa: BLE001 -- a probe must never break search
|
|
437
|
+
return False
|
|
438
|
+
if present:
|
|
439
|
+
self._quarantine_col = True
|
|
440
|
+
return present
|
|
441
|
+
|
|
419
442
|
def search(
|
|
420
443
|
self,
|
|
421
444
|
query_embedding: list[float],
|
|
@@ -439,6 +462,42 @@ class VectorStore:
|
|
|
439
462
|
with self._managed_connection() as conn:
|
|
440
463
|
if top_k <= 0:
|
|
441
464
|
return []
|
|
465
|
+
# A withheld fact must not occupy a nearest-neighbour slot.
|
|
466
|
+
#
|
|
467
|
+
# Its vector stays in the index — quarantine is reversible and
|
|
468
|
+
# deleting the projection would cost a re-embed to undo — so it
|
|
469
|
+
# is filtered here instead.
|
|
470
|
+
#
|
|
471
|
+
# Measured on the author's store: 1,192 of 5,086 projections
|
|
472
|
+
# (23.4%) belong to withheld rows, and because model-written
|
|
473
|
+
# summaries of the same clusters land close together in
|
|
474
|
+
# embedding space they crowd each other. Searching with a vector
|
|
475
|
+
# taken from one of them returned 50 of 50 neighbours withheld
|
|
476
|
+
# without this join, and 0 of 50 with it. So for any query near
|
|
477
|
+
# that cluster the semantic channel was contributing nothing at
|
|
478
|
+
# all — every slot spent on a candidate that hydration would
|
|
479
|
+
# discard — while looking like it had answered.
|
|
480
|
+
#
|
|
481
|
+
# The expansion loop below already exists for exactly this
|
|
482
|
+
# shape of problem (orphaned vec0 rows losing a slot to the
|
|
483
|
+
# relational join) and compensates automatically: it doubles k
|
|
484
|
+
# until top_k surviving pairs are found. Nothing new is needed
|
|
485
|
+
# to make the slots back.
|
|
486
|
+
# LEFT, not INNER. An inner join would make a projection
|
|
487
|
+
# depend on its corpus row still existing, so a legacy orphan
|
|
488
|
+
# metadata row would stop being returned at all -- a behaviour
|
|
489
|
+
# change well beyond quarantine, and one the existing
|
|
490
|
+
# vector-store tests caught immediately by building a store
|
|
491
|
+
# with no matching facts. LEFT leaves af.quarantined NULL for a
|
|
492
|
+
# missing row, and COALESCE keeps it.
|
|
493
|
+
quarantine_join = (
|
|
494
|
+
" LEFT JOIN atomic_facts AS af ON af.fact_id = em.fact_id "
|
|
495
|
+
if self._has_quarantine_column() else ""
|
|
496
|
+
)
|
|
497
|
+
quarantine_filter = (
|
|
498
|
+
" AND COALESCE(af.quarantined, 0) = 0 "
|
|
499
|
+
if self._has_quarantine_column() else ""
|
|
500
|
+
)
|
|
442
501
|
if profile_id is not None:
|
|
443
502
|
sql = (
|
|
444
503
|
"SELECT fe.rowid, fe.distance, em.fact_id "
|
|
@@ -446,8 +505,10 @@ class VectorStore:
|
|
|
446
505
|
"JOIN embedding_metadata AS em "
|
|
447
506
|
"ON em.vec_rowid = fe.rowid "
|
|
448
507
|
"AND em.profile_id = fe.profile_id "
|
|
508
|
+
+ quarantine_join +
|
|
449
509
|
"WHERE fe.embedding MATCH ? "
|
|
450
510
|
"AND fe.profile_id = ? "
|
|
511
|
+
+ quarantine_filter +
|
|
451
512
|
"AND fe.k = ?"
|
|
452
513
|
)
|
|
453
514
|
base_params: tuple[object, ...] = (vec_bytes, profile_id)
|
|
@@ -460,7 +521,9 @@ class VectorStore:
|
|
|
460
521
|
"JOIN embedding_metadata AS em "
|
|
461
522
|
"ON em.vec_rowid = fe.rowid "
|
|
462
523
|
"AND em.profile_id = fe.profile_id "
|
|
524
|
+
+ quarantine_join +
|
|
463
525
|
"WHERE fe.embedding MATCH ? "
|
|
526
|
+
+ quarantine_filter +
|
|
464
527
|
"AND fe.k = ?"
|
|
465
528
|
)
|
|
466
529
|
base_params = (vec_bytes,)
|
|
@@ -39,6 +39,7 @@ from fastapi.middleware.gzip import GZipMiddleware
|
|
|
39
39
|
from pydantic import BaseModel
|
|
40
40
|
import uvicorn
|
|
41
41
|
|
|
42
|
+
from superlocalmemory.core.config import CANONICAL_RECALL_LIMIT
|
|
42
43
|
from superlocalmemory.server.security_middleware import SecurityHeadersMiddleware
|
|
43
44
|
from superlocalmemory.server.routes.helpers import SLM_VERSION
|
|
44
45
|
from superlocalmemory.infra.data_root import DynamicStatePath
|
|
@@ -60,7 +61,7 @@ UI_DIR = Path(__file__).resolve().parent.parent / "ui"
|
|
|
60
61
|
|
|
61
62
|
class SearchRequest(BaseModel):
|
|
62
63
|
query: str
|
|
63
|
-
limit: int =
|
|
64
|
+
limit: int = CANONICAL_RECALL_LIMIT
|
|
64
65
|
min_score: float = 0.3
|
|
65
66
|
|
|
66
67
|
|
|
@@ -246,7 +247,30 @@ def create_app() -> FastAPI:
|
|
|
246
247
|
"<p><a href='/docs'>API Documentation</a></p>"
|
|
247
248
|
"</body></html>"
|
|
248
249
|
)
|
|
249
|
-
|
|
250
|
+
from superlocalmemory import __version__ as _v
|
|
251
|
+
|
|
252
|
+
# __SLM_VERSION__ was substituted only by the unified daemon, so the
|
|
253
|
+
# dashboard's upgrade detector did nothing when served from here.
|
|
254
|
+
# Asset versioning is cosmetic. It must never be why this page 500s.
|
|
255
|
+
#
|
|
256
|
+
# The import is deferred (house style, keeps startup lean), which means
|
|
257
|
+
# it resolves at REQUEST time — so when `pip install -e .` replaced the
|
|
258
|
+
# installed package underneath a running daemon, this route began
|
|
259
|
+
# answering "Internal Server Error" on the dashboard while every other
|
|
260
|
+
# endpoint was fine. A stale hand-written version string is a trifle; a
|
|
261
|
+
# blank page is not. Fall back to the file as written.
|
|
262
|
+
try:
|
|
263
|
+
from superlocalmemory.server.asset_versions import render_index
|
|
264
|
+
|
|
265
|
+
return render_index(
|
|
266
|
+
index_path, UI_DIR, substitutions={"__SLM_VERSION__": _v},
|
|
267
|
+
)
|
|
268
|
+
except Exception as exc: # noqa: BLE001 — serve the page regardless
|
|
269
|
+
logger.warning(
|
|
270
|
+
"asset version rewrite unavailable, serving index.html as "
|
|
271
|
+
"written: %s: %s", type(exc).__name__, exc,
|
|
272
|
+
)
|
|
273
|
+
return index_path.read_text().replace("__SLM_VERSION__", _v)
|
|
250
274
|
|
|
251
275
|
@application.get("/health")
|
|
252
276
|
async def health_check():
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
|
|
4
|
+
"""Derive the dashboard's asset cache-busters from the files themselves.
|
|
5
|
+
|
|
6
|
+
``index.html`` referenced 64 static assets. 35 carried a hand-written
|
|
7
|
+
``?v=`` literal and 29 carried nothing, and not one of them was derived from the
|
|
8
|
+
file it pointed at. So the version strings looked like cache-busting and were
|
|
9
|
+
not: editing a JS or CSS file left its ``?v=`` reading whatever the last person
|
|
10
|
+
typed, which during 4.0.10 was ``022ff653`` on a file that had changed.
|
|
11
|
+
|
|
12
|
+
WHAT THIS DOES AND DOES NOT FIX
|
|
13
|
+
-------------------------------
|
|
14
|
+
It does not fix a live user-facing bug, and it would be dishonest to claim it
|
|
15
|
+
does. Three mechanisms already stop a stale asset reaching a browser on this
|
|
16
|
+
server, and the first was verified against the running daemon rather than read:
|
|
17
|
+
|
|
18
|
+
* ``/static/*`` is served ``Cache-Control: no-cache, must-revalidate`` with an
|
|
19
|
+
ETag (``server/security_middleware.py``), so a browser must revalidate and
|
|
20
|
+
cannot serve a cached copy without asking.
|
|
21
|
+
* The unified daemon copies the whole UI tree into the data directory on every
|
|
22
|
+
start (``unified_daemon.py``, ``state_path("ui")``), so an upgrade refreshes
|
|
23
|
+
the files it serves.
|
|
24
|
+
* ``index.html`` itself is ``no-cache``, so the page is always re-read.
|
|
25
|
+
|
|
26
|
+
What it fixes is a **trap**, and unblocks a real improvement:
|
|
27
|
+
|
|
28
|
+
* 64 references, none tracking content. Anyone reading them concludes
|
|
29
|
+
cache-busting is handled here, which is how the 4.0.10 change shipped with a
|
|
30
|
+
stale literal and how the next one would too. A number that is maintained by
|
|
31
|
+
hand and consulted by nobody is worse than no number.
|
|
32
|
+
* The revalidation policy is the only thing making that safe, and it costs a
|
|
33
|
+
conditional request per asset on every page load — 64 of them. The obvious
|
|
34
|
+
optimisation is ``max-age`` with a long life, and today that change would
|
|
35
|
+
turn every hand-typed literal into an immediate live bug. With versions
|
|
36
|
+
derived from content it becomes safe to make. That policy change is NOT
|
|
37
|
+
made here; it needs its own measurement.
|
|
38
|
+
* A proxy or CDN that ignores ``no-cache`` is defeated by a URL that changes,
|
|
39
|
+
not by a header.
|
|
40
|
+
|
|
41
|
+
DESIGN
|
|
42
|
+
------
|
|
43
|
+
Rewrite at serve time rather than at build time, because there is no build step:
|
|
44
|
+
the UI ships as source files and is copied into place. The hash is computed from
|
|
45
|
+
file bytes and cached on ``(size, mtime_ns)``, so a warm daemon does one ``stat``
|
|
46
|
+
per asset per page load and no reads. Unresolvable references keep whatever the
|
|
47
|
+
HTML said, so a missing file or an odd path degrades to today's behaviour rather
|
|
48
|
+
than breaking the page.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
from __future__ import annotations
|
|
52
|
+
|
|
53
|
+
import hashlib
|
|
54
|
+
import logging
|
|
55
|
+
import re
|
|
56
|
+
from pathlib import Path
|
|
57
|
+
|
|
58
|
+
logger = logging.getLogger(__name__)
|
|
59
|
+
|
|
60
|
+
__all__ = ["render_index", "asset_version", "rewrite_asset_versions"]
|
|
61
|
+
|
|
62
|
+
#: Characters of hex digest used in a URL. Eight is what the existing literals
|
|
63
|
+
#: used and is ample: these identify one file's revisions, not a global
|
|
64
|
+
#: namespace, so a collision needs two versions of the same file agreeing on
|
|
65
|
+
#: eight hex characters.
|
|
66
|
+
_HASH_CHARS = 8
|
|
67
|
+
|
|
68
|
+
#: ``src="static/…"`` / ``href="static/…"`` with an optional existing ``?v=``.
|
|
69
|
+
#: Deliberately narrow — only the ``static/`` prefix the dashboard mounts, only
|
|
70
|
+
#: double-quoted attributes, and the path is captured without its query so the
|
|
71
|
+
#: rewrite cannot alter it.
|
|
72
|
+
_ASSET_REF = re.compile(
|
|
73
|
+
r'(?P<attr>\b(?:src|href)=")'
|
|
74
|
+
r'(?P<path>static/[^"?#]+)'
|
|
75
|
+
r'(?P<query>\?[^"#]*)?'
|
|
76
|
+
r'(?P<fragment>#[^"]*)?'
|
|
77
|
+
r'(?P<close>")'
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
#: (resolved path) -> (size, mtime_ns, digest). Keyed on the path so a daemon
|
|
81
|
+
#: serving from the data-directory copy and one serving from the source tree do
|
|
82
|
+
#: not share entries.
|
|
83
|
+
_CACHE: dict[Path, tuple[int, int, str]] = {}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def asset_version(asset_path: Path) -> str | None:
|
|
87
|
+
"""Short content hash of ``asset_path``, or None if it cannot be read.
|
|
88
|
+
|
|
89
|
+
Cached on ``(size, mtime_ns)``. That pair is what ETag generators use for
|
|
90
|
+
the same reason: it changes on every practical edit, and re-reading a file
|
|
91
|
+
that has not changed costs a page-load's worth of I/O for nothing.
|
|
92
|
+
|
|
93
|
+
``mtime_ns`` rather than ``mtime``: the UI is installed with
|
|
94
|
+
``shutil.copytree``, which preserves timestamps, so two files written inside
|
|
95
|
+
the same filesystem tick are a real possibility on a fast copy.
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
stat = asset_path.stat()
|
|
99
|
+
except OSError:
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
key = (stat.st_size, stat.st_mtime_ns)
|
|
103
|
+
cached = _CACHE.get(asset_path)
|
|
104
|
+
if cached is not None and cached[:2] == key:
|
|
105
|
+
return cached[2]
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
digest = hashlib.sha256(asset_path.read_bytes()).hexdigest()[:_HASH_CHARS]
|
|
109
|
+
except OSError as exc:
|
|
110
|
+
logger.debug("asset version unavailable for %s: %s", asset_path, exc)
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
_CACHE[asset_path] = (*key, digest)
|
|
114
|
+
return digest
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def rewrite_asset_versions(html: str, ui_root: Path) -> str:
|
|
118
|
+
"""Replace every ``static/…?v=`` with a version derived from the file.
|
|
119
|
+
|
|
120
|
+
``ui_root`` is the directory mounted at ``/static``, so a reference to
|
|
121
|
+
``static/js/core.js`` resolves to ``ui_root/js/core.js`` — the ``static/``
|
|
122
|
+
segment is the mount point, not a directory on disk. Getting that wrong
|
|
123
|
+
silently resolves nothing and leaves all 64 literals in place, which is why
|
|
124
|
+
the test asserts a version actually moved rather than only that the call
|
|
125
|
+
returned.
|
|
126
|
+
|
|
127
|
+
Assets with no existing query gain one. That is a URL change, and it is the
|
|
128
|
+
point: 29 of the 64 references had no cache-buster at all, so they were the
|
|
129
|
+
ones a policy change would break first.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
def _replace(match: re.Match[str]) -> str:
|
|
133
|
+
path = match.group("path")
|
|
134
|
+
version = asset_version(ui_root / path[len("static/"):])
|
|
135
|
+
if version is None:
|
|
136
|
+
# Keep whatever the HTML said. A reference we cannot resolve is not
|
|
137
|
+
# a reason to serve a page that cannot load its own stylesheet.
|
|
138
|
+
return match.group(0)
|
|
139
|
+
return (
|
|
140
|
+
f"{match.group('attr')}{path}?v={version}"
|
|
141
|
+
f"{match.group('fragment') or ''}{match.group('close')}"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return _ASSET_REF.sub(_replace, html)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def render_index(
|
|
148
|
+
index_path: Path,
|
|
149
|
+
ui_root: Path | None = None,
|
|
150
|
+
*,
|
|
151
|
+
substitutions: dict[str, str] | None = None,
|
|
152
|
+
) -> str:
|
|
153
|
+
"""Read ``index.html`` and prepare it for serving.
|
|
154
|
+
|
|
155
|
+
One function for the three ``root()`` handlers (``api.py``, ``ui.py``,
|
|
156
|
+
``unified_daemon.py``) that each read this file and returned it. They had
|
|
157
|
+
drifted: only the daemon substituted ``__SLM_VERSION__``, so the upgrade
|
|
158
|
+
detector the dashboard relies on silently did nothing on the other two.
|
|
159
|
+
|
|
160
|
+
``ui_root`` defaults to the file's own directory, which is correct for every
|
|
161
|
+
caller today — ``index.html`` sits at the root of the tree mounted at
|
|
162
|
+
``/static``.
|
|
163
|
+
|
|
164
|
+
Raises ``OSError`` if the index itself cannot be read; every caller already
|
|
165
|
+
checks ``exists()`` and has its own fallback page.
|
|
166
|
+
"""
|
|
167
|
+
html = index_path.read_text()
|
|
168
|
+
html = rewrite_asset_versions(html, ui_root or index_path.parent)
|
|
169
|
+
for placeholder, value in (substitutions or {}).items():
|
|
170
|
+
html = html.replace(placeholder, value)
|
|
171
|
+
return html
|
|
@@ -68,19 +68,35 @@ async def _reward_proxy_loop(
|
|
|
68
68
|
interval_sec: float,
|
|
69
69
|
) -> None:
|
|
70
70
|
"""Run the proxy settler on a steady interval. Never raises."""
|
|
71
|
+
from superlocalmemory.learning.reward_from_outcomes import (
|
|
72
|
+
settle_from_outcomes,
|
|
73
|
+
)
|
|
71
74
|
from superlocalmemory.learning.reward_proxy import settle_stale_plays
|
|
72
75
|
|
|
73
76
|
while True:
|
|
74
77
|
try:
|
|
75
78
|
await asyncio.sleep(interval_sec)
|
|
79
|
+
# Reported outcomes FIRST. The proxy defaults a play once its
|
|
80
|
+
# window closes; if it ran first it would claim every play and a
|
|
81
|
+
# real outcome arriving later would find nothing to settle.
|
|
82
|
+
real = await asyncio.to_thread(
|
|
83
|
+
settle_from_outcomes,
|
|
84
|
+
profile_id, learning_db, memory_db,
|
|
85
|
+
)
|
|
76
86
|
# The settler is synchronous + fast; run in a thread to avoid
|
|
77
87
|
# blocking the event loop on unusual DB lock stalls.
|
|
78
88
|
n = await asyncio.to_thread(
|
|
79
89
|
settle_stale_plays,
|
|
80
90
|
profile_id, learning_db, memory_db,
|
|
81
91
|
)
|
|
92
|
+
# INFO, not DEBUG, when a real outcome moved an arm: this is the
|
|
93
|
+
# only line that distinguishes a learning loop that is running
|
|
94
|
+
# from one that merely starts. Its absence for a month is what
|
|
95
|
+
# made this defect invisible.
|
|
96
|
+
if real:
|
|
97
|
+
logger.info("bandit settled %d play(s) from outcomes", real)
|
|
82
98
|
if n:
|
|
83
|
-
logger.debug("bandit.reward_proxy settled=%d", n)
|
|
99
|
+
logger.debug("bandit.reward_proxy settled=%d (default)", n)
|
|
84
100
|
except asyncio.CancelledError: # pragma: no cover — lifecycle
|
|
85
101
|
raise
|
|
86
102
|
except Exception as exc: # pragma: no cover — defensive
|
|
@@ -27,6 +27,9 @@ from typing import Any
|
|
|
27
27
|
from fastapi import HTTPException, Request
|
|
28
28
|
|
|
29
29
|
from superlocalmemory.access.rbac import Permission, Role, permissions_for_role
|
|
30
|
+
import logging
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
30
33
|
|
|
31
34
|
_SESSION_HEADER = "X-SLM-User-Session"
|
|
32
35
|
_SESSION_COOKIE = "slm_session"
|
|
@@ -132,12 +135,29 @@ def resolve_actor_roles(request: Request, *, profile: str | None = None):
|
|
|
132
135
|
if rbac is not None:
|
|
133
136
|
try:
|
|
134
137
|
role = rbac.get_role(principal["user_id"], profile or _active_profile())
|
|
135
|
-
except Exception:
|
|
136
|
-
#
|
|
137
|
-
#
|
|
138
|
-
#
|
|
139
|
-
#
|
|
140
|
-
|
|
138
|
+
except Exception as exc: # noqa: BLE001
|
|
139
|
+
# A lookup that failed is not a lookup that said yes.
|
|
140
|
+
#
|
|
141
|
+
# This used to return MEMBER, on the reasoning that the caller had
|
|
142
|
+
# already passed a coarser permission check so a transient database
|
|
143
|
+
# error should not deny an authorised write. The effect was that any
|
|
144
|
+
# error in the role lookup -- a write-lock timeout, a checkpoint, a
|
|
145
|
+
# corrupt page -- promoted a viewer to a role that can write, at
|
|
146
|
+
# exactly the moment the store was under stress. A caller able to
|
|
147
|
+
# provoke lock contention could provoke the promotion.
|
|
148
|
+
#
|
|
149
|
+
# "Ask again in a moment" is the honest answer and the one the
|
|
150
|
+
# caller can act on. It is neither a denial nor a grant.
|
|
151
|
+
from fastapi import HTTPException
|
|
152
|
+
|
|
153
|
+
logger.warning(
|
|
154
|
+
"rbac: the role for this caller could not be read (%s); "
|
|
155
|
+
"answering 503 rather than assuming one", exc,
|
|
156
|
+
)
|
|
157
|
+
raise HTTPException(
|
|
158
|
+
status_code=503,
|
|
159
|
+
detail="the workspace's roles are temporarily unreadable; retry",
|
|
160
|
+
) from exc
|
|
141
161
|
mapped = {
|
|
142
162
|
Role.ADMIN: ActorRole.ADMIN,
|
|
143
163
|
Role.MEMBER: ActorRole.MEMBER,
|
|
@@ -291,6 +291,10 @@ def recall_response_metadata(response: Any) -> dict:
|
|
|
291
291
|
"score_contract_version": getattr(response, "score_contract_version", "2"),
|
|
292
292
|
"calibration_status": getattr(response, "calibration_status", "uncalibrated"),
|
|
293
293
|
"calibration_id": getattr(response, "calibration_id", None),
|
|
294
|
+
# The name of this answer. A caller that reports back how the answer
|
|
295
|
+
# went can quote it, and the report then joins to this exact recall
|
|
296
|
+
# instead of being matched by overlapping memory ids.
|
|
297
|
+
"query_id": getattr(response, "query_id", "") or "",
|
|
294
298
|
"answer_confidence": getattr(response, "answer_confidence", None),
|
|
295
299
|
"abstained": bool(getattr(response, "abstained", False)),
|
|
296
300
|
"abstention_reason": getattr(response, "abstention_reason", None),
|
|
@@ -308,4 +312,9 @@ def recall_response_metadata(response: Any) -> dict:
|
|
|
308
312
|
"incomplete_channels": list(
|
|
309
313
|
getattr(response, "incomplete_channels", ()) or ()
|
|
310
314
|
),
|
|
315
|
+
# What became of every channel. Travels with the answer for the same
|
|
316
|
+
# reason as the field above: a caller comparing two runs, or an
|
|
317
|
+
# operator looking at a thin result set, otherwise cannot tell a store
|
|
318
|
+
# with nothing to say from a retrieval path that is partly down.
|
|
319
|
+
"channel_status": dict(getattr(response, "channel_status", {}) or {}),
|
|
311
320
|
}
|
|
@@ -9,6 +9,8 @@ down to source atoms:
|
|
|
9
9
|
|
|
10
10
|
GET /api/v3/abstraction/persona — the per-profile persona roll-up
|
|
11
11
|
GET /api/v3/abstraction/communities — community summaries (Q2)
|
|
12
|
+
GET /api/v3/abstraction/consolidated — display-only cluster summaries
|
|
13
|
+
GET /api/v3/abstraction/health — can my memories be found? (4.0.10)
|
|
12
14
|
GET /api/v3/abstraction/sources — drill-down (node -> source atoms)
|
|
13
15
|
|
|
14
16
|
Read-only, profile-scoped (Rule 01), direct sqlite3 (Rule 06). All handlers
|
|
@@ -30,6 +32,31 @@ logger = logging.getLogger(__name__)
|
|
|
30
32
|
|
|
31
33
|
router = APIRouter(prefix="/api/v3/abstraction", tags=["abstraction"])
|
|
32
34
|
|
|
35
|
+
#: How many summary rows /consolidated will read before ranking them by
|
|
36
|
+
#: quality. Bounded because this runs on a request thread: a store with tens of
|
|
37
|
+
#: thousands of summaries must not turn one card into a full-table scan.
|
|
38
|
+
_SCAN_CEILING = 400
|
|
39
|
+
|
|
40
|
+
#: Characters of normalised opening text that make two summaries "the same
|
|
41
|
+
#: summary" for display. Long enough that two genuinely different subjects
|
|
42
|
+
#: diverge within it, short enough to catch the same sentence with a different
|
|
43
|
+
#: tail — which is the shape the summarizer actually produces.
|
|
44
|
+
_OPENING_KEY_CHARS = 90
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _opening_key(content: object) -> str:
|
|
48
|
+
"""Normalised opening of a summary, for near-duplicate collapsing.
|
|
49
|
+
|
|
50
|
+
Case-folded with runs of whitespace flattened, so two summaries differing
|
|
51
|
+
only in line wrapping or capitalisation collapse together. Returns "" for
|
|
52
|
+
anything too short to judge, which is then never collapsed — better to show
|
|
53
|
+
a duplicate than to hide a distinct summary on a weak signal.
|
|
54
|
+
"""
|
|
55
|
+
text = " ".join(str(content or "").split()).casefold()
|
|
56
|
+
if len(text) < 40:
|
|
57
|
+
return ""
|
|
58
|
+
return text[:_OPENING_KEY_CHARS]
|
|
59
|
+
|
|
33
60
|
|
|
34
61
|
class _ReadDB:
|
|
35
62
|
"""Adapt a raw sqlite3 connection to the .execute(...) -> list contract
|
|
@@ -111,3 +138,177 @@ def get_sources(
|
|
|
111
138
|
return JSONResponse({"profile": pid, "sources": empty})
|
|
112
139
|
finally:
|
|
113
140
|
conn.close()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@router.get("/consolidated")
|
|
144
|
+
def get_consolidated(
|
|
145
|
+
profile: str = Query(""),
|
|
146
|
+
limit: int = Query(50, ge=1, le=200),
|
|
147
|
+
include_unusable: bool = Query(False),
|
|
148
|
+
) -> JSONResponse:
|
|
149
|
+
"""Cluster summaries, read from the DISPLAY table and nowhere else.
|
|
150
|
+
|
|
151
|
+
``consolidated_summaries`` is the only source. Reading ``atomic_facts``
|
|
152
|
+
here would put the boundary back where it was: these summaries were in the
|
|
153
|
+
retrieval corpus until 4.0.10 and the whole point of moving them is that
|
|
154
|
+
exactly one surface shows them, and it is this one.
|
|
155
|
+
|
|
156
|
+
``summaries`` holds only rows worth reading. The rest are REPORTED, not
|
|
157
|
+
returned: ``unusable`` and ``near_duplicates`` are counts over the scanned
|
|
158
|
+
window. A reader is better served by "62 of these came back empty" than by a
|
|
159
|
+
page that silently shows a handful and looks complete — and hiding the fact
|
|
160
|
+
that they came back empty would hide the problem this endpoint exists to make
|
|
161
|
+
visible. ``include_unusable=true`` returns them for inspection.
|
|
162
|
+
|
|
163
|
+
Two orderings, both measured rather than chosen:
|
|
164
|
+
|
|
165
|
+
* Ranking by ``source_count`` alone put the junk on top, because the
|
|
166
|
+
summaries merging the largest clusters are exactly the ones the model had
|
|
167
|
+
least in common to work with. On the author's store **0 of the top 24 by
|
|
168
|
+
source_count were usable**, so a card asking for 24 rendered empty against
|
|
169
|
+
a store holding a thousand summaries.
|
|
170
|
+
* Rows covering real memories rank above rows covering none. 353 of these
|
|
171
|
+
are summaries of summaries; their honest ``source_count`` is 0, and a
|
|
172
|
+
digest of the summarizer's own output is worth less to a reader than a
|
|
173
|
+
digest of their own words.
|
|
174
|
+
|
|
175
|
+
Quality is a Python predicate rather than a SQL expression, which is why the
|
|
176
|
+
window is read to at most ``_SCAN_CEILING`` rows, classified, and then
|
|
177
|
+
ordered.
|
|
178
|
+
"""
|
|
179
|
+
pid = profile or get_active_profile()
|
|
180
|
+
conn = _conn()
|
|
181
|
+
if conn is None:
|
|
182
|
+
return JSONResponse({
|
|
183
|
+
"profile": pid, "summaries": [], "unusable": 0, "scanned": 0,
|
|
184
|
+
})
|
|
185
|
+
try:
|
|
186
|
+
from superlocalmemory.summaries.base import clean_llm_summary
|
|
187
|
+
from superlocalmemory.summaries.non_answer import (
|
|
188
|
+
MIN_USEFUL_CHARS,
|
|
189
|
+
is_non_answer,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
scan = min(_SCAN_CEILING, max(int(limit) * 8, int(limit)))
|
|
193
|
+
rows = conn.execute(
|
|
194
|
+
"SELECT summary_id, entity_name, content, source_count, "
|
|
195
|
+
" generated_by, source_earliest, source_latest, created_at "
|
|
196
|
+
" FROM consolidated_summaries "
|
|
197
|
+
" WHERE profile_id = ? "
|
|
198
|
+
" ORDER BY source_count DESC, created_at DESC, summary_id ASC "
|
|
199
|
+
" LIMIT ?",
|
|
200
|
+
(pid, scan),
|
|
201
|
+
).fetchall()
|
|
202
|
+
|
|
203
|
+
classified: list[dict[str, Any]] = []
|
|
204
|
+
unusable = 0
|
|
205
|
+
for row in rows:
|
|
206
|
+
item = dict(row)
|
|
207
|
+
# CLEAN, THEN JUDGE — the same order the write path uses, and for
|
|
208
|
+
# the same reason. Rows migrated from the old corpus were never
|
|
209
|
+
# cleaned, so their scaffolding is still attached: judging first let
|
|
210
|
+
# "Here is a concise summary paragraph incorporating all 10 facts..."
|
|
211
|
+
# through as usable and put it at the top of the card, because it
|
|
212
|
+
# does contain a summary and the non-answer rules are about refusals,
|
|
213
|
+
# not preambles. Cleaning is also what the reader should see: the
|
|
214
|
+
# scaffolding is addressed to a conversation they cannot read.
|
|
215
|
+
item["content"] = clean_llm_summary(str(item.get("content") or ""))
|
|
216
|
+
rejected, why = is_non_answer(
|
|
217
|
+
item["content"], min_chars=MIN_USEFUL_CHARS,
|
|
218
|
+
)
|
|
219
|
+
item["quality"] = why if rejected else "ok"
|
|
220
|
+
if rejected:
|
|
221
|
+
unusable += 1
|
|
222
|
+
classified.append(item)
|
|
223
|
+
|
|
224
|
+
# Usable first, then rows that cover real memories, then the SQL
|
|
225
|
+
# ordering within each group. Stable sort, so two runs of one request
|
|
226
|
+
# return the same rows in the same order — a summary card that
|
|
227
|
+
# reshuffles itself on refresh reads as a bug even when every row is
|
|
228
|
+
# correct.
|
|
229
|
+
classified.sort(key=lambda item: (
|
|
230
|
+
0 if item["quality"] == "ok" else 1,
|
|
231
|
+
0 if (item.get("source_count") or 0) > 0 else 1,
|
|
232
|
+
))
|
|
233
|
+
|
|
234
|
+
# Collapse near-duplicates.
|
|
235
|
+
#
|
|
236
|
+
# The table's UNIQUE constraint is on exact content, so summaries that
|
|
237
|
+
# differ by a clause survive as separate rows. On the author's store the
|
|
238
|
+
# first 24 usable rows all opened "The Pro and SuperLocalMemory (SLM)
|
|
239
|
+
# projects have made significant progress in..." — twenty-four cards
|
|
240
|
+
# saying one thing, which reads as a broken page rather than as a view
|
|
241
|
+
# of a memory.
|
|
242
|
+
#
|
|
243
|
+
# Collapsed on a normalised opening, keeping the row that merged the
|
|
244
|
+
# most memories (the ordering above already put it first). The count is
|
|
245
|
+
# reported, not swallowed: that these summaries repeat each other is a
|
|
246
|
+
# real property of the store and worth a reader knowing.
|
|
247
|
+
deduped: list[dict[str, Any]] = []
|
|
248
|
+
seen_openings: set[str] = set()
|
|
249
|
+
collapsed = 0
|
|
250
|
+
for item in classified:
|
|
251
|
+
key = _opening_key(item.get("content"))
|
|
252
|
+
if key and key in seen_openings:
|
|
253
|
+
collapsed += 1
|
|
254
|
+
continue
|
|
255
|
+
if key:
|
|
256
|
+
seen_openings.add(key)
|
|
257
|
+
deduped.append(item)
|
|
258
|
+
|
|
259
|
+
# Only rows worth reading occupy the window.
|
|
260
|
+
#
|
|
261
|
+
# A first draft returned everything, usable first, and truncated at
|
|
262
|
+
# `limit`. Because the usable rows on this store collapse to a handful
|
|
263
|
+
# of distinct openings, the tail of a limit-10 request filled with
|
|
264
|
+
# refusals — and a card asking for 10 got 2 it could render and 8 it
|
|
265
|
+
# threw away. The counts carry what the reader needs to know about the
|
|
266
|
+
# rest; the rows themselves add nothing to a card.
|
|
267
|
+
shown = (
|
|
268
|
+
deduped[:int(limit)] if include_unusable
|
|
269
|
+
else [i for i in deduped if i["quality"] == "ok"][:int(limit)]
|
|
270
|
+
)
|
|
271
|
+
return JSONResponse({
|
|
272
|
+
"profile": pid,
|
|
273
|
+
"summaries": shown,
|
|
274
|
+
"unusable": unusable,
|
|
275
|
+
"near_duplicates": collapsed,
|
|
276
|
+
"scanned": len(rows),
|
|
277
|
+
})
|
|
278
|
+
except sqlite3.Error as exc:
|
|
279
|
+
# A store that predates the display table. Empty, not an error.
|
|
280
|
+
logger.debug("consolidated summaries read failed: %s", exc)
|
|
281
|
+
return JSONResponse({
|
|
282
|
+
"profile": pid, "summaries": [], "unusable": 0, "scanned": 0,
|
|
283
|
+
})
|
|
284
|
+
finally:
|
|
285
|
+
conn.close()
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@router.get("/health")
|
|
289
|
+
def get_memory_health() -> JSONResponse:
|
|
290
|
+
"""Whether this store's memories can actually be found.
|
|
291
|
+
|
|
292
|
+
Same measurement ``slm doctor`` prints, so the dashboard and the CLI cannot
|
|
293
|
+
tell the owner two different things. Read-only and fail-soft.
|
|
294
|
+
"""
|
|
295
|
+
try:
|
|
296
|
+
from superlocalmemory.core.memory_health import describe, measure
|
|
297
|
+
|
|
298
|
+
health = measure(DB_PATH)
|
|
299
|
+
return JSONResponse({
|
|
300
|
+
"live_facts": health.live_facts,
|
|
301
|
+
"findable_by_meaning": health.findable_by_meaning,
|
|
302
|
+
"missing_vector": health.missing_vector,
|
|
303
|
+
"withheld_summaries": health.withheld_summaries,
|
|
304
|
+
"display_summaries": health.display_summaries,
|
|
305
|
+
"hidden_by_forgetting": health.hidden_by_forgetting,
|
|
306
|
+
"inconsistently_hidden": health.inconsistently_hidden,
|
|
307
|
+
"reachability": round(health.reachability, 4),
|
|
308
|
+
"healthy": health.healthy,
|
|
309
|
+
"unavailable": list(health.unavailable),
|
|
310
|
+
"summary": describe(health),
|
|
311
|
+
})
|
|
312
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
313
|
+
logger.debug("memory health read failed: %s", exc)
|
|
314
|
+
return JSONResponse({"healthy": None, "summary": [], "unavailable": ["error"]})
|