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.
- package/CHANGELOG.md +75 -0
- package/README.md +3 -3
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +45 -2
- package/src/superlocalmemory/cli/main.py +2 -2
- package/src/superlocalmemory/code_graph/bridge/maintenance.py +8 -0
- package/src/superlocalmemory/core/fact_consolidator.py +316 -125
- package/src/superlocalmemory/core/maintenance.py +44 -6
- package/src/superlocalmemory/core/memory_health.py +266 -0
- package/src/superlocalmemory/core/operation_policy_registry.py +1 -1
- package/src/superlocalmemory/core/operation_request.py +1 -1
- package/src/superlocalmemory/core/ops_remediation.py +2 -2
- package/src/superlocalmemory/core/store_pipeline.py +78 -3
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +15 -1
- package/src/superlocalmemory/mcp/server.py +1 -1
- package/src/superlocalmemory/mcp/session_binding.py +92 -0
- package/src/superlocalmemory/mcp/tools_core.py +40 -39
- package/src/superlocalmemory/mcp/tools_ops.py +2 -2
- package/src/superlocalmemory/retrieval/bm25_channel.py +4 -8
- package/src/superlocalmemory/retrieval/entity_channel.py +7 -1
- package/src/superlocalmemory/retrieval/scope_policy.py +22 -1
- package/src/superlocalmemory/retrieval/temporal_channel.py +13 -1
- package/src/superlocalmemory/retrieval/vector_store.py +63 -0
- package/src/superlocalmemory/server/api.py +6 -1
- package/src/superlocalmemory/server/asset_versions.py +171 -0
- package/src/superlocalmemory/server/routes/abstraction.py +201 -0
- package/src/superlocalmemory/server/routes/data_io.py +29 -1
- package/src/superlocalmemory/server/routes/entity.py +13 -1
- package/src/superlocalmemory/server/routes/mesh.py +1 -1
- package/src/superlocalmemory/server/routes/v3_api.py +2 -2
- package/src/superlocalmemory/server/ui.py +8 -1
- package/src/superlocalmemory/server/unified_daemon.py +111 -9
- package/src/superlocalmemory/storage/_migration_internals.py +4 -0
- package/src/superlocalmemory/storage/database.py +128 -30
- package/src/superlocalmemory/storage/migration_runner.py +11 -0
- package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +488 -0
- package/src/superlocalmemory/storage/schema.py +98 -0
- package/src/superlocalmemory/summaries/base.py +1 -1
- package/src/superlocalmemory/summaries/non_answer.py +223 -0
- package/src/superlocalmemory/ui/index.html +1 -1
- package/src/superlocalmemory/ui/js/od-memories.js +190 -1
- 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
|
-
|
|
324
|
-
|
|
325
|
-
#
|
|
326
|
-
#
|
|
327
|
-
#
|
|
328
|
-
#
|
|
329
|
-
#
|
|
330
|
-
#
|
|
331
|
-
#
|
|
332
|
-
#
|
|
333
|
-
#
|
|
334
|
-
effective_sid =
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
"""
|
|
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
|
|
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
|
-
#
|
|
225
|
-
#
|
|
226
|
-
#
|
|
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
|
-
|
|
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
|