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.
Files changed (165) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +245 -0
  3. package/README.md +7 -7
  4. package/package.json +4 -2
  5. package/plugin/.claude-plugin/plugin.json +2 -2
  6. package/plugin/CLAUDE.md +3 -3
  7. package/plugin/agents/slm-governance-advisor.md +1 -1
  8. package/plugin/agents/slm-loop-runner.md +4 -4
  9. package/plugin/agents/slm-memory-advisor.md +1 -1
  10. package/plugin/agents/slm-optimize-advisor.md +1 -1
  11. package/plugin/requirements.txt +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 +2 -2
  17. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  18. package/plugin/skills/slm-profile/SKILL.md +5 -5
  19. package/plugin/skills/slm-recall/SKILL.md +102 -15
  20. package/plugin/skills/slm-remember/SKILL.md +35 -3
  21. package/plugin/skills/slm-scope/SKILL.md +1 -1
  22. package/plugin/skills/slm-session/SKILL.md +29 -3
  23. package/plugin/skills/slm-status/SKILL.md +1 -1
  24. package/plugin-src/rules/AGENTS.md +16 -8
  25. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-governance/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-loop/SKILL.md +2 -2
  30. package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
  31. package/plugin-src/skills/slm-profile/SKILL.md +5 -5
  32. package/plugin-src/skills/slm-recall/SKILL.md +102 -15
  33. package/plugin-src/skills/slm-remember/SKILL.md +35 -3
  34. package/plugin-src/skills/slm-scope/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-session/SKILL.md +29 -3
  36. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  37. package/pyproject.toml +1 -1
  38. package/src/superlocalmemory/__init__.py +1 -1
  39. package/src/superlocalmemory/cli/commands.py +308 -20
  40. package/src/superlocalmemory/cli/daemon.py +30 -0
  41. package/src/superlocalmemory/cli/db_migrate.py +71 -1
  42. package/src/superlocalmemory/cli/gdpr_cmd.py +15 -2
  43. package/src/superlocalmemory/cli/main.py +26 -4
  44. package/src/superlocalmemory/code_graph/bridge/maintenance.py +8 -0
  45. package/src/superlocalmemory/code_graph/database.py +44 -0
  46. package/src/superlocalmemory/compliance/gdpr.py +449 -39
  47. package/src/superlocalmemory/core/admission.py +231 -11
  48. package/src/superlocalmemory/core/backend_orchestrator.py +190 -84
  49. package/src/superlocalmemory/core/config.py +90 -11
  50. package/src/superlocalmemory/core/consolidation_engine.py +34 -0
  51. package/src/superlocalmemory/core/engine.py +140 -11
  52. package/src/superlocalmemory/core/fact_consolidator.py +316 -125
  53. package/src/superlocalmemory/core/graph_analyzer.py +76 -112
  54. package/src/superlocalmemory/core/graph_metrics.py +597 -0
  55. package/src/superlocalmemory/core/graph_pruner.py +121 -0
  56. package/src/superlocalmemory/core/maintenance.py +44 -6
  57. package/src/superlocalmemory/core/maintenance_scheduler.py +205 -0
  58. package/src/superlocalmemory/core/memory_health.py +266 -0
  59. package/src/superlocalmemory/core/mode_capability.py +111 -0
  60. package/src/superlocalmemory/core/ollama_validator.py +315 -0
  61. package/src/superlocalmemory/core/operation_policy_registry.py +1 -1
  62. package/src/superlocalmemory/core/operation_request.py +1 -1
  63. package/src/superlocalmemory/core/ops_remediation.py +2 -2
  64. package/src/superlocalmemory/core/projection_drain.py +380 -0
  65. package/src/superlocalmemory/core/recall_pipeline.py +390 -3
  66. package/src/superlocalmemory/core/recall_worker.py +6 -3
  67. package/src/superlocalmemory/core/scale_autopromote.py +196 -0
  68. package/src/superlocalmemory/core/scale_engine.py +16 -2
  69. package/src/superlocalmemory/core/score_contract.py +21 -1
  70. package/src/superlocalmemory/core/session_identity.py +85 -0
  71. package/src/superlocalmemory/core/status_contract.py +108 -0
  72. package/src/superlocalmemory/core/store_pipeline.py +78 -3
  73. package/src/superlocalmemory/core/worker_pool.py +4 -4
  74. package/src/superlocalmemory/core/working_memory.py +288 -0
  75. package/src/superlocalmemory/encoding/cognitive_consolidator.py +51 -7
  76. package/src/superlocalmemory/encoding/context_generator.py +1 -1
  77. package/src/superlocalmemory/encoding/entity_resolver.py +38 -0
  78. package/src/superlocalmemory/encoding/fact_extractor.py +18 -14
  79. package/src/superlocalmemory/encoding/prospective_markers.py +262 -0
  80. package/src/superlocalmemory/encoding/type_router.py +12 -12
  81. package/src/superlocalmemory/evolution/mutation_generator.py +30 -4
  82. package/src/superlocalmemory/graph/cozo_adjacency.py +122 -0
  83. package/src/superlocalmemory/graph/cozo_backend.py +103 -138
  84. package/src/superlocalmemory/hooks/portable_kit.py +10 -2
  85. package/src/superlocalmemory/learning/bandit.py +43 -0
  86. package/src/superlocalmemory/learning/consolidation_worker.py +54 -0
  87. package/src/superlocalmemory/learning/database.py +60 -3
  88. package/src/superlocalmemory/learning/entity_compiler.py +21 -58
  89. package/src/superlocalmemory/learning/feedback.py +3 -1
  90. package/src/superlocalmemory/learning/outcomes.py +47 -16
  91. package/src/superlocalmemory/learning/pattern_miner.py +28 -3
  92. package/src/superlocalmemory/learning/pattern_miner_constants.py +43 -0
  93. package/src/superlocalmemory/learning/pcos.py +291 -0
  94. package/src/superlocalmemory/learning/reward_from_outcomes.py +365 -0
  95. package/src/superlocalmemory/learning/reward_proxy.py +100 -10
  96. package/src/superlocalmemory/learning/signal_kinds.py +79 -0
  97. package/src/superlocalmemory/mcp/profiles.py +14 -2
  98. package/src/superlocalmemory/mcp/server.py +1 -1
  99. package/src/superlocalmemory/mcp/session_binding.py +92 -0
  100. package/src/superlocalmemory/mcp/tools_active.py +2 -1
  101. package/src/superlocalmemory/mcp/tools_core.py +71 -42
  102. package/src/superlocalmemory/mcp/tools_ops.py +2 -2
  103. package/src/superlocalmemory/mcp/tools_v28.py +20 -1
  104. package/src/superlocalmemory/parameterization/pattern_extractor.py +14 -1
  105. package/src/superlocalmemory/parameterization/soft_prompt_generator.py +98 -0
  106. package/src/superlocalmemory/retrieval/bm25_channel.py +68 -11
  107. package/src/superlocalmemory/retrieval/channel_status.py +117 -0
  108. package/src/superlocalmemory/retrieval/engine.py +106 -11
  109. package/src/superlocalmemory/retrieval/entity_channel.py +217 -257
  110. package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
  111. package/src/superlocalmemory/retrieval/scope_policy.py +42 -1
  112. package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
  113. package/src/superlocalmemory/retrieval/spreading.py +288 -0
  114. package/src/superlocalmemory/retrieval/temporal_channel.py +13 -1
  115. package/src/superlocalmemory/retrieval/vector_store.py +63 -0
  116. package/src/superlocalmemory/server/api.py +26 -2
  117. package/src/superlocalmemory/server/asset_versions.py +171 -0
  118. package/src/superlocalmemory/server/bandit_loops.py +17 -1
  119. package/src/superlocalmemory/server/rbac_enforce.py +26 -6
  120. package/src/superlocalmemory/server/recall_serializer.py +9 -0
  121. package/src/superlocalmemory/server/routes/abstraction.py +201 -0
  122. package/src/superlocalmemory/server/routes/behavioral.py +75 -10
  123. package/src/superlocalmemory/server/routes/compliance.py +98 -18
  124. package/src/superlocalmemory/server/routes/config_api.py +186 -4
  125. package/src/superlocalmemory/server/routes/data_io.py +29 -1
  126. package/src/superlocalmemory/server/routes/entity.py +13 -1
  127. package/src/superlocalmemory/server/routes/evolution.py +178 -0
  128. package/src/superlocalmemory/server/routes/ingest.py +8 -0
  129. package/src/superlocalmemory/server/routes/learning_telemetry.py +2 -1
  130. package/src/superlocalmemory/server/routes/memories.py +49 -7
  131. package/src/superlocalmemory/server/routes/mesh.py +1 -1
  132. package/src/superlocalmemory/server/routes/timeline.py +4 -0
  133. package/src/superlocalmemory/server/routes/v3_api.py +193 -17
  134. package/src/superlocalmemory/server/ui.py +24 -1
  135. package/src/superlocalmemory/server/unified_daemon.py +292 -9
  136. package/src/superlocalmemory/storage/_migration_internals.py +35 -0
  137. package/src/superlocalmemory/storage/_schema_version.py +24 -3
  138. package/src/superlocalmemory/storage/database.py +598 -82
  139. package/src/superlocalmemory/storage/embedding_codec.py +71 -0
  140. package/src/superlocalmemory/storage/lineage_retention.py +236 -0
  141. package/src/superlocalmemory/storage/logical_edges.py +43 -2
  142. package/src/superlocalmemory/storage/migration_runner.py +130 -0
  143. package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +488 -0
  144. package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +127 -0
  145. package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +158 -0
  146. package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +620 -0
  147. package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +306 -0
  148. package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +207 -0
  149. package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +201 -0
  150. package/src/superlocalmemory/storage/migrations.py +18 -2
  151. package/src/superlocalmemory/storage/models.py +40 -1
  152. package/src/superlocalmemory/storage/projection_outbox.py +346 -0
  153. package/src/superlocalmemory/storage/retention_policy.py +860 -0
  154. package/src/superlocalmemory/storage/schema.py +110 -1
  155. package/src/superlocalmemory/storage/write_coordinator.py +19 -2
  156. package/src/superlocalmemory/summaries/base.py +1 -1
  157. package/src/superlocalmemory/summaries/non_answer.py +223 -0
  158. package/src/superlocalmemory/trust/scorer.py +43 -1
  159. package/src/superlocalmemory/ui/index.html +10 -19
  160. package/src/superlocalmemory/ui/js/event-delegation.js +12 -1
  161. package/src/superlocalmemory/ui/js/od-health.js +28 -6
  162. package/src/superlocalmemory/ui/js/od-memories.js +209 -1
  163. package/src/superlocalmemory/ui/js/od-ops-health.js +1 -1
  164. package/src/superlocalmemory/ui/js/od-settings.js +87 -1
  165. 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 = 10
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
- return index_path.read_text()
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
- # The caller already passed require_permission for this operation, so
137
- # a transient role lookup must not surface as a 500. Fall back to the
138
- # least-privileged write-capable role rather than deny an authorized
139
- # write.
140
- return frozenset({ActorRole.MEMBER})
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"]})