superlocalmemory 4.0.9 → 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 (64) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +3 -3
  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/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +1 -1
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +1 -1
  24. package/pyproject.toml +1 -1
  25. package/src/superlocalmemory/__init__.py +1 -1
  26. package/src/superlocalmemory/cli/commands.py +45 -2
  27. package/src/superlocalmemory/cli/main.py +2 -2
  28. package/src/superlocalmemory/code_graph/bridge/maintenance.py +8 -0
  29. package/src/superlocalmemory/core/fact_consolidator.py +316 -125
  30. package/src/superlocalmemory/core/maintenance.py +44 -6
  31. package/src/superlocalmemory/core/memory_health.py +266 -0
  32. package/src/superlocalmemory/core/operation_policy_registry.py +1 -1
  33. package/src/superlocalmemory/core/operation_request.py +1 -1
  34. package/src/superlocalmemory/core/ops_remediation.py +2 -2
  35. package/src/superlocalmemory/core/store_pipeline.py +78 -3
  36. package/src/superlocalmemory/encoding/cognitive_consolidator.py +15 -1
  37. package/src/superlocalmemory/mcp/server.py +1 -1
  38. package/src/superlocalmemory/mcp/session_binding.py +92 -0
  39. package/src/superlocalmemory/mcp/tools_core.py +40 -39
  40. package/src/superlocalmemory/mcp/tools_ops.py +2 -2
  41. package/src/superlocalmemory/retrieval/bm25_channel.py +4 -8
  42. package/src/superlocalmemory/retrieval/entity_channel.py +7 -1
  43. package/src/superlocalmemory/retrieval/scope_policy.py +22 -1
  44. package/src/superlocalmemory/retrieval/temporal_channel.py +13 -1
  45. package/src/superlocalmemory/retrieval/vector_store.py +63 -0
  46. package/src/superlocalmemory/server/api.py +6 -1
  47. package/src/superlocalmemory/server/asset_versions.py +171 -0
  48. package/src/superlocalmemory/server/routes/abstraction.py +201 -0
  49. package/src/superlocalmemory/server/routes/data_io.py +29 -1
  50. package/src/superlocalmemory/server/routes/entity.py +13 -1
  51. package/src/superlocalmemory/server/routes/mesh.py +1 -1
  52. package/src/superlocalmemory/server/routes/v3_api.py +2 -2
  53. package/src/superlocalmemory/server/ui.py +8 -1
  54. package/src/superlocalmemory/server/unified_daemon.py +111 -9
  55. package/src/superlocalmemory/storage/_migration_internals.py +4 -0
  56. package/src/superlocalmemory/storage/database.py +128 -30
  57. package/src/superlocalmemory/storage/migration_runner.py +11 -0
  58. package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +488 -0
  59. package/src/superlocalmemory/storage/schema.py +98 -0
  60. package/src/superlocalmemory/summaries/base.py +1 -1
  61. package/src/superlocalmemory/summaries/non_answer.py +223 -0
  62. package/src/superlocalmemory/ui/index.html +1 -1
  63. package/src/superlocalmemory/ui/js/od-memories.js +190 -1
  64. package/src/superlocalmemory/ui/js/od-ops-health.js +1 -1
@@ -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"]})
@@ -12,7 +12,7 @@ import gzip
12
12
  import hashlib
13
13
  import json
14
14
  import logging
15
- from typing import Optional
15
+ from typing import Any, Optional
16
16
  from datetime import datetime, timezone
17
17
 
18
18
  from fastapi import APIRouter, HTTPException, Query, Request, UploadFile, File
