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
@@ -73,6 +73,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
73
73
  scope: str | None = None,
74
74
  shared_with: str = "",
75
75
  idempotency_key: str = "",
76
+ session_date: str = "",
76
77
  ) -> dict:
77
78
  """Store content to memory with intelligent indexing.
78
79
 
@@ -82,11 +83,35 @@ def register_core_tools(server, get_engine: Callable) -> None:
82
83
  Multi-scope: ``scope`` sets visibility (personal/shared/global).
83
84
  ``shared_with`` is a comma-separated list of profile_ids for
84
85
  shared scope.
86
+
87
+ ``session_date`` says WHEN the memory is about, as opposed to when it
88
+ was written. Omit it and the memory is dated today, which is what every
89
+ memory got before 4.0.10 because there was no way to say otherwise.
90
+ Accepts YYYY-MM-DD or a full ISO 8601 timestamp.
85
91
  """
86
92
  # v3.6.10: resolve "mcp_client" sentinel → URL path (HTTP) or env var (stdio)
87
93
  if agent_id == "mcp_client":
88
94
  from superlocalmemory.mcp.agent_context import get_current_agent_id
89
95
  agent_id = get_current_agent_id()
96
+ # Bind the write to a session the same way the read path does.
97
+ #
98
+ # recall has resolved this through a four-step ladder since S9-DASH-10;
99
+ # remember stored whatever it was handed, which for a caller that does
100
+ # not pass one is nothing. Result on the author's store: 192 of 3,894
101
+ # facts carry a session_id (4.9%). The engine's session-diversity
102
+ # promotion cannot promote a fact with no session, so it was running
103
+ # against a corpus where 95% of rows looked like the same session.
104
+ #
105
+ # allow_agent_fallback is OFF here, unlike recall. `mcp:<agent_id>` is a
106
+ # useful key for settling one outcome; as a stored session_id it would
107
+ # file every memory an agent ever wrote under one session, and diversity
108
+ # promotion would then treat a whole history as a single conversation —
109
+ # worse than the empty string it replaces.
110
+ from superlocalmemory.mcp.session_binding import resolve_session_id
111
+
112
+ session_id = resolve_session_id(
113
+ session_id, agent_id=agent_id, allow_agent_fallback=False,
114
+ )
90
115
  meta = {
91
116
  "project": project,
92
117
  "importance": importance,
@@ -141,6 +166,7 @@ def register_core_tools(server, get_engine: Callable) -> None:
141
166
  "content": content, "tags": tags, "metadata": meta,
142
167
  "scope": scope, "shared_with": _shared_list,
143
168
  "session_id": session_id,
169
+ "session_date": session_date,
144
170
  "idempotency_key": effective_idempotency_key or None,
145
171
  })
146
172
  if resp and (resp.get("fact_ids") is not None or resp.get("ok")):
@@ -320,45 +346,20 @@ def register_core_tools(server, get_engine: Callable) -> None:
320
346
  import asyncio
321
347
  try:
322
348
  from superlocalmemory.mcp._daemon_proxy import choose_pool
