superlocalmemory 4.0.8 → 4.0.10
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 +221 -1
- package/README.md +6 -6
- 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/scripts/ensure-venv.sh +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 +3 -3
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +2 -2
- package/pyproject.toml +1 -1
- package/scripts/postinstall.js +4 -0
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/_lazy_init.py +1 -1
- package/src/superlocalmemory/cli/commands.py +164 -11
- package/src/superlocalmemory/cli/db_migrate.py +0 -2
- package/src/superlocalmemory/cli/gdpr_io.py +1 -1
- package/src/superlocalmemory/cli/main.py +7 -7
- package/src/superlocalmemory/cli/service_installer.py +2 -1
- package/src/superlocalmemory/cli/setup_wizard.py +1 -1
- package/src/superlocalmemory/code_graph/bridge/maintenance.py +8 -0
- package/src/superlocalmemory/core/config.py +41 -7
- package/src/superlocalmemory/core/context_cache.py +0 -2
- package/src/superlocalmemory/core/engine.py +371 -63
- package/src/superlocalmemory/core/evidence_bundle.py +3 -1
- package/src/superlocalmemory/core/fact_consolidator.py +316 -125
- package/src/superlocalmemory/core/install_detector.py +131 -0
- package/src/superlocalmemory/core/maintenance.py +44 -6
- package/src/superlocalmemory/core/memory_health.py +266 -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/progressive_abstraction.py +1 -1
- package/src/superlocalmemory/core/security_primitives.py +3 -6
- package/src/superlocalmemory/core/store_pipeline.py +172 -29
- package/src/superlocalmemory/core/topic_signature.py +0 -2
- package/src/superlocalmemory/core/transactions/concrete_owners.py +15 -8
- package/src/superlocalmemory/dynamics/eap_scheduler.py +17 -6
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +15 -1
- package/src/superlocalmemory/encoding/graph_builder.py +2 -2
- package/src/superlocalmemory/encoding/scene_builder.py +8 -2
- package/src/superlocalmemory/hooks/adapter_base.py +0 -2
- package/src/superlocalmemory/hooks/context_payload.py +0 -2
- package/src/superlocalmemory/hooks/portable_kit.py +8 -8
- package/src/superlocalmemory/hooks/post_tool_async_hook.py +0 -2
- package/src/superlocalmemory/hooks/prewarm_auth.py +0 -2
- package/src/superlocalmemory/hooks/user_prompt_hook.py +0 -2
- package/src/superlocalmemory/infra/backup.py +44 -8
- package/src/superlocalmemory/integrations/bounded_loops_mcp.py +24 -7
- package/src/superlocalmemory/learning/arm_catalog.py +0 -2
- package/src/superlocalmemory/learning/bandit.py +0 -2
- package/src/superlocalmemory/learning/bandit_cache.py +0 -2
- package/src/superlocalmemory/learning/dedup_hnsw.py +11 -11
- package/src/superlocalmemory/learning/ensemble.py +0 -2
- package/src/superlocalmemory/learning/labeler.py +0 -2
- package/src/superlocalmemory/learning/legacy_migration.py +0 -2
- package/src/superlocalmemory/learning/model_cache.py +0 -2
- package/src/superlocalmemory/learning/ranker.py +0 -2
- package/src/superlocalmemory/learning/reward_archive.py +6 -1
- package/src/superlocalmemory/learning/reward_proxy.py +0 -2
- package/src/superlocalmemory/learning/signal_worker.py +0 -2
- package/src/superlocalmemory/math/fisher.py +1 -1
- package/src/superlocalmemory/math/hopfield.py +4 -1
- package/src/superlocalmemory/math/langevin.py +1 -1
- package/src/superlocalmemory/math/sheaf.py +7 -3
- package/src/superlocalmemory/mcp/cli_fallback.py +1 -1
- package/src/superlocalmemory/mcp/profiles.py +1 -1
- package/src/superlocalmemory/mcp/server.py +2 -2
- package/src/superlocalmemory/mcp/session_binding.py +92 -0
- package/src/superlocalmemory/mcp/tools_active.py +56 -0
- package/src/superlocalmemory/mcp/tools_core.py +41 -40
- package/src/superlocalmemory/mcp/tools_ops.py +2 -2
- package/src/superlocalmemory/optimize/cache/manager.py +2 -2
- package/src/superlocalmemory/optimize/compress/ccr.py +1 -1
- package/src/superlocalmemory/optimize/compress/router.py +1 -1
- package/src/superlocalmemory/optimize/proxy/_helpers.py +2 -2
- package/src/superlocalmemory/optimize/proxy/server.py +1 -1
- package/src/superlocalmemory/optimize/proxy/vertex_surface.py +2 -2
- package/src/superlocalmemory/optimize/storage/db.py +2 -2
- package/src/superlocalmemory/retrieval/agentic.py +1 -1
- package/src/superlocalmemory/retrieval/ann_index.py +9 -2
- package/src/superlocalmemory/retrieval/bm25_channel.py +6 -10
- package/src/superlocalmemory/retrieval/bridge_discovery.py +2 -2
- package/src/superlocalmemory/retrieval/engine.py +272 -43
- package/src/superlocalmemory/retrieval/entity_channel.py +8 -2
- package/src/superlocalmemory/retrieval/hopfield_channel.py +8 -2
- package/src/superlocalmemory/retrieval/profile_channel.py +1 -1
- package/src/superlocalmemory/retrieval/quantization_aware_search.py +1 -1
- package/src/superlocalmemory/retrieval/remote_reranker.py +2 -2
- package/src/superlocalmemory/retrieval/reranker.py +3 -3
- package/src/superlocalmemory/retrieval/scope_policy.py +22 -1
- package/src/superlocalmemory/retrieval/semantic_channel.py +3 -3
- package/src/superlocalmemory/retrieval/spreading_activation.py +8 -8
- package/src/superlocalmemory/retrieval/strategy.py +94 -0
- package/src/superlocalmemory/retrieval/temporal_channel.py +179 -10
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +1 -1
- package/src/superlocalmemory/retrieval/vector_store.py +151 -10
- package/src/superlocalmemory/server/api.py +6 -1
- package/src/superlocalmemory/server/asset_versions.py +171 -0
- package/src/superlocalmemory/server/recall_serializer.py +10 -0
- package/src/superlocalmemory/server/routes/abstraction.py +201 -0
- package/src/superlocalmemory/server/routes/brain.py +2 -2
- package/src/superlocalmemory/server/routes/data_io.py +29 -1
- package/src/superlocalmemory/server/routes/entity.py +13 -1
- package/src/superlocalmemory/server/routes/mesh.py +1 -1
- package/src/superlocalmemory/server/routes/prewarm.py +4 -4
- package/src/superlocalmemory/server/routes/v3_api.py +2 -2
- package/src/superlocalmemory/server/ui.py +8 -1
- package/src/superlocalmemory/server/unified_daemon.py +570 -16
- package/src/superlocalmemory/storage/_migration_internals.py +4 -0
- package/src/superlocalmemory/storage/_schema_version.py +46 -3
- package/src/superlocalmemory/storage/backup.py +531 -0
- package/src/superlocalmemory/storage/database.py +139 -34
- package/src/superlocalmemory/storage/embedding_codec.py +129 -0
- package/src/superlocalmemory/storage/embedding_migrator.py +5 -3
- package/src/superlocalmemory/storage/migration_runner.py +153 -2
- package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +488 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +1 -1
- package/src/superlocalmemory/storage/migrations.py +15 -1
- package/src/superlocalmemory/storage/models.py +7 -0
- package/src/superlocalmemory/storage/quantized_store.py +4 -2
- package/src/superlocalmemory/storage/schema.py +98 -0
- package/src/superlocalmemory/summaries/base.py +1 -1
- package/src/superlocalmemory/summaries/non_answer.py +223 -0
- package/src/superlocalmemory/summaries/session_summary.py +1 -1
- package/src/superlocalmemory/ui/css/legacy-dashboard.css +1 -1
- package/src/superlocalmemory/ui/css/neural-glass.css +1 -1
- package/src/superlocalmemory/ui/index.html +1 -1
- package/src/superlocalmemory/ui/js/core.js +1 -1
- package/src/superlocalmemory/ui/js/od-brain.js +1 -1
- package/src/superlocalmemory/ui/js/od-memories.js +190 -1
- package/src/superlocalmemory/ui/js/od-ops-health.js +1 -1
|
@@ -25,6 +25,7 @@ from typing import Generator
|
|
|
25
25
|
|
|
26
26
|
import numpy as np
|
|
27
27
|
|
|
28
|
+
from superlocalmemory.storage.embedding_codec import encode_embedding
|
|
28
29
|
from superlocalmemory.storage.write_lock import get_write_lock
|
|
29
30
|
|
|
30
31
|
logger = logging.getLogger(__name__)
|
|
@@ -249,8 +250,19 @@ class VectorStore:
|
|
|
249
250
|
|
|
250
251
|
@staticmethod
|
|
251
252
|
def _serialize_f32(vector: list[float]) -> bytes:
|
|
252
|
-
"""Serialize float list to raw bytes for sqlite-vec.
|
|
253
|
-
|
|
253
|
+
"""Serialize float list to raw bytes for sqlite-vec.
|
|
254
|
+
|
|
255
|
+
Delegates to the shared embedding codec so that a format change in
|
|
256
|
+
that codec propagates here automatically.
|
|
257
|
+
"""
|
|
258
|
+
result = encode_embedding(vector)
|
|
259
|
+
if result is None:
|
|
260
|
+
# encode_embedding returns None only for None input; _serialize_f32
|
|
261
|
+
# is never called with None, so this branch is unreachable in
|
|
262
|
+
# production. Return empty bytes rather than raising so the caller
|
|
263
|
+
# sees an empty query result rather than an unhandled exception.
|
|
264
|
+
return b""
|
|
265
|
+
return result
|
|
254
266
|
|
|
255
267
|
# -- CRUD Operations ----------------------------------------------------
|
|
256
268
|
|
|
@@ -404,6 +416,29 @@ class VectorStore:
|
|
|
404
416
|
logger.debug("upsert failed for fact_id=%s: %s", fact_id, exc)
|
|
405
417
|
return False
|
|
406
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
|
+
|
|
407
442
|
def search(
|
|
408
443
|
self,
|
|
409
444
|
query_embedding: list[float],
|
|
@@ -427,6 +462,42 @@ class VectorStore:
|
|
|
427
462
|
with self._managed_connection() as conn:
|
|
428
463
|
if top_k <= 0:
|
|
429
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
|
+
)
|
|
430
501
|
if profile_id is not None:
|
|
431
502
|
sql = (
|
|
432
503
|
"SELECT fe.rowid, fe.distance, em.fact_id "
|
|
@@ -434,8 +505,10 @@ class VectorStore:
|
|
|
434
505
|
"JOIN embedding_metadata AS em "
|
|
435
506
|
"ON em.vec_rowid = fe.rowid "
|
|
436
507
|
"AND em.profile_id = fe.profile_id "
|
|
508
|
+
+ quarantine_join +
|
|
437
509
|
"WHERE fe.embedding MATCH ? "
|
|
438
510
|
"AND fe.profile_id = ? "
|
|
511
|
+
+ quarantine_filter +
|
|
439
512
|
"AND fe.k = ?"
|
|
440
513
|
)
|
|
441
514
|
base_params: tuple[object, ...] = (vec_bytes, profile_id)
|
|
@@ -448,7 +521,9 @@ class VectorStore:
|
|
|
448
521
|
"JOIN embedding_metadata AS em "
|
|
449
522
|
"ON em.vec_rowid = fe.rowid "
|
|
450
523
|
"AND em.profile_id = fe.profile_id "
|
|
524
|
+
+ quarantine_join +
|
|
451
525
|
"WHERE fe.embedding MATCH ? "
|
|
526
|
+
+ quarantine_filter +
|
|
452
527
|
"AND fe.k = ?"
|
|
453
528
|
)
|
|
454
529
|
base_params = (vec_bytes,)
|
|
@@ -478,14 +553,28 @@ class VectorStore:
|
|
|
478
553
|
(*base_params, search_k),
|
|
479
554
|
).fetchall()
|
|
480
555
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
556
|
+
# Rank the whole candidate list, THEN cut it. The expansion above
|
|
557
|
+
# can leave `rows` longer than top_k, and the SQL has no ORDER BY —
|
|
558
|
+
# vec0 applies k, then a relational join emits the survivors in an
|
|
559
|
+
# order SQLite is free to choose. Cutting first therefore cut by
|
|
560
|
+
# position and only ranked what survived, which can drop a nearer
|
|
561
|
+
# fact in favour of a farther one.
|
|
562
|
+
#
|
|
563
|
+
# Measured on the 0.95 GB archive: 29 of 60 queries do take the
|
|
564
|
+
# expansion path (orphan vectors are common), and the produced set
|
|
565
|
+
# matched the true nearest-k in all 29 — the join does emit in
|
|
566
|
+
# distance order in practice. So this was latent, not active. It is
|
|
567
|
+
# fixed because "the planner happens to" is not a guarantee: add an
|
|
568
|
+
# index, change SQLite, or change the join and the answer moves.
|
|
569
|
+
#
|
|
570
|
+
# The tie-break on fact_id makes the cut total, so two facts at
|
|
571
|
+
# equal distance cannot swap across the top_k boundary between runs.
|
|
572
|
+
results: list[tuple[str, float]] = [
|
|
573
|
+
(str(row["fact_id"]), max(0.0, 1.0 - row["distance"]))
|
|
574
|
+
for row in rows
|
|
575
|
+
]
|
|
576
|
+
results.sort(key=lambda x: (-x[1], x[0]))
|
|
577
|
+
return results[:top_k]
|
|
489
578
|
|
|
490
579
|
except Exception as exc:
|
|
491
580
|
logger.debug("search failed: %s", exc)
|
|
@@ -596,6 +685,58 @@ class VectorStore:
|
|
|
596
685
|
except Exception:
|
|
597
686
|
return False
|
|
598
687
|
|
|
688
|
+
def is_searchable_by_meaning(
|
|
689
|
+
self,
|
|
690
|
+
fact_id: str,
|
|
691
|
+
profile_id: str | None = None,
|
|
692
|
+
) -> bool:
|
|
693
|
+
"""Return True if search() would be able to return this fact.
|
|
694
|
+
|
|
695
|
+
The check mirrors search()'s own join:
|
|
696
|
+
|
|
697
|
+
FROM fact_embeddings AS fe
|
|
698
|
+
JOIN embedding_metadata AS em
|
|
699
|
+
ON em.vec_rowid = fe.rowid
|
|
700
|
+
AND em.profile_id = fe.profile_id
|
|
701
|
+
|
|
702
|
+
A fact that has a vector in fact_embeddings but no row in
|
|
703
|
+
embedding_metadata will NOT be returned by search(), so this method
|
|
704
|
+
returns False for it — even though raw_vector_present() would return
|
|
705
|
+
True. Callers that need to decide whether a fact requires re-embedding
|
|
706
|
+
must use this method, not raw_vector_present().
|
|
707
|
+
|
|
708
|
+
Returns False on any error or when the store is unavailable (fail-
|
|
709
|
+
closed: never optimistic).
|
|
710
|
+
"""
|
|
711
|
+
if not self._available:
|
|
712
|
+
return False
|
|
713
|
+
try:
|
|
714
|
+
with self._managed_connection() as conn:
|
|
715
|
+
if profile_id is not None:
|
|
716
|
+
sql = (
|
|
717
|
+
"SELECT 1 FROM fact_embeddings AS fe "
|
|
718
|
+
"JOIN embedding_metadata AS em "
|
|
719
|
+
"ON em.vec_rowid = fe.rowid "
|
|
720
|
+
"AND em.profile_id = fe.profile_id "
|
|
721
|
+
"WHERE em.fact_id = ? "
|
|
722
|
+
"AND fe.profile_id = ? "
|
|
723
|
+
"LIMIT 1"
|
|
724
|
+
)
|
|
725
|
+
row = conn.execute(sql, (fact_id, profile_id)).fetchone()
|
|
726
|
+
else:
|
|
727
|
+
sql = (
|
|
728
|
+
"SELECT 1 FROM fact_embeddings AS fe "
|
|
729
|
+
"JOIN embedding_metadata AS em "
|
|
730
|
+
"ON em.vec_rowid = fe.rowid "
|
|
731
|
+
"AND em.profile_id = fe.profile_id "
|
|
732
|
+
"WHERE em.fact_id = ? "
|
|
733
|
+
"LIMIT 1"
|
|
734
|
+
)
|
|
735
|
+
row = conn.execute(sql, (fact_id,)).fetchone()
|
|
736
|
+
return row is not None
|
|
737
|
+
except Exception:
|
|
738
|
+
return False
|
|
739
|
+
|
|
599
740
|
def count(self, profile_id: str | None = None) -> int:
|
|
600
741
|
"""Count complete metadata/vector pairs in the store.
|
|
601
742
|
|
|
@@ -246,7 +246,12 @@ def create_app() -> FastAPI:
|
|
|
246
246
|
"<p><a href='/docs'>API Documentation</a></p>"
|
|
247
247
|
"</body></html>"
|
|
248
248
|
)
|
|
249
|
-
|
|
249
|
+
from superlocalmemory.server.asset_versions import render_index
|
|
250
|
+
from superlocalmemory import __version__ as _v
|
|
251
|
+
|
|
252
|
+
return render_index(
|
|
253
|
+
index_path, UI_DIR, substitutions={"__SLM_VERSION__": _v},
|
|
254
|
+
)
|
|
250
255
|
|
|
251
256
|
@application.get("/health")
|
|
252
257
|
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
|
|
@@ -298,4 +298,14 @@ def recall_response_metadata(response: Any) -> dict:
|
|
|
298
298
|
# Q2b: thematic community summary (pure pass-through; computed upstream
|
|
299
299
|
# in the engine where DB access is available). None on most recalls.
|
|
300
300
|
"thematic_context": getattr(response, "community_context", None),
|
|
301
|
+
# Channels abandoned at the hang guard, so their candidates are absent
|
|
302
|
+
# from this answer. Empty on a healthy recall, which is the normal case.
|
|
303
|
+
# Non-empty is the one situation in which asking the same question twice
|
|
304
|
+
# may legitimately give different answers, so it has to travel with the
|
|
305
|
+
# response rather than living only in a server log — otherwise a caller
|
|
306
|
+
# comparing two runs has no way to tell an incomplete answer from a
|
|
307
|
+
# changed one. A list, because JSON has no tuple.
|
|
308
|
+
"incomplete_channels": list(
|
|
309
|
+
getattr(response, "incomplete_channels", ()) or ()
|
|
310
|
+
),
|
|
301
311
|
}
|
|
@@ -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"]})
|
|
@@ -806,7 +806,7 @@ def _compute_cross_platform() -> dict:
|
|
|
806
806
|
``memory.db`` (LLD-07 M004). On any adapter error, that adapter
|
|
807
807
|
reports ``active: false`` with ``reason: error:<ExcName>`` rather
|
|
808
808
|
than crashing the whole Brain endpoint (LLD-04 §2 — "honest, never
|
|
809
|
-
fake"). An unimportable adapter means the install is missing
|
|
809
|
+
fake"). An unimportable adapter means the install is missing the
|
|
810
810
|
components, which is legitimate for an older 3.4.20 → 3.4.22 upgrade
|
|
811
811
|
mid-migration.
|
|
812
812
|
"""
|
|
@@ -953,7 +953,7 @@ def _compute_active_clients(profile_id: str) -> dict:
|
|
|
953
953
|
try:
|
|
954
954
|
from superlocalmemory.hooks.session_registry import active_client_summary
|
|
955
955
|
clients = active_client_summary(profile_id, within_seconds=300)
|
|
956
|
-
except Exception as exc: # distinguish failure from emptiness (
|
|
956
|
+
except Exception as exc: # distinguish failure from emptiness (important: these are different states)
|
|
957
957
|
registry_ok = False
|
|
958
958
|
reg_status = "error"
|
|
959
959
|
logger.debug("active_clients: registry error: %s", exc)
|