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.
Files changed (147) hide show
  1. package/CHANGELOG.md +221 -1
  2. package/README.md +6 -6
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +1 -1
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/scripts/ensure-venv.sh +1 -1
  12. package/plugin/skills/slm-cache/SKILL.md +1 -1
  13. package/plugin/skills/slm-compress/SKILL.md +1 -1
  14. package/plugin/skills/slm-governance/SKILL.md +1 -1
  15. package/plugin/skills/slm-graph/SKILL.md +1 -1
  16. package/plugin/skills/slm-loop/SKILL.md +1 -1
  17. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  18. package/plugin/skills/slm-profile/SKILL.md +1 -1
  19. package/plugin/skills/slm-recall/SKILL.md +1 -1
  20. package/plugin/skills/slm-remember/SKILL.md +1 -1
  21. package/plugin/skills/slm-scope/SKILL.md +1 -1
  22. package/plugin/skills/slm-session/SKILL.md +1 -1
  23. package/plugin/skills/slm-status/SKILL.md +3 -3
  24. package/plugin-src/rules/AGENTS.md +1 -1
  25. package/plugin-src/skills/slm-status/SKILL.md +2 -2
  26. package/pyproject.toml +1 -1
  27. package/scripts/postinstall.js +4 -0
  28. package/src/superlocalmemory/__init__.py +1 -1
  29. package/src/superlocalmemory/cli/_lazy_init.py +1 -1
  30. package/src/superlocalmemory/cli/commands.py +164 -11
  31. package/src/superlocalmemory/cli/db_migrate.py +0 -2
  32. package/src/superlocalmemory/cli/gdpr_io.py +1 -1
  33. package/src/superlocalmemory/cli/main.py +7 -7
  34. package/src/superlocalmemory/cli/service_installer.py +2 -1
  35. package/src/superlocalmemory/cli/setup_wizard.py +1 -1
  36. package/src/superlocalmemory/code_graph/bridge/maintenance.py +8 -0
  37. package/src/superlocalmemory/core/config.py +41 -7
  38. package/src/superlocalmemory/core/context_cache.py +0 -2
  39. package/src/superlocalmemory/core/engine.py +371 -63
  40. package/src/superlocalmemory/core/evidence_bundle.py +3 -1
  41. package/src/superlocalmemory/core/fact_consolidator.py +316 -125
  42. package/src/superlocalmemory/core/install_detector.py +131 -0
  43. package/src/superlocalmemory/core/maintenance.py +44 -6
  44. package/src/superlocalmemory/core/memory_health.py +266 -0
  45. package/src/superlocalmemory/core/operation_policy_registry.py +1 -1
  46. package/src/superlocalmemory/core/operation_request.py +1 -1
  47. package/src/superlocalmemory/core/ops_remediation.py +2 -2
  48. package/src/superlocalmemory/core/progressive_abstraction.py +1 -1
  49. package/src/superlocalmemory/core/security_primitives.py +3 -6
  50. package/src/superlocalmemory/core/store_pipeline.py +172 -29
  51. package/src/superlocalmemory/core/topic_signature.py +0 -2
  52. package/src/superlocalmemory/core/transactions/concrete_owners.py +15 -8
  53. package/src/superlocalmemory/dynamics/eap_scheduler.py +17 -6
  54. package/src/superlocalmemory/encoding/cognitive_consolidator.py +15 -1
  55. package/src/superlocalmemory/encoding/graph_builder.py +2 -2
  56. package/src/superlocalmemory/encoding/scene_builder.py +8 -2
  57. package/src/superlocalmemory/hooks/adapter_base.py +0 -2
  58. package/src/superlocalmemory/hooks/context_payload.py +0 -2
  59. package/src/superlocalmemory/hooks/portable_kit.py +8 -8
  60. package/src/superlocalmemory/hooks/post_tool_async_hook.py +0 -2
  61. package/src/superlocalmemory/hooks/prewarm_auth.py +0 -2
  62. package/src/superlocalmemory/hooks/user_prompt_hook.py +0 -2
  63. package/src/superlocalmemory/infra/backup.py +44 -8
  64. package/src/superlocalmemory/integrations/bounded_loops_mcp.py +24 -7
  65. package/src/superlocalmemory/learning/arm_catalog.py +0 -2
  66. package/src/superlocalmemory/learning/bandit.py +0 -2
  67. package/src/superlocalmemory/learning/bandit_cache.py +0 -2
  68. package/src/superlocalmemory/learning/dedup_hnsw.py +11 -11
  69. package/src/superlocalmemory/learning/ensemble.py +0 -2
  70. package/src/superlocalmemory/learning/labeler.py +0 -2
  71. package/src/superlocalmemory/learning/legacy_migration.py +0 -2
  72. package/src/superlocalmemory/learning/model_cache.py +0 -2
  73. package/src/superlocalmemory/learning/ranker.py +0 -2
  74. package/src/superlocalmemory/learning/reward_archive.py +6 -1
  75. package/src/superlocalmemory/learning/reward_proxy.py +0 -2
  76. package/src/superlocalmemory/learning/signal_worker.py +0 -2
  77. package/src/superlocalmemory/math/fisher.py +1 -1
  78. package/src/superlocalmemory/math/hopfield.py +4 -1
  79. package/src/superlocalmemory/math/langevin.py +1 -1
  80. package/src/superlocalmemory/math/sheaf.py +7 -3
  81. package/src/superlocalmemory/mcp/cli_fallback.py +1 -1
  82. package/src/superlocalmemory/mcp/profiles.py +1 -1
  83. package/src/superlocalmemory/mcp/server.py +2 -2
  84. package/src/superlocalmemory/mcp/session_binding.py +92 -0
  85. package/src/superlocalmemory/mcp/tools_active.py +56 -0
  86. package/src/superlocalmemory/mcp/tools_core.py +41 -40
  87. package/src/superlocalmemory/mcp/tools_ops.py +2 -2
  88. package/src/superlocalmemory/optimize/cache/manager.py +2 -2
  89. package/src/superlocalmemory/optimize/compress/ccr.py +1 -1
  90. package/src/superlocalmemory/optimize/compress/router.py +1 -1
  91. package/src/superlocalmemory/optimize/proxy/_helpers.py +2 -2
  92. package/src/superlocalmemory/optimize/proxy/server.py +1 -1
  93. package/src/superlocalmemory/optimize/proxy/vertex_surface.py +2 -2
  94. package/src/superlocalmemory/optimize/storage/db.py +2 -2
  95. package/src/superlocalmemory/retrieval/agentic.py +1 -1
  96. package/src/superlocalmemory/retrieval/ann_index.py +9 -2
  97. package/src/superlocalmemory/retrieval/bm25_channel.py +6 -10
  98. package/src/superlocalmemory/retrieval/bridge_discovery.py +2 -2
  99. package/src/superlocalmemory/retrieval/engine.py +272 -43
  100. package/src/superlocalmemory/retrieval/entity_channel.py +8 -2
  101. package/src/superlocalmemory/retrieval/hopfield_channel.py +8 -2
  102. package/src/superlocalmemory/retrieval/profile_channel.py +1 -1
  103. package/src/superlocalmemory/retrieval/quantization_aware_search.py +1 -1
  104. package/src/superlocalmemory/retrieval/remote_reranker.py +2 -2
  105. package/src/superlocalmemory/retrieval/reranker.py +3 -3
  106. package/src/superlocalmemory/retrieval/scope_policy.py +22 -1
  107. package/src/superlocalmemory/retrieval/semantic_channel.py +3 -3
  108. package/src/superlocalmemory/retrieval/spreading_activation.py +8 -8
  109. package/src/superlocalmemory/retrieval/strategy.py +94 -0
  110. package/src/superlocalmemory/retrieval/temporal_channel.py +179 -10
  111. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +1 -1
  112. package/src/superlocalmemory/retrieval/vector_store.py +151 -10
  113. package/src/superlocalmemory/server/api.py +6 -1
  114. package/src/superlocalmemory/server/asset_versions.py +171 -0
  115. package/src/superlocalmemory/server/recall_serializer.py +10 -0
  116. package/src/superlocalmemory/server/routes/abstraction.py +201 -0
  117. package/src/superlocalmemory/server/routes/brain.py +2 -2
  118. package/src/superlocalmemory/server/routes/data_io.py +29 -1
  119. package/src/superlocalmemory/server/routes/entity.py +13 -1
  120. package/src/superlocalmemory/server/routes/mesh.py +1 -1
  121. package/src/superlocalmemory/server/routes/prewarm.py +4 -4
  122. package/src/superlocalmemory/server/routes/v3_api.py +2 -2
  123. package/src/superlocalmemory/server/ui.py +8 -1
  124. package/src/superlocalmemory/server/unified_daemon.py +570 -16
  125. package/src/superlocalmemory/storage/_migration_internals.py +4 -0
  126. package/src/superlocalmemory/storage/_schema_version.py +46 -3
  127. package/src/superlocalmemory/storage/backup.py +531 -0
  128. package/src/superlocalmemory/storage/database.py +139 -34
  129. package/src/superlocalmemory/storage/embedding_codec.py +129 -0
  130. package/src/superlocalmemory/storage/embedding_migrator.py +5 -3
  131. package/src/superlocalmemory/storage/migration_runner.py +153 -2
  132. package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +488 -0
  133. package/src/superlocalmemory/storage/migrations/__init__.py +1 -1
  134. package/src/superlocalmemory/storage/migrations.py +15 -1
  135. package/src/superlocalmemory/storage/models.py +7 -0
  136. package/src/superlocalmemory/storage/quantized_store.py +4 -2
  137. package/src/superlocalmemory/storage/schema.py +98 -0
  138. package/src/superlocalmemory/summaries/base.py +1 -1
  139. package/src/superlocalmemory/summaries/non_answer.py +223 -0
  140. package/src/superlocalmemory/summaries/session_summary.py +1 -1
  141. package/src/superlocalmemory/ui/css/legacy-dashboard.css +1 -1
  142. package/src/superlocalmemory/ui/css/neural-glass.css +1 -1
  143. package/src/superlocalmemory/ui/index.html +1 -1
  144. package/src/superlocalmemory/ui/js/core.js +1 -1
  145. package/src/superlocalmemory/ui/js/od-brain.js +1 -1
  146. package/src/superlocalmemory/ui/js/od-memories.js +190 -1
  147. 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
- return np.array(vector, dtype=np.float32).tobytes()
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
- results: list[tuple[str, float]] = []
482
- for row in rows[:top_k]:
483
- fid = str(row["fact_id"])
484
- similarity = max(0.0, 1.0 - row["distance"])
485
- results.append((fid, similarity))
486
-
487
- results.sort(key=lambda x: x[1], reverse=True)
488
- return results
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
- return index_path.read_text()
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 Wave 2C
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 (Wave 4)
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)