323
- # S9-DASH-10: priority for session_id, so engagement
324
- # signals land on the right pending_outcome:
325
- # 1. Explicit ``session_id`` tool-call argument.
326
- # 2. ``SLM_SESSION_ID`` / ``CLAUDE_SESSION_ID`` env var.
327
- # 3. Most-recent-active Claude session from the hook
328
- # registry (last 60s). This catches the common case
329
- # where Claude Code's hooks ran the UserPromptSubmit
330
- # hook right before invoking the MCP tool.
331
- # 4. Stable per-agent fallback ``mcp:<agent_id>`` the
332
- # Stop hook will NOT match this, so the reaper
333
- # settles it at neutral 0.5.
334
- effective_sid = session_id
335
- if not effective_sid:
336
- import os as _os
337
- effective_sid = (
338
- _os.environ.get("SLM_SESSION_ID")
339
- or _os.environ.get("CLAUDE_SESSION_ID")
340
- or ""
341
- )
342
- if not effective_sid:
343
- try:
344
- from superlocalmemory.hooks.session_registry import (
345
- lookup_by_parent,
346
- most_recent_active,
347
- )
348
- # Parent-PID lookup is collision-free across multiple
349
- # parallel Claude sessions (each MCP server's parent
350
- # is the IDE that spawned it).
351
- effective_sid = (
352
- lookup_by_parent(within_seconds=60)
353
- or most_recent_active(
354
- agent_type="claude", within_seconds=60,
355
- )
356
- or ""
357
- )
358
- except Exception:
359
- pass
360
- if not effective_sid:
361
- effective_sid = f"mcp:{agent_id}"
349
+ from superlocalmemory.mcp.session_binding import resolve_session_id
350
+
351
+ # S9-DASH-10's four-step ladder, now shared with remember() so the
352
+ # read path and the write path cannot disagree about which session
353
+ # they are in. remember() had no ladder at all, which is why 95% of
354
+ # stored facts carry no session_id. See mcp/session_binding.py.
355
+ #
356
+ # The per-agent fallback stays ON here: this id settles a pending
357
+ # outcome, and `mcp:<agent_id>` is deliberately not matched by the
358
+ # Stop hook, so the reaper settles it at a neutral 0.5 rather than
359
+ # attributing engagement to a session that never existed.
360
+ effective_sid = resolve_session_id(
361
+ session_id, agent_id=agent_id, allow_agent_fallback=True,
362
+ )
362
363
  # Resolve the daemon proxy inside the worker too. ``choose_pool``
363
364
  # verifies daemon ownership through a synchronous /health request;
364
365
  # when this tool is served by the daemon's mounted HTTP MCP app,
@@ -2,7 +2,7 @@
2
2
  # Licensed under AGPL-3.0-or-later - see LICENSE file
3
3
  # Part of SuperLocalMemory V4 | https://qualixar.com | https://varunpratap.com
4
4
 
5
- """Wave-3 Operational Recovery & Admin Remediation MCP tools (2 tools).
5
+ """Operational Recovery & Admin Remediation MCP tools (2 tools).
6
6
 
7
7
  list_failed_operations — Surface dead-letter, degraded, and exhausted ops.
8
8
  resolve_operation — Admin retry/force-reconcile/cancel for stuck ops.
@@ -28,7 +28,7 @@ _VALID_ACTIONS = frozenset({"retry", "force_reconcile", "cancel"})
28
28
 
29
29
 
30
30
  def register_ops_tools(server, get_engine: Callable) -> None:
31
- """Register Wave-3 operational-recovery MCP tools on *server*."""
31
+ """Register operational-recovery MCP tools on *server*."""
32
32
 
33
33
  # ------------------------------------------------------------------
34
34
  # 1. list_failed_operations — surface all stuck/failed/degraded ops
@@ -221,14 +221,10 @@ class BM25Channel:
221
221
  include_shared=include_shared,
222
222
  prefix="af",
223
223
  )
224
- # Archived facts are not live; never surface them in keyword recall.
225
- # Guarded on column presence: the archive column is added by a deferred
226
- # migration and may be absent on an unmigrated database.
227
- archive_clause = (
228
- " AND COALESCE(af.archive_status, 'live') != 'archived'"
229
- if self._db._has_archive_status()
230
- else ""
231
- )
224
+ # Soft-deleted and withheld rows are not live. Filtered HERE rather
225
+ # than only at hydration so neither spends one of this channel's top_k
226
+ # slots; the shared clause keeps the definition in one place.
227
+ archive_clause = self._db.visible_fact_clause("af")
232
228
  sql = (
233
229
  "SELECT af.fact_id AS fact_id, bm25(atomic_facts_fts) AS rank "
234
230
  "FROM atomic_facts_fts "
@@ -398,9 +398,15 @@ class EntityGraphChannel:
398
398
  include_global=include_global,
399
399
  include_shared=include_shared,
400
400
  )
