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
|
@@ -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=
|
|
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')
|
|
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');
|