superlocalmemory 4.0.8 → 4.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +221 -1
- package/README.md +6 -6
- 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/scripts/ensure-venv.sh +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 +3 -3
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +2 -2
- package/pyproject.toml +1 -1
- package/scripts/postinstall.js +4 -0
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/_lazy_init.py +1 -1
- package/src/superlocalmemory/cli/commands.py +164 -11
- package/src/superlocalmemory/cli/db_migrate.py +0 -2
- package/src/superlocalmemory/cli/gdpr_io.py +1 -1
- package/src/superlocalmemory/cli/main.py +7 -7
- package/src/superlocalmemory/cli/service_installer.py +2 -1
- package/src/superlocalmemory/cli/setup_wizard.py +1 -1
- package/src/superlocalmemory/code_graph/bridge/maintenance.py +8 -0
- package/src/superlocalmemory/core/config.py +41 -7
- package/src/superlocalmemory/core/context_cache.py +0 -2
- package/src/superlocalmemory/core/engine.py +371 -63
- package/src/superlocalmemory/core/evidence_bundle.py +3 -1
- package/src/superlocalmemory/core/fact_consolidator.py +316 -125
- package/src/superlocalmemory/core/install_detector.py +131 -0
- 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/progressive_abstraction.py +1 -1
- package/src/superlocalmemory/core/security_primitives.py +3 -6
- package/src/superlocalmemory/core/store_pipeline.py +172 -29
- package/src/superlocalmemory/core/topic_signature.py +0 -2
- package/src/superlocalmemory/core/transactions/concrete_owners.py +15 -8
- package/src/superlocalmemory/dynamics/eap_scheduler.py +17 -6
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +15 -1
- package/src/superlocalmemory/encoding/graph_builder.py +2 -2
- package/src/superlocalmemory/encoding/scene_builder.py +8 -2
- package/src/superlocalmemory/hooks/adapter_base.py +0 -2
- package/src/superlocalmemory/hooks/context_payload.py +0 -2
- package/src/superlocalmemory/hooks/portable_kit.py +8 -8
- package/src/superlocalmemory/hooks/post_tool_async_hook.py +0 -2
- package/src/superlocalmemory/hooks/prewarm_auth.py +0 -2
- package/src/superlocalmemory/hooks/user_prompt_hook.py +0 -2
- package/src/superlocalmemory/infra/backup.py +44 -8
- package/src/superlocalmemory/integrations/bounded_loops_mcp.py +24 -7
- package/src/superlocalmemory/learning/arm_catalog.py +0 -2
- package/src/superlocalmemory/learning/bandit.py +0 -2
- package/src/superlocalmemory/learning/bandit_cache.py +0 -2
- package/src/superlocalmemory/learning/dedup_hnsw.py +11 -11
- package/src/superlocalmemory/learning/ensemble.py +0 -2
- package/src/superlocalmemory/learning/labeler.py +0 -2
- package/src/superlocalmemory/learning/legacy_migration.py +0 -2
- package/src/superlocalmemory/learning/model_cache.py +0 -2
- package/src/superlocalmemory/learning/ranker.py +0 -2
- package/src/superlocalmemory/learning/reward_archive.py +6 -1
- package/src/superlocalmemory/learning/reward_proxy.py +0 -2
- package/src/superlocalmemory/learning/signal_worker.py +0 -2
- package/src/superlocalmemory/math/fisher.py +1 -1
- package/src/superlocalmemory/math/hopfield.py +4 -1
- package/src/superlocalmemory/math/langevin.py +1 -1
- package/src/superlocalmemory/math/sheaf.py +7 -3
- package/src/superlocalmemory/mcp/cli_fallback.py +1 -1
- package/src/superlocalmemory/mcp/profiles.py +1 -1
- package/src/superlocalmemory/mcp/server.py +2 -2
- package/src/superlocalmemory/mcp/session_binding.py +92 -0
- package/src/superlocalmemory/mcp/tools_active.py +56 -0
- package/src/superlocalmemory/mcp/tools_core.py +41 -40
- package/src/superlocalmemory/mcp/tools_ops.py +2 -2
- package/src/superlocalmemory/optimize/cache/manager.py +2 -2
- package/src/superlocalmemory/optimize/compress/ccr.py +1 -1
- package/src/superlocalmemory/optimize/compress/router.py +1 -1
- package/src/superlocalmemory/optimize/proxy/_helpers.py +2 -2
- package/src/superlocalmemory/optimize/proxy/server.py +1 -1
- package/src/superlocalmemory/optimize/proxy/vertex_surface.py +2 -2
- package/src/superlocalmemory/optimize/storage/db.py +2 -2
- package/src/superlocalmemory/retrieval/agentic.py +1 -1
- package/src/superlocalmemory/retrieval/ann_index.py +9 -2
- package/src/superlocalmemory/retrieval/bm25_channel.py +6 -10
- package/src/superlocalmemory/retrieval/bridge_discovery.py +2 -2
- package/src/superlocalmemory/retrieval/engine.py +272 -43
- package/src/superlocalmemory/retrieval/entity_channel.py +8 -2
- package/src/superlocalmemory/retrieval/hopfield_channel.py +8 -2
- package/src/superlocalmemory/retrieval/profile_channel.py +1 -1
- package/src/superlocalmemory/retrieval/quantization_aware_search.py +1 -1
- package/src/superlocalmemory/retrieval/remote_reranker.py +2 -2
- package/src/superlocalmemory/retrieval/reranker.py +3 -3
- package/src/superlocalmemory/retrieval/scope_policy.py +22 -1
- package/src/superlocalmemory/retrieval/semantic_channel.py +3 -3
- package/src/superlocalmemory/retrieval/spreading_activation.py +8 -8
- package/src/superlocalmemory/retrieval/strategy.py +94 -0
- package/src/superlocalmemory/retrieval/temporal_channel.py +179 -10
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +1 -1
- package/src/superlocalmemory/retrieval/vector_store.py +151 -10
- package/src/superlocalmemory/server/api.py +6 -1
- package/src/superlocalmemory/server/asset_versions.py +171 -0
- package/src/superlocalmemory/server/recall_serializer.py +10 -0
- package/src/superlocalmemory/server/routes/abstraction.py +201 -0
- package/src/superlocalmemory/server/routes/brain.py +2 -2
- 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/prewarm.py +4 -4
- 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 +570 -16
- package/src/superlocalmemory/storage/_migration_internals.py +4 -0
- package/src/superlocalmemory/storage/_schema_version.py +46 -3
- package/src/superlocalmemory/storage/backup.py +531 -0
- package/src/superlocalmemory/storage/database.py +139 -34
- package/src/superlocalmemory/storage/embedding_codec.py +129 -0
- package/src/superlocalmemory/storage/embedding_migrator.py +5 -3
- package/src/superlocalmemory/storage/migration_runner.py +153 -2
- package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +488 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +1 -1
- package/src/superlocalmemory/storage/migrations.py +15 -1
- package/src/superlocalmemory/storage/models.py +7 -0
- package/src/superlocalmemory/storage/quantized_store.py +4 -2
- 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/summaries/session_summary.py +1 -1
- package/src/superlocalmemory/ui/css/legacy-dashboard.css +1 -1
- package/src/superlocalmemory/ui/css/neural-glass.css +1 -1
- package/src/superlocalmemory/ui/index.html +1 -1
- package/src/superlocalmemory/ui/js/core.js +1 -1
- package/src/superlocalmemory/ui/js/od-brain.js +1 -1
- package/src/superlocalmemory/ui/js/od-memories.js +190 -1
- package/src/superlocalmemory/ui/js/od-ops-health.js +1 -1
|
@@ -21,9 +21,12 @@ Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
|
21
21
|
|
|
22
22
|
from __future__ import annotations
|
|
23
23
|
|
|
24
|
+
import logging
|
|
24
25
|
import sqlite3
|
|
25
26
|
from typing import Final
|
|
26
27
|
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
27
30
|
# ---------------------------------------------------------------------------
|
|
28
31
|
# Constants
|
|
29
32
|
# ---------------------------------------------------------------------------
|
|
@@ -57,6 +60,7 @@ _TABLES: Final[tuple[str, ...]] = (
|
|
|
57
60
|
"config",
|
|
58
61
|
"entity_communities",
|
|
59
62
|
"community_summaries",
|
|
63
|
+
"consolidated_summaries",
|
|
60
64
|
"persona_summary",
|
|
61
65
|
)
|
|
62
66
|
|
|
@@ -190,6 +194,11 @@ CREATE TABLE IF NOT EXISTS atomic_facts (
|
|
|
190
194
|
CHECK (lifecycle IN (
|
|
191
195
|
'active', 'warm', 'cold', 'archived'
|
|
192
196
|
)),
|
|
197
|
+
-- Withheld from retrieval without being destroyed. Set by repair, never
|
|
198
|
+
-- by a normal write; enforced in exactly one place,
|
|
199
|
+
-- DatabaseManager.get_facts_by_ids, which every channel's candidates are
|
|
200
|
+
-- re-authorised through and which the engine hydrates from.
|
|
201
|
+
quarantined INTEGER NOT NULL DEFAULT 0,
|
|
193
202
|
langevin_position TEXT,
|
|
194
203
|
|
|
195
204
|
-- Emotional
|
|
@@ -842,6 +851,51 @@ CREATE INDEX IF NOT EXISTS idx_comm_summ_profile
|
|
|
842
851
|
ON community_summaries(profile_id);
|
|
843
852
|
"""
|
|
844
853
|
|
|
854
|
+
# Display-only consolidated summaries.
|
|
855
|
+
#
|
|
856
|
+
# A summary of a cluster of facts is a VIEW of memory, not a memory. Between
|
|
857
|
+
# v3.6.4 and 4.0.9 the fact consolidator wrote its summaries straight into
|
|
858
|
+
# atomic_facts with a raw INSERT, which put model-authored prose into the
|
|
859
|
+
# retrieval corpus alongside the user's own words — where it out-ranked them,
|
|
860
|
+
# because those rows carried every entity in their cluster and so had more
|
|
861
|
+
# entity links than any real fact.
|
|
862
|
+
#
|
|
863
|
+
# This table restores the boundary. community_summaries is the precedent to
|
|
864
|
+
# read it by: written by one owner, read only after retrieval has finished, and
|
|
865
|
+
# named by no channel. tests/test_retrieval/test_summaries_stay_out_of_recall.py
|
|
866
|
+
# fails if a retrieval module so much as mentions it.
|
|
867
|
+
#
|
|
868
|
+
# source_earliest / source_latest are the honest dates for a derived row. A
|
|
869
|
+
# summary has no observation_date of its own — it was never observed — but the
|
|
870
|
+
# span of what it summarises is real, and it is what lets the dashboard say
|
|
871
|
+
# which stretch of work a summary covers.
|
|
872
|
+
CONSOLIDATED_SUMMARIES_DDL: Final[str] = """
|
|
873
|
+
CREATE TABLE IF NOT EXISTS consolidated_summaries (
|
|
874
|
+
summary_id TEXT PRIMARY KEY,
|
|
875
|
+
profile_id TEXT NOT NULL,
|
|
876
|
+
entity_id TEXT NOT NULL DEFAULT '',
|
|
877
|
+
entity_name TEXT NOT NULL DEFAULT '',
|
|
878
|
+
content TEXT NOT NULL,
|
|
879
|
+
source_fact_ids TEXT NOT NULL DEFAULT '[]',
|
|
880
|
+
source_count INTEGER NOT NULL DEFAULT 0,
|
|
881
|
+
char_count INTEGER NOT NULL DEFAULT 0,
|
|
882
|
+
generated_by TEXT NOT NULL DEFAULT 'extractive'
|
|
883
|
+
CHECK (generated_by IN (
|
|
884
|
+
'extractive', 'ollama', 'cloud', 'migrated'
|
|
885
|
+
)),
|
|
886
|
+
scope TEXT NOT NULL DEFAULT 'personal',
|
|
887
|
+
shared_with TEXT,
|
|
888
|
+
source_earliest TEXT,
|
|
889
|
+
source_latest TEXT,
|
|
890
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
891
|
+
UNIQUE (profile_id, entity_id, content)
|
|
892
|
+
);
|
|
893
|
+
CREATE INDEX IF NOT EXISTS idx_consolidated_summaries_profile
|
|
894
|
+
ON consolidated_summaries(profile_id, created_at DESC);
|
|
895
|
+
CREATE INDEX IF NOT EXISTS idx_consolidated_summaries_entity
|
|
896
|
+
ON consolidated_summaries(profile_id, entity_id);
|
|
897
|
+
"""
|
|
898
|
+
|
|
845
899
|
# Wave Q3: progressive-abstraction top tier — one persona roll-up per profile
|
|
846
900
|
# consuming the top community summaries (additive; safe on existing DBs).
|
|
847
901
|
# Recall-gated (never auto-injected into hot recall) and size-bounded to avoid
|
|
@@ -893,6 +947,7 @@ _DDL_ORDERED: Final[tuple[str, ...]] = (
|
|
|
893
947
|
_SQL_ENTITY_COMMUNITIES,
|
|
894
948
|
# Wave Q2: community summaries (additive; safe on existing DBs)
|
|
895
949
|
_SQL_COMMUNITY_SUMMARIES,
|
|
950
|
+
CONSOLIDATED_SUMMARIES_DDL,
|
|
896
951
|
# Wave Q3: persona roll-up tier (additive; safe on existing DBs)
|
|
897
952
|
_SQL_PERSONA_SUMMARY,
|
|
898
953
|
)
|
|
@@ -902,6 +957,38 @@ _DDL_ORDERED: Final[tuple[str, ...]] = (
|
|
|
902
957
|
# Public API
|
|
903
958
|
# ---------------------------------------------------------------------------
|
|
904
959
|
|
|
960
|
+
#: (table, column, column definition). Applied with ALTER TABLE ADD COLUMN,
|
|
961
|
+
#: which SQLite offers no IF NOT EXISTS form of, so presence is checked first.
|
|
962
|
+
_ADDITIVE_COLUMNS: Final[tuple[tuple[str, str, str], ...]] = (
|
|
963
|
+
("atomic_facts", "quarantined", "INTEGER NOT NULL DEFAULT 0"),
|
|
964
|
+
)
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
def _add_missing_columns(conn: sqlite3.Connection) -> None:
|
|
968
|
+
"""Add columns that upgraded databases predate. Idempotent.
|
|
969
|
+
|
|
970
|
+
A missing table is not an error: this runs inside create_all_tables, so the
|
|
971
|
+
table is created moments earlier in the same call, and a database old enough
|
|
972
|
+
to lack it entirely has nothing to alter.
|
|
973
|
+
"""
|
|
974
|
+
for table, column, definition in _ADDITIVE_COLUMNS:
|
|
975
|
+
try:
|
|
976
|
+
present = any(
|
|
977
|
+
row[1] == column
|
|
978
|
+
for row in conn.execute(f"PRAGMA table_info({table})")
|
|
979
|
+
)
|
|
980
|
+
if not present:
|
|
981
|
+
conn.execute(
|
|
982
|
+
f"ALTER TABLE {table} ADD COLUMN {column} {definition}"
|
|
983
|
+
)
|
|
984
|
+
except sqlite3.Error as exc:
|
|
985
|
+
# Never fatal. A store that cannot take the column keeps working;
|
|
986
|
+
# get_facts_by_ids checks for the column before filtering on it.
|
|
987
|
+
logger.warning(
|
|
988
|
+
"additive column %s.%s not applied: %s", table, column, exc,
|
|
989
|
+
)
|
|
990
|
+
|
|
991
|
+
|
|
905
992
|
def create_all_tables(conn: sqlite3.Connection) -> None:
|
|
906
993
|
"""Create every table, index, trigger, and FTS virtual table.
|
|
907
994
|
|
|
@@ -921,6 +1008,17 @@ def create_all_tables(conn: sqlite3.Connection) -> None:
|
|
|
921
1008
|
for ddl in V32_DDL:
|
|
922
1009
|
conn.executescript(ddl)
|
|
923
1010
|
|
|
1011
|
+
# Additive columns on tables that predate them.
|
|
1012
|
+
#
|
|
1013
|
+
# CREATE TABLE IF NOT EXISTS cannot add a column to a table that already
|
|
1014
|
+
# exists, so an upgraded database gets the column here rather than only from
|
|
1015
|
+
# a migration. Doing it at every engine init makes the invariant "if
|
|
1016
|
+
# atomic_facts exists then quarantined exists" hold even when the migration
|
|
1017
|
+
# pass failed or was never reached — which matters because withholding a
|
|
1018
|
+
# poisoned row from retrieval must not be contingent on a migration having
|
|
1019
|
+
# succeeded.
|
|
1020
|
+
_add_missing_columns(conn)
|
|
1021
|
+
|
|
924
1022
|
# Seed schema version on first run.
|
|
925
1023
|
existing = conn.execute(
|
|
926
1024
|
"SELECT COUNT(*) AS n FROM schema_version"
|
|
@@ -62,7 +62,7 @@ class SummaryResult:
|
|
|
62
62
|
|
|
63
63
|
# ── coverage constants ──────────────────────────────────────────────────────
|
|
64
64
|
#
|
|
65
|
-
# Use these strings; the
|
|
65
|
+
# Use these strings; the tests check for their presence
|
|
66
66
|
# and the values must be human-interpretable without this file.
|
|
67
67
|
|
|
68
68
|
COVERAGE_FULL = "full"
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
|
|
4
|
+
"""Refuse to store a model's non-answer as if it were a memory.
|
|
5
|
+
|
|
6
|
+
A summarizer is handed a cluster of facts and asked to merge them. When the
|
|
7
|
+
cluster has nothing in common the model does not fail — it answers the question
|
|
8
|
+
it was asked, in prose, and that prose is indistinguishable from a summary to
|
|
9
|
+
any caller that only checks ``if summary:``. On the author's own store the
|
|
10
|
+
result was rows reading
|
|
11
|
+
|
|
12
|
+
"Unfortunately, there is no information available about 'Gateway', 'State',
|
|
13
|
+
'Bounded', or 'Claude' in the provided text."
|
|
14
|
+
|
|
15
|
+
sitting at ranks 1, 2 and 3 for "what am I working on".
|
|
16
|
+
|
|
17
|
+
WHAT THIS IS AND IS NOT
|
|
18
|
+
-----------------------
|
|
19
|
+
This is a **forward guard**: it stops the next such row from being written. It
|
|
20
|
+
is deliberately NOT the repair for rows already stored, because it cannot be.
|
|
21
|
+
Measured across the 307 retrieval-eligible consolidated rows on the author's
|
|
22
|
+
store, this predicate rejects **34** and lets **273** through — because those
|
|
23
|
+
273 are fluent, plausible, entirely generic prose ("The Pro and
|
|
24
|
+
SuperLocalMemory (SLM) projects have made significant progress...") that no
|
|
25
|
+
honest content predicate can separate from a real summary. Repairing by content
|
|
26
|
+
would have cleared a ninth of the problem and declared victory. Existing rows
|
|
27
|
+
are handled by provenance instead.
|
|
28
|
+
|
|
29
|
+
Measured the other way, on 3,894 genuine facts, it rejects 70 — and all 70 are
|
|
30
|
+
real defects, not false positives: 68 memories carry raw tool-call markup
|
|
31
|
+
scraped in from a transcript, and 2 are a model's refusal that was stored as
|
|
32
|
+
though it were a memory ("I cannot verify when ... ended a session. Can I help
|
|
33
|
+
you with something else?"). Zero legitimate memories are rejected.
|
|
34
|
+
|
|
35
|
+
So the bar here is: catch text that is *addressed to the prompt* rather than
|
|
36
|
+
*about the facts*, and nothing else. Everything is anchored to the start of the
|
|
37
|
+
text or to a whole leading sentence, because a genuine memory may legitimately
|
|
38
|
+
contain "there is no" in the middle of a sentence.
|
|
39
|
+
|
|
40
|
+
Companion to ``clean_llm_summary`` in :mod:`superlocalmemory.summaries.base`,
|
|
41
|
+
which strips scaffolding *around* an answer. This one rejects text that is
|
|
42
|
+
scaffolding *all the way through*. Run the stripper first: "Here is a concise
|
|
43
|
+
summary paragraph: <real content>" is salvageable and must not be discarded.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import re
|
|
49
|
+
|
|
50
|
+
__all__ = ["is_non_answer", "NON_ANSWER_PATTERNS", "MIN_USEFUL_CHARS"]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
#: A *merged summary* this short did not merge anything. The summarizers already
|
|
54
|
+
#: refuse model output under 50 characters; this is the same floor applied to
|
|
55
|
+
#: text that arrived by another route (extractive mode, or a pre-computed summary
|
|
56
|
+
#: handed in by a caller).
|
|
57
|
+
#:
|
|
58
|
+
#: It is NOT a floor for memories in general, and ``is_non_answer`` therefore
|
|
59
|
+
#: does not apply it unless a caller asks. Measured on the author's store, 730
|
|
60
|
+
#: of 3,894 genuine facts are under 50 characters and every sampled one is a
|
|
61
|
+
#: real memory — "2026-05-02 is the date when the session ended",
|
|
62
|
+
#: "This is the case for keeping AMS." Baking this floor into the default would
|
|
63
|
+
#: have made the guard reject a fifth of a user's memory as junk. The floor is
|
|
64
|
+
#: the *caller's* policy about its own output, not a fact about text.
|
|
65
|
+
MIN_USEFUL_CHARS = 50
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
#: Each entry is (regex, why-it-is-not-a-memory). The reason travels with the
|
|
69
|
+
#: pattern so a future reader can tell whether a new false positive means the
|
|
70
|
+
#: pattern is wrong or the input genuinely is a non-answer.
|
|
71
|
+
_PATTERN_SOURCES: tuple[tuple[str, str], ...] = (
|
|
72
|
+
(
|
|
73
|
+
r"^\W*(?:unfortunately|regrettably|sadly)\b[^.!?]*\bno\b",
|
|
74
|
+
"opens by apologising for having nothing to say",
|
|
75
|
+
),
|
|
76
|
+
(
|
|
77
|
+
r"^\W*there\s+(?:is|are)\s+no\s+"
|
|
78
|
+
r"(?:information|mention|reference|facts?|details?|data|content)\b",
|
|
79
|
+
"states the absence of input rather than summarising input",
|
|
80
|
+
),
|
|
81
|
+
(
|
|
82
|
+
r"\bno\s+(?:information|facts?|details?|data)\s+"
|
|
83
|
+
r"(?:is|are|was|were)?\s*(?:available|provided|given|present)\b",
|
|
84
|
+
"reports an empty input set",
|
|
85
|
+
),
|
|
86
|
+
(
|
|
87
|
+
r"^\W*(?:i\s+(?:cannot|can't|can\s+not|am\s+unable\s+to)|"
|
|
88
|
+
r"it\s+is\s+not\s+possible\s+to)\b",
|
|
89
|
+
"declines the task",
|
|
90
|
+
),
|
|
91
|
+
(
|
|
92
|
+
r"^\W*(?:i\s+don'?t|i\s+do\s+not)\s+(?:have|see|find)\b",
|
|
93
|
+
"declines the task in the first person",
|
|
94
|
+
),
|
|
95
|
+
(
|
|
96
|
+
r"\bthe\s+(?:provided|given|above|following)\s+"
|
|
97
|
+
r"(?:text|facts?|context|input|information)\b",
|
|
98
|
+
"refers to the prompt, so it is talking to the asker, not about the memory",
|
|
99
|
+
),
|
|
100
|
+
(
|
|
101
|
+
r"^\W*(?:as\s+an?\s+(?:ai|language\s+model)|i'?m\s+an?\s+ai)\b",
|
|
102
|
+
"identifies itself as a model",
|
|
103
|
+
),
|
|
104
|
+
(
|
|
105
|
+
r"^\W*(?:please\s+)?(?:provide|share|give)\s+(?:me\s+)?"
|
|
106
|
+
r"(?:more|the|some|additional)\b",
|
|
107
|
+
"asks the user for input instead of answering",
|
|
108
|
+
),
|
|
109
|
+
# The five below were added after running the first four against all 1,035
|
|
110
|
+
# summaries stored on the author's machine. They catch 14 rows the original
|
|
111
|
+
# set let through -- every one a measured string from that store, not a
|
|
112
|
+
# guess about what a model might say.
|
|
113
|
+
(
|
|
114
|
+
r"^\W*i\s+(?:did\s*n[o']?t|didn'?t)\s+receive\b",
|
|
115
|
+
"says it was given nothing to summarise",
|
|
116
|
+
),
|
|
117
|
+
(
|
|
118
|
+
r"\bthere\s+(?:is|are|was|were)\s+n(?:o|ot)\s+\d+\s+facts?\b",
|
|
119
|
+
"argues with the number of facts it was asked to merge",
|
|
120
|
+
),
|
|
121
|
+
(
|
|
122
|
+
r"\bthe\s+text\s+(?:snippet\s+)?"
|
|
123
|
+
r"(?:appears|seems|does\s+not|doesn'?t|is\s+not)\b",
|
|
124
|
+
"describes the prompt instead of summarising it",
|
|
125
|
+
),
|
|
126
|
+
(
|
|
127
|
+
r"\bin\s+the\s+text\s+(?:provided|given|above|supplied)\b"
|
|
128
|
+
r"|\bthe\s+text\s+(?:provided|supplied)\b",
|
|
129
|
+
"refers to the prompt (word order the earlier rule missed)",
|
|
130
|
+
),
|
|
131
|
+
(
|
|
132
|
+
r"^\W*i\s+must\s+point\s+out\b",
|
|
133
|
+
"opens with meta-commentary about the request",
|
|
134
|
+
),
|
|
135
|
+
(
|
|
136
|
+
# "This appears to be a detailed log of progress in writing..." — the
|
|
137
|
+
# model describing the shape of what it was shown rather than saying
|
|
138
|
+
# what it says. Six of the first twelve rows the dashboard rendered
|
|
139
|
+
# opened this way. Deliberately narrower than it could be: "This is a
|
|
140
|
+
# summary of an audit session" is clumsy but it does summarise, so it
|
|
141
|
+
# is left alone.
|
|
142
|
+
r"^\W*this\s+(?:appears|seems)\s+to\s+be\b"
|
|
143
|
+
r"|^\W*this\s+text\s+(?:is|appears|seems)\b",
|
|
144
|
+
"describes the shape of the input instead of its content",
|
|
145
|
+
),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
#: Compiled once. ``re.IGNORECASE`` throughout — the casing of a refusal is not
|
|
149
|
+
#: information. ``re.DOTALL`` is deliberately NOT set: ``[^.!?]*`` and the
|
|
150
|
+
#: leading anchors are meant to stay within the opening sentence.
|
|
151
|
+
NON_ANSWER_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = tuple(
|
|
152
|
+
(re.compile(src, re.IGNORECASE), why) for src, why in _PATTERN_SOURCES
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
#: Tool-call and markup fragments. A memory whose content carries these was
|
|
156
|
+
#: assembled from a transcript that still had its plumbing attached; the user
|
|
157
|
+
#: sees raw XML in their own memory list. Matched anywhere, not anchored,
|
|
158
|
+
#: because the fragment can appear at any offset in a spliced transcript.
|
|
159
|
+
_MARKUP = re.compile(
|
|
160
|
+
r"</(?:content|antml:parameter|parameter|invoke|function_calls|thinking)>"
|
|
161
|
+
r"|<(?:antml:)?(?:parameter|invoke|function_calls)\b"
|
|
162
|
+
r"|<\|(?:im_start|im_end|endoftext)\|>",
|
|
163
|
+
re.IGNORECASE,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def is_non_answer(
|
|
168
|
+
text: str | None,
|
|
169
|
+
*,
|
|
170
|
+
min_chars: int = 0,
|
|
171
|
+
model_written: bool = True,
|
|
172
|
+
) -> tuple[bool, str]:
|
|
173
|
+
"""Whether ``text`` is a model talking about the prompt, not a memory.
|
|
174
|
+
|
|
175
|
+
Returns ``(rejected, reason)``. ``reason`` is empty when the text is
|
|
176
|
+
acceptable, and otherwise names which rule fired — callers log it, so a
|
|
177
|
+
silent rejection never happens and a false positive is diagnosable from
|
|
178
|
+
normal logs rather than needing a repro.
|
|
179
|
+
|
|
180
|
+
``min_chars`` defaults to **no floor**. A caller that knows its own output
|
|
181
|
+
should be long — a summarizer merging three or more facts — passes
|
|
182
|
+
:data:`MIN_USEFUL_CHARS`. See that constant for why this is not the default.
|
|
183
|
+
|
|
184
|
+
``model_written=False`` applies only the rules that hold for text no model
|
|
185
|
+
composed: the length floor and the markup check. Every other rule here
|
|
186
|
+
describes a MODEL's behaviour — refusing, apologising, discussing the
|
|
187
|
+
prompt — and an extractive summary is the owner's own sentences reassembled,
|
|
188
|
+
so a memory that happens to say "the facts provided" would be rejected for
|
|
189
|
+
having phrased itself like a chatbot. Nothing is lost by exempting it: an
|
|
190
|
+
extractive summary cannot refuse, because there is nobody there to refuse.
|
|
191
|
+
|
|
192
|
+
Cheap and side-effect free: safe to call on every candidate write.
|
|
193
|
+
|
|
194
|
+
>>> is_non_answer("Unfortunately, there is no information available.")[0]
|
|
195
|
+
True
|
|
196
|
+
>>> is_non_answer("Varun ships SLM 4.0.10 with an auto-repair migration.")
|
|
197
|
+
(False, '')
|
|
198
|
+
>>> is_non_answer("The team has no information silo left to dismantle.")[0]
|
|
199
|
+
False
|
|
200
|
+
>>> is_non_answer("2026-05-02 is the date the session ended.")
|
|
201
|
+
(False, '')
|
|
202
|
+
>>> is_non_answer("Too short.", min_chars=MIN_USEFUL_CHARS)[0]
|
|
203
|
+
True
|
|
204
|
+
"""
|
|
205
|
+
if text is None:
|
|
206
|
+
return True, "empty"
|
|
207
|
+
stripped = text.strip()
|
|
208
|
+
if not stripped:
|
|
209
|
+
return True, "empty"
|
|
210
|
+
if min_chars and len(stripped) < min_chars:
|
|
211
|
+
return True, f"shorter than {min_chars} characters"
|
|
212
|
+
|
|
213
|
+
if _MARKUP.search(stripped):
|
|
214
|
+
return True, "contains tool-call or chat-template markup"
|
|
215
|
+
|
|
216
|
+
if not model_written:
|
|
217
|
+
return False, ""
|
|
218
|
+
|
|
219
|
+
for pattern, why in NON_ANSWER_PATTERNS:
|
|
220
|
+
if pattern.search(stripped):
|
|
221
|
+
return True, why
|
|
222
|
+
|
|
223
|
+
return False, ""
|
|
@@ -8,7 +8,7 @@ CRITICAL DATA HONESTY NOTE
|
|
|
8
8
|
---------------------------
|
|
9
9
|
On a real 3,294-fact store, only 127 facts (3.9%) carry a session_id.
|
|
10
10
|
A Session Summary that presents itself as "everything you did this session"
|
|
11
|
-
while silently covering 4% of the facts is the same
|
|
11
|
+
while silently covering 4% of the facts is overclaiming — the same defect
|
|
12
12
|
removed from brain/truth.py.
|
|
13
13
|
|
|
14
14
|
Coverage is ALWAYS disclosed. If the session has too few facts to summarise
|
|
@@ -1604,7 +1604,7 @@
|
|
|
1604
1604
|
<script src="static/js/od-ops-health.js?v=400"></script>
|
|
1605
1605
|
<script src="static/js/od-team.js?v=379"></script>
|
|
1606
1606
|
<script src="static/js/od-graph.js?v=6812bf6c"></script>
|
|
1607
|
-
<script src="static/js/od-memories.js?v=
|
|
1607
|
+
<script src="static/js/od-memories.js?v=997f1674"></script>
|
|
1608
1608
|
<script src="static/js/od-entities.js?v=379"></script>
|
|
1609
1609
|
<!-- Multi-Agent Memory pane (v3.8.0): visualises memory written by multiple agents -->
|
|
1610
1610
|
<script src="static/js/od-agents.js?v=c75ff3c2"></script>
|
|
@@ -292,7 +292,7 @@ function showEmpty(containerId, icon, message) {
|
|
|
292
292
|
}
|
|
293
293
|
|
|
294
294
|
// ============================================================================
|
|
295
|
-
// Pane error state
|
|
295
|
+
// Pane error state
|
|
296
296
|
// showPaneError / clearPaneError / paneErrorMessage
|
|
297
297
|
// ============================================================================
|
|
298
298
|
|
|
@@ -774,7 +774,7 @@
|
|
|
774
774
|
var evb = EL('div', { className: 'card-pad' });
|
|
775
775
|
|
|
776
776
|
if (registryStatus === 'error' || registryStatus === 'unknown') {
|
|
777
|
-
//
|
|
777
|
+
// Honesty constraint: failure and emptiness must not return the same value.
|
|
778
778
|
// 'unknown' belongs HERE, not in the healthy-empty branch below. The
|
|
779
779
|
// registry reader is fail-soft — a corrupt or unreadable file makes
|
|
780
780
|
// _load() return {} and active_client_summary() return [], so the read
|
|
@@ -189,6 +189,18 @@
|
|
|
189
189
|
'</select>' +
|
|
190
190
|
'</div>' +
|
|
191
191
|
'<div id="' + id + '-sum-out" style="font-size:13px;color:var(--fg-2)">Pick a summary above.</div>' +
|
|
192
|
+
// Knowledge Overview.
|
|
193
|
+
//
|
|
194
|
+
// The Today/Yesterday buttons above are a date filter over
|
|
195
|
+
// atomic_facts and have never had any link to a cluster, so the
|
|
196
|
+
// Summaries tab could not show what the store actually knows ABOUT
|
|
197
|
+
// anything. This card fills that: cluster summaries, read from the
|
|
198
|
+
// display-only table, which is where they belong now that they are
|
|
199
|
+
// out of the retrieval corpus.
|
|
200
|
+
'<div id="' + id + '-kover" style="margin-top:26px;' +
|
|
201
|
+
'border-top:1px solid var(--border);padding-top:20px">' +
|
|
202
|
+
_loading('Loading what your memory knows…') +
|
|
203
|
+
'</div>' +
|
|
192
204
|
'</div>' +
|
|
193
205
|
// Recall Lab pane — exact IDs required by recall-lab.js:
|
|
194
206
|
// #recall-lab-query (input), #recall-lab-search (button — click check),
|
|
@@ -363,7 +375,7 @@
|
|
|
363
375
|
if (pane) pane.classList.add('active');
|
|
364
376
|
if (tab === 'timeline') _loadTimeline(id);
|
|
365
377
|
if (tab === 'clusters') _loadClusters(id);
|
|
366
|
-
if (tab === 'summary')
|
|
378
|
+
if (tab === 'summary') { _loadProjectOptions(id); _loadKnowledgeOverview(id); }
|
|
367
379
|
}
|
|
368
380
|
|
|
369
381
|
/* Populate the project picker from projects SLM has actually recorded.
|
|
@@ -815,6 +827,183 @@
|
|
|
815
827
|
|
|
816
828
|
// ── Clusters fetch & render ──────────────────────────────────────────────────
|
|
817
829
|
|
|
830
|
+
/* Knowledge Overview — cluster summaries, plus an honest health line.
|
|
831
|
+
*
|
|
832
|
+
* Two rules this card exists to obey.
|
|
833
|
+
*
|
|
834
|
+
* IT READS ONLY THE DISPLAY TABLE. These summaries were in the retrieval
|
|
835
|
+
* corpus until 4.0.10, where they out-ranked the user's own words. Moving
|
|
836
|
+
* them out is only worth anything if exactly one surface shows them, and
|
|
837
|
+
* this is that surface.
|
|
838
|
+
*
|
|
839
|
+
* IT DOES NOT DRESS UP JUNK AS INSIGHT. Some stored summaries are a model's
|
|
840
|
+
* non-answer -- "Unfortunately, there is no information available about
|
|
841
|
+
* 'State'..." -- and rendering those as a heading called Knowledge Overview
|
|
842
|
+
* makes the product look broken. The endpoint labels each row's quality; the
|
|
843
|
+
* usable ones are shown and the rest are counted in one plain sentence. Not
|
|
844
|
+
* hidden: hiding them would hide the very problem the release fixes, and a
|
|
845
|
+
* reader who is told "3 could not be generated" trusts the other 47 more.
|
|
846
|
+
*/
|
|
847
|
+
function _loadKnowledgeOverview(id) {
|
|
848
|
+
var box = document.getElementById(id + '-kover');
|
|
849
|
+
if (!box) return;
|
|
850
|
+
// Guarded on the element, not a module flag — same reason as
|
|
851
|
+
// _loadProjectOptions: a stale "already loaded" boolean outlives the DOM
|
|
852
|
+
// it described and leaves a permanent spinner after a re-render.
|
|
853
|
+
if (box.dataset.loaded === '1') return;
|
|
854
|
+
|
|
855
|
+
Promise.all([
|
|
856
|
+
fetch('/api/v3/abstraction/consolidated?limit=24')
|
|
857
|
+
.then(function (r) { return r.ok ? r.json() : { summaries: [], unusable: 0 }; })
|
|
858
|
+
.catch(function () { return { summaries: [], unusable: 0 }; }),
|
|
859
|
+
fetch('/api/v3/abstraction/health')
|
|
860
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
861
|
+
.catch(function () { return null; })
|
|
862
|
+
]).then(function (res) {
|
|
863
|
+
box.dataset.loaded = '1';
|
|
864
|
+
box.innerHTML = _koverHtml(res[0] || {}, res[1]);
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function _koverHtml(data, health) {
|
|
869
|
+
var all = data.summaries || [];
|
|
870
|
+
var usable = all.filter(function (s) { return s.quality === 'ok'; });
|
|
871
|
+
var unusable = typeof data.unusable === 'number' ? data.unusable
|
|
872
|
+
: (all.length - usable.length);
|
|
873
|
+
|
|
874
|
+
var html = '<h3 style="font-size:15px;margin:0 0 4px">What your memory knows</h3>' +
|
|
875
|
+
'<p style="font-size:12px;color:var(--fg-2);margin:0 0 14px">' +
|
|
876
|
+
'Written by the summarizer from groups of your memories. These are a ' +
|
|
877
|
+
'view of your memory, not memories themselves — they are never returned ' +
|
|
878
|
+
'as answers.' +
|
|
879
|
+
'</p>';
|
|
880
|
+
|
|
881
|
+
if (health) html += _koverHealth(health);
|
|
882
|
+
|
|
883
|
+
if (usable.length === 0) {
|
|
884
|
+
html += '<div style="padding:20px;color:var(--fg-2);font-size:13px">' +
|
|
885
|
+
(all.length === 0
|
|
886
|
+
? 'No summaries yet. They are written in the background as related ' +
|
|
887
|
+
'memories accumulate.'
|
|
888
|
+
: 'None of the ' + all.length + ' stored summaries came back usable. ' +
|
|
889
|
+
'That usually means the summarizer had nothing in common to merge.') +
|
|
890
|
+
'</div>';
|
|
891
|
+
return html;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
html += '<div style="display:grid;gap:12px;' +
|
|
895
|
+
'grid-template-columns:repeat(auto-fill,minmax(280px,1fr))">';
|
|
896
|
+
usable.forEach(function (s) { html += _koverCard(s); });
|
|
897
|
+
html += '</div>';
|
|
898
|
+
|
|
899
|
+
var notes = [];
|
|
900
|
+
if (unusable > 0) {
|
|
901
|
+
notes.push(unusable + (unusable === 1 ? ' summary' : ' summaries') +
|
|
902
|
+
' could not be generated and are not shown. The memories they were ' +
|
|
903
|
+
'built from are unaffected.');
|
|
904
|
+
}
|
|
905
|
+
if (data.near_duplicates > 0) {
|
|
906
|
+
notes.push(data.near_duplicates + ' near-identical ' +
|
|
907
|
+
(data.near_duplicates === 1 ? 'summary was' : 'summaries were') +
|
|
908
|
+
' collapsed into the ones above.');
|
|
909
|
+
}
|
|
910
|
+
if (notes.length) {
|
|
911
|
+
html += '<p style="font-size:12px;color:var(--fg-2);margin-top:12px">' +
|
|
912
|
+
notes.map(_esc).join(' ') + '</p>';
|
|
913
|
+
}
|
|
914
|
+
return html;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function _koverCard(s) {
|
|
918
|
+
// Every field is escaped. Anything in a memory can reach this screen: the
|
|
919
|
+
// summarizer merges fact content verbatim, so a memory containing markup
|
|
920
|
+
// would otherwise render as markup.
|
|
921
|
+
var body = _koverPlain(s.content);
|
|
922
|
+
var clipped = body.length > 320;
|
|
923
|
+
var shown = clipped ? body.slice(0, 320).replace(/\s+\S*$/, '') + '…' : body;
|
|
924
|
+
|
|
925
|
+
var meta = [];
|
|
926
|
+
if (s.source_count) {
|
|
927
|
+
meta.push(s.source_count + (s.source_count === 1 ? ' memory' : ' memories'));
|
|
928
|
+
}
|
|
929
|
+
var span = _koverSpan(s.source_earliest, s.source_latest);
|
|
930
|
+
if (span) meta.push(span);
|
|
931
|
+
|
|
932
|
+
return '<div style="background:var(--card-2);border:1px solid var(--border);' +
|
|
933
|
+
'border-radius:var(--r-md);padding:14px">' +
|
|
934
|
+
(s.entity_name
|
|
935
|
+
? '<div style="font-size:13px;font-weight:600;margin-bottom:6px">' +
|
|
936
|
+
_esc(s.entity_name) + '</div>'
|
|
937
|
+
: '') +
|
|
938
|
+
'<div style="font-size:13px;line-height:1.5">' + _esc(shown) + '</div>' +
|
|
939
|
+
(meta.length
|
|
940
|
+
? '<div style="margin-top:10px;display:flex;gap:6px;flex-wrap:wrap">' +
|
|
941
|
+
meta.map(_koverChip).join('') +
|
|
942
|
+
'</div>'
|
|
943
|
+
: '') +
|
|
944
|
+
'</div>';
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/* Flatten a summary to plain prose for display.
|
|
948
|
+
*
|
|
949
|
+
* Mode B/C summaries come back with markdown in them — "**Audit Round**",
|
|
950
|
+
* "**Findings**:" — because a chat-tuned model formats for a chat window.
|
|
951
|
+
* Rendered as text those asterisks read as broken output to exactly the
|
|
952
|
+
* reader this card is for. Escaping is still what protects the page; this
|
|
953
|
+
* only removes the emphasis marks that carry no meaning here.
|
|
954
|
+
*/
|
|
955
|
+
function _koverPlain(text) {
|
|
956
|
+
return String(text || '')
|
|
957
|
+
.replace(/```[\s\S]*?```/g, ' ') // fenced blocks: no room for them
|
|
958
|
+
.replace(/`([^`]*)`/g, '$1') // inline code marks
|
|
959
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1') // bold
|
|
960
|
+
.replace(/(^|\s)\*([^*\s][^*]*)\*/g, '$1$2') // italic, not bullets
|
|
961
|
+
.replace(/^#{1,6}\s+/gm, '') // headings
|
|
962
|
+
.replace(/^\s*[-*+]\s+/gm, '') // list bullets
|
|
963
|
+
.replace(/\s+/g, ' ') // collapse whitespace + newlines
|
|
964
|
+
.trim();
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/* A chip, not a keyword dump.
|
|
968
|
+
*
|
|
969
|
+
* The neighbouring community-summary API returns its `summary` field as
|
|
970
|
+
* "Topics: CCQ, Resume, P07…", which printed verbatim reads as a broken
|
|
971
|
+
* page. Counts and date spans are rendered as discrete labels instead.
|
|
972
|
+
*/
|
|
973
|
+
function _koverChip(text) {
|
|
974
|
+
return '<span style="font-size:11px;padding:2px 8px;border-radius:999px;' +
|
|
975
|
+
'background:var(--card);border:1px solid var(--border);color:var(--fg-2)">' +
|
|
976
|
+
_esc(text) + '</span>';
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function _koverSpan(earliest, latest) {
|
|
980
|
+
var a = String(earliest || '').slice(0, 10);
|
|
981
|
+
var b = String(latest || '').slice(0, 10);
|
|
982
|
+
if (!a && !b) return '';
|
|
983
|
+
if (!a || !b || a === b) return a || b;
|
|
984
|
+
return a + ' to ' + b;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/* Whether the memories behind all this can actually be found.
|
|
988
|
+
*
|
|
989
|
+
* The one question no screen answered before. A machine ran for months with
|
|
990
|
+
* 43.7% of its store unreachable by meaning while every status line it showed
|
|
991
|
+
* said healthy, because nothing counted the reachable share.
|
|
992
|
+
*/
|
|
993
|
+
function _koverHealth(h) {
|
|
994
|
+
var lines = h.summary || [];
|
|
995
|
+
if (!lines.length) return '';
|
|
996
|
+
var bad = h.healthy === false;
|
|
997
|
+
return '<div style="margin:0 0 16px;padding:12px 14px;border-radius:var(--r-md);' +
|
|
998
|
+
'background:var(--card-2);border-left:3px solid ' +
|
|
999
|
+
(bad ? 'var(--warn)' : 'var(--ok)') + '">' +
|
|
1000
|
+
lines.map(function (l) {
|
|
1001
|
+
return '<div style="font-size:12px;color:var(--fg-2);line-height:1.5">' +
|
|
1002
|
+
_esc(l) + '</div>';
|
|
1003
|
+
}).join('') +
|
|
1004
|
+
'</div>';
|
|
1005
|
+
}
|
|
1006
|
+
|
|
818
1007
|
function _loadClusters(id) {
|
|
819
1008
|
if (_st.cluLoaded) return;
|
|
820
1009
|
var grid = document.getElementById(id + '-clu-grid');
|