401
+ # Withheld rows must not enter the entity map at all. They carry
402
+ # their whole cluster's pooled entity list, which is exactly why
403
+ # they out-ranked real memories here in the first place — leaving
404
+ # them in the map would keep spending this channel's budget on
405
+ # candidates that hydration then discards.
401
406
  rows = self._db.execute(
402
407
  "SELECT fact_id, canonical_entities_json "
403
- f"FROM atomic_facts WHERE {where} "
408
+ f"FROM atomic_facts WHERE {where}"
409
+ f"{self._db.visible_fact_clause()} "
404
410
  "ORDER BY created_at DESC LIMIT ?",
405
411
  (*params, _unbounded_facts_ceiling()),
406
412
  )
@@ -53,9 +53,30 @@ def authorized_fact_ids(
53
53
  include_shared=include_shared,
54
54
  )
55
55
  placeholders = ",".join("?" for _ in unique_ids)
56
+ # Mirror the primary path's visibility rule, not just its scope rule.
57
+ # This branch exists for lightweight wrappers that expose execute() but
58
+ # not get_facts_by_ids, and it was authorizing withheld and
59
+ # soft-deleted rows that the primary path refuses -- so any channel
60
+ # whose db object took this branch had a different idea of what is
61
+ # visible than the engine that hydrates its results.
62
+ # Resolved on the TYPE, not the instance. A MagicMock fabricates any
63
+ # attribute you ask for, so an instance check returns a callable that
64
+ # returns another MagicMock, whose repr then lands in the SQL string and
65
+ # makes the whole query a syntax error -- and this function's `except`
66
+ # turns that into an empty authorized set, i.e. every candidate silently
67
+ # dropped. tests/test_retrieval/test_spreading_activation.py caught it
68
+ # by passing exactly such a mock. The same reasoning is already written
69
+ # up in retrieval/engine.py for the reranker's optional contract.
70
+ visible = ""
71
+ clause_fn = getattr(type(db), "visible_fact_clause", None)
72
+ if callable(clause_fn):
73
+ try:
74
+ visible = clause_fn(db)
75
+ except Exception: # noqa: BLE001 -- fall back to scope-only
76
+ visible = ""
56
77
  rows = db.execute(
57
78
  f"SELECT fact_id FROM atomic_facts WHERE fact_id IN ({placeholders}) "
58
- f"AND {where}",
79
+ f"AND {where}{visible}",
59
80
  (*unique_ids, *params),
60
81
  )
61
82
  if not isinstance(rows, list):
@@ -231,6 +231,14 @@ class TemporalChannel:
231
231
  )
232
232
 
233
233
  for name in names[:3]: # Limit to first 3 entity mentions
234
+ # An empty name would match the placeholder entity that dated
235
+ # facts with no resolved entity attach their temporal events to
236
+ # (core/store_pipeline.py::_ensure_unresolved_entity, whose
237
+ # canonical_name is ''). Entity extraction does not produce an
238
+ # empty name, so this guard costs nothing and removes the one way
239
+ # the placeholder could ever surface as a real entity match.
240
+ if not name or not name.strip():
241
+ continue
234
242
  # Resolve the entity and event in one scope-filtered query. Looking
235
243
  # up the entity only in the requester's profile made global events
236
244
  # owned by another profile undiscoverable before authorization was
@@ -370,10 +378,14 @@ class TemporalChannel:
370
378
  include_shared=include_shared,
371
379
  prefix="af",
372
380
  )
381
+ # The recency fallback is where these rows won before: they have no
382
+ # temporal_events at all, so they reached the top through created_at
383
+ # alone. Fifty slots, and 1,195 withheld rows all written recently.
373
384
  rows = self._db.execute(
374
385
  "SELECT af.fact_id, af.created_at "
375
386
  "FROM atomic_facts AS af "
376
- f"WHERE {where} "
387
+ f"WHERE {where}"
388
+ f"{self._db.visible_fact_clause('af')} "
377
389
  " AND af.created_at >= datetime('now', '-90 days') "
378
390
  "ORDER BY af.created_at DESC, af.fact_id ASC "
379
391
  "LIMIT 50",
@@ -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,)
@@ -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