@@ -78,7 +78,22 @@ async def export_memories(
78
78
  use_v3 = False
79
79
 
80
80
  if use_v3:
81
+ # Withheld summaries are excluded, and this is about IMPORT, not
82
+ # tidiness. Import re-ingests every record through the normal
83
+ # pipeline, which mints a fresh memory with quarantined = 0 — so an
84
+ # export taken here and imported anywhere would resurrect all 1,195
85
+ # model-written rows as though the owner had written them, on a
86
+ # machine where nothing had gone wrong. The repair would then have
87
+ # to run again there.
88
+ #
89
+ # Nothing the owner wrote is lost: these are derived artefacts, the
90
+ # consolidator regenerates them from the facts that ARE exported,
91
+ # and their text is kept in consolidated_summaries. (That table is
92
+ # not in this export either — it is a view, not a memory. Worth
93
+ # revisiting when export covers derived state.)
81
94
  query = "SELECT * FROM atomic_facts WHERE profile_id = ?"
95
+ if _has_column(cursor, "atomic_facts", "quarantined"):
96
+ query += " AND COALESCE(quarantined, 0) = 0"
82
97
  params = [active_profile]
83
98
  if category:
84
99
  query += " AND fact_type = ?"
@@ -154,6 +169,19 @@ async def export_memories(
154
169
  raise _internal_error("Export error")
155
170
 
156
171
 
172
+ def _has_column(cursor: Any, table: str, column: str) -> bool:
173
+ """Whether ``table`` carries ``column`` in this database.
174
+
175
+ Presence-guarded because ``quarantined`` arrives with a migration and this
176
+ route must keep working on a store the engine has not opened.
177
+ """
178
+ try:
179
+ cursor.execute(f"PRAGMA table_info({table})")
180
+ return any(row[1] == column for row in cursor.fetchall())
181
+ except Exception: # noqa: BLE001 -- an export must not fail over a probe
182
+ return False
183
+
184
+
157
185
  @router.post("/api/import")
158
186
  async def import_memories(request: Request, file: UploadFile = File(...)):
159
187
  """Import memories from JSON file using V3 engine."""
@@ -84,7 +84,19 @@ def list_entities(
84
84
 
85
85
  conn = get_read_connection(engine._config.db_path)
86
86
  try:
87
- where = ["ce.profile_id = ?"]
87
+ # Hide the placeholder that dated facts with no recognised entity
88
+ # attach their temporal events to (core/store_pipeline.py,
89
+ # _ensure_unresolved_entity). It is a hook for a foreign key, not a
90
+ # concept, and it would otherwise appear here as a nameless entity and
91
+ # be counted in the total the owner reads as "entities I have".
92
+ #
93
+ # Only the presentation layer needs this. The other readers of
94
+ # canonical_entities are unaffected on inspection: graph_pruner uses
95
+ # `NOT IN (SELECT entity_id ...)` to find orphaned edges, where
96
+ # including it is correct; community_summary builds from graph edges and
97
+ # the placeholder has none; scale_engine counts for backend sync, not
98
+ # for display.
99
+ where = ["ce.profile_id = ?", "ce.entity_type != 'unresolved'"]
88
100
  params: list[object] = [profile]
89
101
  if entity_type and entity_type.lower() != "all":
90
102
  where.append("ce.entity_type = ? COLLATE NOCASE")
@@ -408,7 +408,7 @@ def send(req: SendRequest, request: Request):
408
408
  if sig_err is not None:
409
409
  raise HTTPException(401, detail=sig_err.get("error", "signature error"))
410
410
 
411
- # Admission gate parity (closes Wave-1 P1 bypass for inbound remote send).
411
+ # Admission gate parity (closes the P1 bypass for inbound remote send).
412
412
  try:
413
413
  from superlocalmemory.core.admission import (
414
414
  AdmissionDenied,
@@ -266,7 +266,7 @@ def apply_settings_update(config: "SLMConfig", payload: dict) -> "SLMConfig":
266
266
  This is the SINGLE authoritative place where incoming dashboard save payloads
267
267
  are merged onto the stored config. The POST /api/v3/mode/set handler calls
268
268
  this function after its HTTP-layer concerns (SSRF guard, auth) are settled,
269
- so the acceptance gate (test_wave2_acceptance.py::P3) tests real product
269
+ so the test suite (test_acceptance_core.py::P3) tests real product
270
270
  behaviour — not a reimplementation.
271
271
 
272
272
  SEC-L-01 PRESERVED:
@@ -444,7 +444,7 @@ async def set_full_config(request: Request):
444
444
  # Resolve the effective endpoint value before SSRF validation.
445
445
  # Only the Ollama default injection happens here; the fallback to the
446
446
  # stored URL (and redacted-echo detection) live inside
447
- # apply_settings_update so that the P3 acceptance gate exercises
447
+ # apply_settings_update so that the P3 test exercises
448
448
  # the same code path as the HTTP handler — not a reimplementation.
449
449
  _raw_ep: str = "" if clear_base_url else (base_url_input or endpoint_input or "")
450
450
  if (
@@ -231,7 +231,14 @@ def create_app() -> FastAPI:
231
231
  "<p><a href='/api/docs'>API Documentation</a></p>"
232
232
  "</body></html>"
233
233
  )
234
- return index_path.read_text()
234
+ from superlocalmemory.server.asset_versions import render_index
235
+ from superlocalmemory import __version__ as _v
236
+
237
+ # __SLM_VERSION__ was substituted only by the unified daemon, so the
238
+ # dashboard's upgrade detector did nothing when served from here.
239
+ return render_index(
240
+ index_path, UI_DIR, substitutions={"__SLM_VERSION__": _v},
241
+ )
235
242
 
236
243
  @application.get("/favicon.ico", include_in_schema=False)
237
244
  async def favicon():
@@ -52,7 +52,7 @@ os.environ.setdefault("SLM_MCP_EMBEDDED", "1")
52
52
  from fastapi import FastAPI, HTTPException, Request
53
53
  from fastapi.middleware.cors import CORSMiddleware
54
54
  from fastapi.middleware.gzip import GZipMiddleware
55
- from pydantic import BaseModel
55
+ from pydantic import BaseModel, field_validator
56
56
 
57
57
  from superlocalmemory.core.config import CANONICAL_RECALL_LIMIT
58
58
  from superlocalmemory.infra.daemon_identity import (
@@ -587,6 +587,62 @@ class RememberRequest(BaseModel):
587
587
  metadata: dict | None = None # v3.4.26: pass-through from MCP pool_store
588
588
  idempotency_key: str | None = None
589
589
  session_id: str = ""
590
+ #: WHEN this memory is about, as distinct from when it was written.
591
+ #:
592
+ #: The internal admission record has carried this field all along and this
593
+ #: model never had it, so every memory arriving over HTTP — which is every
594
+ #: memory, from the CLI, the tool interface and the dashboard alike — was
595
+ #: stamped with its ingestion date. Measured on the author's store: 200 of
596
+ #: the 200 most recent facts have an observation_date, and 196 of them are
597
+ #: the day they were written. A store that cannot be told "this happened in
598
+ #: March" cannot answer a question about March.
599
+ #:
600
+ #: Empty means "today", the previous behaviour. Format is YYYY-MM-DD or a
601
+ #: full ISO 8601 timestamp.
602
+ session_date: str = ""
603
+
604
+ @field_validator("session_date")
605
+ @classmethod
606
+ def _session_date_is_a_date(cls, value: str) -> str:
607
+ """Reject a malformed date rather than ignore it.
608
+
609
+ Dropping it silently would leave the caller believing the date was
610
+ recorded while the memory quietly filed itself under today — which is
611
+ the exact failure this field exists to fix, reintroduced one layer up.
612
+ Rejecting is recoverable: the caller sees the error and resends.
613
+
614
+ ISO ONLY, DELIBERATELY, even though this system ships a parser that is
615
+ far more permissive. ``encoding/temporal_parser.parse_session_date``
616
+ accepts "May 8, 2026", "1:56 pm on 8 May, 2026", "14/03/2026", and also
617
+ "last tuesday" and "March 2026" — and those last two are why it is not
618
+ used here. Asked on 2026-08-21 it resolves "last tuesday" to
619
+ **2026-08-25**, four days into the future, and "March 2026" to the 21st,
620
+ a day it invents. Accepting either at this boundary would file a memory
621
+ under a confidently wrong date, silently, which is precisely the class
622
+ of defect this field was added to remove.
623
+
624
+ The caller here is a program — the command line, the tool interface, the
625
+ dashboard — not a person typing. ISO is the right contract for a
626
+ program, and a caller holding a human-typed date can run it through the
627
+ parser itself and send the result.
628
+ """
629
+ if not value:
630
+ return value
631
+ from datetime import datetime as _dt
632
+
633
+ text = value.strip()
634
+ try:
635
+ _dt.fromisoformat(text.replace("Z", "+00:00"))
636
+ except ValueError:
637
+ raise ValueError(
638
+ "session_date must be YYYY-MM-DD or a full ISO 8601 timestamp; "
639
+ f"got {value!r}. To accept looser human phrasing, parse it "
640
+ "first with TemporalParser.parse_session_date and send the ISO "
641
+ "result — but check what it returns, because it resolves "
642
+ "relative phrases against today and can answer with a future "
643
+ "date."
644
+ ) from None
645
+ return text
590
646
  # v3.6.15 multi-scope: visibility of the new memory. ``None`` scope means
591
647
  # "use the configured default_scope" (personal). shared_with is the list of
592
648
  # profile_ids for scope='shared'.
@@ -2399,6 +2455,10 @@ async def lifespan(application: FastAPI):
2399
2455
  "state": "checking_components", "embeddings_backfilled": 0,
2400
2456
  "expansion_backfilled": 0, "null_remaining": None,
2401
2457
  "components": None,
2458
+ # Declared here so /status carries the same keys whatever
2459
+ # happens; a field that only appears on failure is a field
2460
+ # nobody's dashboard renders.
2461
+ "incomplete_reason": None,
2402
2462
  "started_at": _t.time(), "finished_at": None,
2403
2463
  }
2404
2464
  # Step 0 (v3.8.2 "whole self-healer"): repair components that
@@ -2507,9 +2567,42 @@ async def lifespan(application: FastAPI):
2507
2567
  _backfill_vector_store()
2508
2568
  except Exception as exc:
2509
2569
  logger.warning("Self-heal vector index failed (non-fatal): %s", exc)
2510
- _SELF_HEAL_STATUS["state"] = "complete"
2570
+
2571
+ # "complete" has to mean complete.
2572
+ #
2573
+ # This line used to set "complete" unconditionally, so a
2574
+ # backfill that gave up after five no-progress attempts, or ran
2575
+ # out its 500-iteration budget, reported success with facts
2576
+ # still unembedded — and an unembedded fact cannot be found by
2577
+ # meaning at all. That is how a machine sat at 56.3% of its
2578
+ # memory reachable while its own status endpoint said the heal
2579
+ # had finished. Nobody was going to look past a green light.
2580
+ #
2581
+ # Now the state is derived from the remaining count, and the
2582
+ # gap is named in plain language for the dashboard, so an
2583
+ # incomplete heal is visible and gets retried on next start
2584
+ # instead of being declared done forever.
2585
+ _remaining = _SELF_HEAL_STATUS.get("null_remaining")
2511
2586
  _SELF_HEAL_STATUS["finished_at"] = _t.time()
2512
- logger.info("Self-heal complete: %s", _SELF_HEAL_STATUS)
2587
+ if _remaining is None:
2588
+ _SELF_HEAL_STATUS["state"] = "complete"
2589
+ elif int(_remaining) > 0:
2590
+ _SELF_HEAL_STATUS["state"] = "incomplete"
2591
+ _SELF_HEAL_STATUS["incomplete_reason"] = (
2592
+ f"{int(_remaining)} memories still have no meaning "
2593
+ f"vector, so they cannot be found by asking a question. "
2594
+ f"This retries automatically next time the service "
2595
+ f"starts. It usually means the embedding model was "
2596
+ f"unavailable."
2597
+ )
2598
+ logger.warning(
2599
+ "Self-heal INCOMPLETE: %d facts still unembedded and "
2600
+ "therefore unreachable by meaning; will retry on next "
2601
+ "start. %s", int(_remaining), _SELF_HEAL_STATUS,
2602
+ )
2603
+ else:
2604
+ _SELF_HEAL_STATUS["state"] = "complete"
2605
+ logger.info("Self-heal complete: %s", _SELF_HEAL_STATUS)
2513
2606
  except Exception as exc:
2514
2607
  _SELF_HEAL_STATUS["state"] = "error"
2515
2608
  logger.warning("Self-heal failed (non-fatal): %s", exc)
@@ -3975,8 +4068,16 @@ def _register_dashboard_routes(application: FastAPI) -> None:
3975
4068
  # v3.4.23: substitute version placeholder so the dashboard can detect
3976
4069
  # upgrades and auto-reload. Read fresh each request (daemon uptime is
3977
4070
  # days, but we want zero caching surprises during development).
3978
- html = index_path.read_text()
3979
- return html.replace("__SLM_VERSION__", _SLM_VERSION)
4071
+ #
4072
+ # 4.0.10: asset ?v= strings are now derived from file content instead of
4073
+ # being hand-written literals that tracked nothing. See
4074
+ # server/asset_versions.py — including what that does and does not fix.
4075
+ from superlocalmemory.server.asset_versions import render_index
4076
+
4077
+ return render_index(
4078
+ index_path, UI_DIR,
4079
+ substitutions={"__SLM_VERSION__": _SLM_VERSION},
4080
+ )
3980
4081
 
3981
4082
  @application.get("/favicon.ico", include_in_schema=False)
3982
4083
  async def favicon():
@@ -4146,7 +4247,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
4146
4247
  "runtime_state": runtime_state,
4147
4248
  "active_profile": profile_snapshot.profile_id,
4148
4249
  "profile_generation": profile_snapshot.generation,
4149
- # Wave-3: operational failure counts (visible to all team members)
4250
+ # operational failure counts (visible to all team members)
4150
4251
  **_ops_failure_counts(engine, application),
4151
4252
  # issue #107: does this daemon's *imported* code still match the
4152
4253
  # installed distribution? ``version`` above reports what this
@@ -4513,6 +4614,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
4513
4614
  shared_with=tuple(shared_with or ()),
4514
4615
  trusted_actor_id=trusted_actor_id,
4515
4616
  session_id=req.session_id,
4617
+ session_date=req.session_date,
4516
4618
  )
4517
4619
  actor = Actor(
4518
4620
  principal_id=trusted_actor_id,
@@ -4859,7 +4961,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
4859
4961
  # index backfill after an upgrade). Dashboard renders a plain
4860
4962
  # "Optimizing memory…" line from this. Defaults to idle before start.
4861
4963
  "self_heal": globals().get("_SELF_HEAL_STATUS", {"state": "idle"}),
4862
- # Wave-3: operational failure counts (dead-letter, degraded, stalled)
4964
+ # operational failure counts (dead-letter, degraded, stalled)
4863
4965
  **_ops_failure_counts(engine, application),
4864
4966
  }
4865
4967
 
@@ -4913,7 +5015,7 @@ def _register_daemon_routes(application: FastAPI) -> None:
4913
5015
  return {"status": "started"}
4914
5016
 
4915
5017
  # ------------------------------------------------------------------
4916
- # Wave-3: Operational Recovery & Admin Remediation (V4 resilience slice)
5018
+ # Operational Recovery & Admin Remediation (V4 resilience slice)
4917
5019
  # ------------------------------------------------------------------
4918
5020
 
4919
5021
  @application.get("/operations/failed")
@@ -5460,7 +5562,7 @@ def _terminalize_orphan_operation(engine, operation_id: str) -> None:
5460
5562
 
5461
5563
 
5462
5564
  def _ops_failure_counts(engine, application) -> dict:
5463
- """Return Wave-3 operational failure counts for /status and /health.
5565
+ """Return operational failure counts for /status and /health.
5464
5566
 
5465
5567
  Always returns a dict (never raises). Counts default to 0 on any error.
5466
5568
  Includes: dead_letter_count, degraded_operations, exhausted_obligations,
@@ -153,6 +153,9 @@ from superlocalmemory.storage.migrations import (
153
153
  from superlocalmemory.storage.migrations import (
154
154
  M042_correction_case_ledger as _M042,
155
155
  )
156
+ from superlocalmemory.storage.migrations import (
157
+ M043_quarantine_display_summaries as _M043,
158
+ )
156
159
 
157
160
  # Emit under the runner's logger name so operational log filters that key on
158
161
  # "superlocalmemory.storage.migration_runner" keep matching after this split.
@@ -203,6 +206,7 @@ _MODULES = {
203
206
  _M040.NAME: _M040,
204
207
  _M041.NAME: _M041,
205
208
  _M042.NAME: _M042,
209
+ _M043.NAME: _M043,
206
210
  }
207
211
 
208
212
  # Exact historical DDL fingerprints whose resulting schema is intentionally