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
@@ -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, ""
@@ -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=022ff653"></script>
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>
@@ -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') _loadProjectOptions(id);
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');
@@ -1,4 +1,4 @@
1
- /* od-ops-health.js — Wave-3 Admin Operations & Health panel
1
+ /* od-ops-health.js — Admin Operations & Health panel
2
2
  * Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar — AGPL-3.0-or-later
3
3
  *
4
4
  * Injects an "Admin: Ops Health" tab into the Governance pane tab bar