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
|
@@ -31,6 +31,16 @@ if TYPE_CHECKING:
|
|
|
31
31
|
|
|
32
32
|
logger = logging.getLogger(__name__)
|
|
33
33
|
|
|
34
|
+
|
|
35
|
+
class _ConsolidationDisabled(Exception):
|
|
36
|
+
"""Internal signal: consolidation is switched off, so skip its block.
|
|
37
|
+
|
|
38
|
+
A private exception rather than restructuring the surrounding try/except:
|
|
39
|
+
the block's job is to keep one optional maintenance step from taking the
|
|
40
|
+
whole pass down with it, and that guarantee should not be weakened to
|
|
41
|
+
express "deliberately skipped". Caught immediately below, never propagated.
|
|
42
|
+
"""
|
|
43
|
+
|
|
34
44
|
# Backfill constants
|
|
35
45
|
_BACKFILL_BURN_IN_STEPS = 50
|
|
36
46
|
_LANGEVIN_DIM = 8
|
|
@@ -638,9 +648,12 @@ def run_maintenance(
|
|
|
638
648
|
logger.warning("Entity summary consolidation failed: %s", exc)
|
|
639
649
|
|
|
640
650
|
# 4. Fact consolidation (v3.8.4 concurrency-safe path via DatabaseManager).
|
|
641
|
-
#
|
|
642
|
-
#
|
|
643
|
-
#
|
|
651
|
+
# Groups warm/cold atomic facts that share an entity and writes ONE
|
|
652
|
+
# DISPLAY summary per cluster into consolidated_summaries, with provenance
|
|
653
|
+
# in fact_consolidations. It does not write to atomic_facts and does not
|
|
654
|
+
# archive the source facts — until 4.0.10 it did both, which put 1,195
|
|
655
|
+
# model-written rows into the retrieval corpus and left 528 genuine
|
|
656
|
+
# memories archived out of normal recall.
|
|
644
657
|
#
|
|
645
658
|
# Uses the DatabaseManager path so LLM calls happen OUTSIDE the write lock:
|
|
646
659
|
# - Discover clusters in a short memory_read() (no write lock held).
|
|
@@ -652,20 +665,45 @@ def run_maintenance(
|
|
|
652
665
|
try:
|
|
653
666
|
from superlocalmemory.core.fact_consolidator import consolidate_facts
|
|
654
667
|
|
|
668
|
+
# The documented off-switch has to actually switch something off.
|
|
669
|
+
# ConsolidationConfig.enabled has existed since Phase 5 and this call
|
|
670
|
+
# site never read it, so a user who ran `slm config` to turn
|
|
671
|
+
# consolidation off got consolidation anyway — for four months, on
|
|
672
|
+
# every maintenance pass. -2 is a third distinguishable value, kept
|
|
673
|
+
# apart from 0 (nothing to merge) and -1 (the step failed), so a
|
|
674
|
+
# deliberately disabled step is never mistaken for either.
|
|
675
|
+
_consolidation = getattr(config, "consolidation", None)
|
|
676
|
+
if _consolidation is not None and not getattr(_consolidation, "enabled", True):
|
|
677
|
+
counts["facts_consolidated"] = -2
|
|
678
|
+
logger.debug("Fact consolidation disabled by configuration")
|
|
679
|
+
raise _ConsolidationDisabled
|
|
680
|
+
|
|
655
681
|
fc_stats = consolidate_facts(
|
|
656
682
|
db,
|
|
657
683
|
profile_id=profile_id,
|
|
658
|
-
|
|
684
|
+
# Read from ConsolidationConfig, with the old SLMConfig-level name
|
|
685
|
+
# as the fallback. `getattr(config, "max_consolidation_clusters")`
|
|
686
|
+
# alone never resolved — SLMConfig has no such attribute — so the
|
|
687
|
+
# default was the only value this had ever used.
|
|
688
|
+
max_clusters=int(
|
|
689
|
+
getattr(_consolidation, "max_consolidation_clusters", None)
|
|
690
|
+
or getattr(config, "max_consolidation_clusters", None)
|
|
691
|
+
or 20
|
|
692
|
+
),
|
|
659
693
|
dry_run=False,
|
|
660
694
|
config=config,
|
|
661
695
|
)
|
|
662
696
|
counts["facts_consolidated"] = fc_stats.get("consolidated", 0)
|
|
663
697
|
if fc_stats.get("consolidated", 0) > 0:
|
|
664
698
|
logger.info(
|
|
665
|
-
"Fact consolidation: %d
|
|
699
|
+
"Fact consolidation: %d display summaries over %d facts "
|
|
700
|
+
"(%d clusters refused)",
|
|
666
701
|
fc_stats.get("consolidated", 0),
|
|
667
|
-
fc_stats.get("
|
|
702
|
+
fc_stats.get("facts_summarized", 0),
|
|
703
|
+
fc_stats.get("rejected", 0),
|
|
668
704
|
)
|
|
705
|
+
except _ConsolidationDisabled:
|
|
706
|
+
pass
|
|
669
707
|
except Exception as exc:
|
|
670
708
|
# WARNING, not debug, and a distinguishable count. Leaving this at debug
|
|
671
709
|
# with facts_consolidated=0 made a failing consolidation report exactly
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
|
|
4
|
+
"""Tell the owner, in their own words, whether their memory works.
|
|
5
|
+
|
|
6
|
+
Until now the only way to learn that 43.7% of a store could not be found by
|
|
7
|
+
asking a question was to write the SQL yourself. One machine sat in exactly
|
|
8
|
+
that state for months while every status line it showed said the system was
|
|
9
|
+
healthy, because nothing measured reachability and nothing reported it.
|
|
10
|
+
|
|
11
|
+
So this module answers four questions a non-engineer can act on:
|
|
12
|
+
|
|
13
|
+
* How many memories do I have?
|
|
14
|
+
* How many can actually be found by asking a question?
|
|
15
|
+
* How many were withheld because a model wrote them, not me?
|
|
16
|
+
* Is anything still being repaired?
|
|
17
|
+
|
|
18
|
+
Read-only, and every query is bounded. Fail-soft by construction: a missing
|
|
19
|
+
table or column yields ``None`` for that line rather than an exception, because
|
|
20
|
+
a health report that crashes on an old store is worse than one that says "not
|
|
21
|
+
known yet".
|
|
22
|
+
|
|
23
|
+
Consumed by ``slm doctor``, ``GET /api/v3/memory-health``, and the dashboard.
|
|
24
|
+
One implementation so the three cannot disagree with each other.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import logging
|
|
30
|
+
import re
|
|
31
|
+
import sqlite3
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
__all__ = ["MemoryHealth", "measure", "describe"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class MemoryHealth:
|
|
42
|
+
"""A store's answer-ability, counted rather than assumed."""
|
|
43
|
+
|
|
44
|
+
#: Memories that recall is allowed to return.
|
|
45
|
+
live_facts: int = 0
|
|
46
|
+
#: Of those, how many have a vector projection, i.e. can be found by
|
|
47
|
+
#: meaning rather than only by matching words.
|
|
48
|
+
findable_by_meaning: int = 0
|
|
49
|
+
#: Memories with no vector at all. These are reachable by keyword only.
|
|
50
|
+
missing_vector: int = 0
|
|
51
|
+
#: Machine-written summaries withheld from recall and kept for display.
|
|
52
|
+
withheld_summaries: int = 0
|
|
53
|
+
#: Summaries preserved in the display table.
|
|
54
|
+
display_summaries: int = 0
|
|
55
|
+
#: Memories hidden by the retention system, excluding the withheld ones.
|
|
56
|
+
hidden_by_forgetting: int = 0
|
|
57
|
+
#: Rows whose retention zone contradicts their retention score, i.e. hidden
|
|
58
|
+
#: while the maths says to keep them. Should be zero after repair.
|
|
59
|
+
inconsistently_hidden: int = 0
|
|
60
|
+
#: Present only when a table or column was absent.
|
|
61
|
+
unavailable: tuple[str, ...] = field(default_factory=tuple)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def reachability(self) -> float:
|
|
65
|
+
"""Share of live memories findable by meaning, 0.0-1.0."""
|
|
66
|
+
if self.live_facts <= 0:
|
|
67
|
+
return 1.0
|
|
68
|
+
return self.findable_by_meaning / self.live_facts
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def healthy(self) -> bool:
|
|
72
|
+
"""Whether anything here warrants telling the owner about."""
|
|
73
|
+
return (
|
|
74
|
+
self.reachability >= 0.99
|
|
75
|
+
and self.missing_vector == 0
|
|
76
|
+
and self.inconsistently_hidden == 0
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def measure(db_path: str | Path) -> MemoryHealth:
|
|
81
|
+
"""Count the store's answer-ability. Read-only; never raises."""
|
|
82
|
+
unavailable: list[str] = []
|
|
83
|
+
try:
|
|
84
|
+
conn = sqlite3.connect(f"file:{Path(db_path)}?mode=ro", uri=True)
|
|
85
|
+
except sqlite3.Error as exc:
|
|
86
|
+
logger.debug("memory health: cannot open %s: %s", db_path, exc)
|
|
87
|
+
return MemoryHealth(unavailable=("database",))
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
if not _table_exists(conn, "atomic_facts"):
|
|
91
|
+
return MemoryHealth(unavailable=("atomic_facts",))
|
|
92
|
+
|
|
93
|
+
# Quarantine came in 4.0.10. On an older store every fact is "live",
|
|
94
|
+
# which is the honest reading of a store that has no withheld rows.
|
|
95
|
+
has_q = _has_column(conn, "atomic_facts", "quarantined")
|
|
96
|
+
if not has_q:
|
|
97
|
+
unavailable.append("quarantined")
|
|
98
|
+
live_clause = "COALESCE(quarantined, 0) = 0" if has_q else "1=1"
|
|
99
|
+
|
|
100
|
+
live = _count(conn, f"SELECT COUNT(*) FROM atomic_facts WHERE {live_clause}")
|
|
101
|
+
withheld = (
|
|
102
|
+
_count(conn, "SELECT COUNT(*) FROM atomic_facts WHERE quarantined = 1")
|
|
103
|
+
if has_q else 0
|
|
104
|
+
)
|
|
105
|
+
missing_vec = _count(
|
|
106
|
+
conn,
|
|
107
|
+
f"SELECT COUNT(*) FROM atomic_facts "
|
|
108
|
+
f"WHERE embedding IS NULL AND {live_clause}",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if _table_exists(conn, "embedding_metadata"):
|
|
112
|
+
findable = _count(
|
|
113
|
+
conn,
|
|
114
|
+
"SELECT COUNT(*) FROM embedding_metadata em "
|
|
115
|
+
"JOIN atomic_facts af ON af.fact_id = em.fact_id "
|
|
116
|
+
f"WHERE {_prefixed(live_clause, 'af')}",
|
|
117
|
+
)
|
|
118
|
+
else:
|
|
119
|
+
unavailable.append("embedding_metadata")
|
|
120
|
+
findable = 0
|
|
121
|
+
|
|
122
|
+
display = (
|
|
123
|
+
_count(conn, "SELECT COUNT(*) FROM consolidated_summaries")
|
|
124
|
+
if _table_exists(conn, "consolidated_summaries") else 0
|
|
125
|
+
)
|
|
126
|
+
if not _table_exists(conn, "consolidated_summaries"):
|
|
127
|
+
unavailable.append("consolidated_summaries")
|
|
128
|
+
|
|
129
|
+
hidden = inconsistent = 0
|
|
130
|
+
if _table_exists(conn, "fact_retention"):
|
|
131
|
+
hidden = _count(
|
|
132
|
+
conn,
|
|
133
|
+
"SELECT COUNT(*) FROM fact_retention r "
|
|
134
|
+
"JOIN atomic_facts af ON af.fact_id = r.fact_id "
|
|
135
|
+
"WHERE r.lifecycle_zone IN ('archive', 'forgotten') "
|
|
136
|
+
f" AND {_prefixed(live_clause, 'af')}",
|
|
137
|
+
)
|
|
138
|
+
# The contradiction M043 repairs: hidden, yet scored to keep.
|
|
139
|
+
inconsistent = _count(
|
|
140
|
+
conn,
|
|
141
|
+
"SELECT COUNT(*) FROM fact_retention r "
|
|
142
|
+
"JOIN atomic_facts af ON af.fact_id = r.fact_id "
|
|
143
|
+
"WHERE r.lifecycle_zone IN ('archive', 'forgotten') "
|
|
144
|
+
" AND r.retention_score > 0.8 "
|
|
145
|
+
f" AND {_prefixed(live_clause, 'af')}",
|
|
146
|
+
)
|
|
147
|
+
else:
|
|
148
|
+
unavailable.append("fact_retention")
|
|
149
|
+
|
|
150
|
+
return MemoryHealth(
|
|
151
|
+
live_facts=live,
|
|
152
|
+
findable_by_meaning=findable,
|
|
153
|
+
missing_vector=missing_vec,
|
|
154
|
+
withheld_summaries=withheld,
|
|
155
|
+
display_summaries=display,
|
|
156
|
+
hidden_by_forgetting=hidden,
|
|
157
|
+
inconsistently_hidden=inconsistent,
|
|
158
|
+
unavailable=tuple(unavailable),
|
|
159
|
+
)
|
|
160
|
+
except sqlite3.Error as exc:
|
|
161
|
+
logger.debug("memory health measurement failed: %s", exc)
|
|
162
|
+
return MemoryHealth(unavailable=(*unavailable, "query_failed"))
|
|
163
|
+
finally:
|
|
164
|
+
conn.close()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def describe(health: MemoryHealth) -> list[str]:
|
|
168
|
+
"""Plain-language lines for a reader who does not write SQL.
|
|
169
|
+
|
|
170
|
+
No percentages without the counts behind them, and no jargon: "findable by
|
|
171
|
+
asking a question" rather than "vector coverage", because the person who
|
|
172
|
+
needs this line is the one who would not know what a vector is.
|
|
173
|
+
"""
|
|
174
|
+
lines: list[str] = []
|
|
175
|
+
if "atomic_facts" in health.unavailable or "database" in health.unavailable:
|
|
176
|
+
return ["Memory store not readable yet."]
|
|
177
|
+
|
|
178
|
+
lines.append(f"You have {health.live_facts:,} memories.")
|
|
179
|
+
|
|
180
|
+
if "embedding_metadata" in health.unavailable:
|
|
181
|
+
lines.append(
|
|
182
|
+
"Whether they can be found by asking a question is not known yet — "
|
|
183
|
+
"the search index has not been built."
|
|
184
|
+
)
|
|
185
|
+
elif health.live_facts:
|
|
186
|
+
pct = 100.0 * health.reachability
|
|
187
|
+
if health.findable_by_meaning >= health.live_facts:
|
|
188
|
+
# "All" only when the counts actually agree. The threshold used to
|
|
189
|
+
# be reachability >= 0.99, which printed "All of them can be found
|
|
190
|
+
# by asking a question (5,199 indexed)" on a store of 5,205 — a
|
|
191
|
+
# claim of all, contradicted by the number beside it. This module
|
|
192
|
+
# exists to be believed; it cannot round in its own favour.
|
|
193
|
+
lines.append(
|
|
194
|
+
f"All {health.live_facts:,} of them can be found by asking a "
|
|
195
|
+
f"question."
|
|
196
|
+
)
|
|
197
|
+
elif health.reachability >= 0.99:
|
|
198
|
+
gap = health.live_facts - health.findable_by_meaning
|
|
199
|
+
lines.append(
|
|
200
|
+
f"{health.findable_by_meaning:,} of them can be found by asking "
|
|
201
|
+
f"a question. The other {gap:,} can only be found by matching "
|
|
202
|
+
f"words. That is a small enough share to be normal — a memory "
|
|
203
|
+
f"written moments ago, or one the model could not read."
|
|
204
|
+
)
|
|
205
|
+
else:
|
|
206
|
+
gap = health.live_facts - health.findable_by_meaning
|
|
207
|
+
lines.append(
|
|
208
|
+
f"{health.findable_by_meaning:,} of them ({pct:.0f}%) can be "
|
|
209
|
+
f"found by asking a question. The other {gap:,} can only be "
|
|
210
|
+
f"found by matching words, so a question phrased differently "
|
|
211
|
+
f"will miss them. This repairs itself as the service runs; if "
|
|
212
|
+
f"it does not, the embedding model is unavailable."
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
if health.withheld_summaries:
|
|
216
|
+
lines.append(
|
|
217
|
+
f"{health.withheld_summaries:,} machine-written summaries are kept "
|
|
218
|
+
f"out of your answers and shown on the dashboard instead. They were "
|
|
219
|
+
f"written by the summarizer, not by you, and they used to be "
|
|
220
|
+
f"returned as if they were your own notes."
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
if health.inconsistently_hidden:
|
|
224
|
+
lines.append(
|
|
225
|
+
f"{health.inconsistently_hidden:,} memories are hidden even though "
|
|
226
|
+
f"they are marked worth keeping. This is a fault and it is repaired "
|
|
227
|
+
f"automatically the next time the service starts."
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
if health.hidden_by_forgetting:
|
|
231
|
+
lines.append(
|
|
232
|
+
f"{health.hidden_by_forgetting:,} older memories are set aside by "
|
|
233
|
+
f"the forgetting curve. They are not deleted and a deep search "
|
|
234
|
+
f"still reaches them."
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
return lines
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _table_exists(conn: sqlite3.Connection, table: str) -> bool:
|
|
241
|
+
return conn.execute(
|
|
242
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,),
|
|
243
|
+
).fetchone() is not None
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _has_column(conn: sqlite3.Connection, table: str, column: str) -> bool:
|
|
247
|
+
return any(
|
|
248
|
+
row[1] == column for row in conn.execute(f"PRAGMA table_info({table})")
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _prefixed(clause: str, prefix: str) -> str:
|
|
253
|
+
"""Qualify a bare column reference for use in a joined query.
|
|
254
|
+
|
|
255
|
+
Word-bounded, so a future column named ``quarantined_at`` is not silently
|
|
256
|
+
rewritten to ``af.quarantined_at`` by a substring match. No such column
|
|
257
|
+
exists today; the point is that the failure would be a wrong count rather
|
|
258
|
+
than an error, and a wrong count in a health report is the one thing this
|
|
259
|
+
module must not produce.
|
|
260
|
+
"""
|
|
261
|
+
return re.sub(r"\bquarantined\b", f"{prefix}.quarantined", clause)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _count(conn: sqlite3.Connection, sql: str) -> int:
|
|
265
|
+
row = conn.execute(sql).fetchone()
|
|
266
|
+
return int(row[0]) if row else 0
|
|
@@ -281,7 +281,7 @@ class OperationPolicyRegistry:
|
|
|
281
281
|
allowed_transports=_ALL_TRANSPORTS,
|
|
282
282
|
audit_level="full",
|
|
283
283
|
),
|
|
284
|
-
# Operational recovery & admin remediation (
|
|
284
|
+
# Operational recovery & admin remediation (resilience slice)
|
|
285
285
|
# OPS_INSPECT: read-only listing of failed/stuck/degraded ops.
|
|
286
286
|
# Allowed over all transports so dashboard, MCP, and CLI all work.
|
|
287
287
|
OperationKind.OPS_INSPECT: OperationPolicy(
|
|
@@ -51,7 +51,7 @@ class OperationKind(str, Enum):
|
|
|
51
51
|
SCHEMA_MIGRATE = "schema_migrate"
|
|
52
52
|
VECTOR_MIGRATE = "vector_migrate"
|
|
53
53
|
EVOLVE_SKILL = "evolve_skill"
|
|
54
|
-
# Operational recovery & admin remediation (V4
|
|
54
|
+
# Operational recovery & admin remediation (V4 resilience slice)
|
|
55
55
|
OPS_INSPECT = "ops_inspect" # List failed/stuck/degraded operations (OWNER/ADMIN)
|
|
56
56
|
OPS_RESOLVE = "ops_resolve" # Retry/force-reconcile/cancel an operation (OWNER/ADMIN)
|
|
57
57
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
2
|
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
3
|
|
|
4
|
-
"""Operational Recovery & Admin Remediation helpers (
|
|
4
|
+
"""Operational Recovery & Admin Remediation helpers (resilience slice).
|
|
5
5
|
|
|
6
6
|
Provides two primary functions used by HTTP endpoints, MCP tools, and CLI:
|
|
7
7
|
|
|
@@ -22,7 +22,7 @@ Design constraints (NON-NEGOTIABLE):
|
|
|
22
22
|
- Additive & backward-compatible — healthy-path unaffected
|
|
23
23
|
- Immutable return dicts; explicit error handling; no silent swallowing
|
|
24
24
|
|
|
25
|
-
Part of SuperLocalMemory V4 |
|
|
25
|
+
Part of SuperLocalMemory V4 | Operational Recovery
|
|
26
26
|
"""
|
|
27
27
|
|
|
28
28
|
from __future__ import annotations
|
|
@@ -118,6 +118,54 @@ def _record_correction_candidate(
|
|
|
118
118
|
logger.warning("Correction candidate not recorded for %s: %s", successor_fact_id, exc)
|
|
119
119
|
|
|
120
120
|
|
|
121
|
+
#: Entity id pattern for the per-profile placeholder a dated fact with no
|
|
122
|
+
#: resolved entity points its temporal event at. Per profile, not shared, so
|
|
123
|
+
#: profile deletion cascades it away with everything else.
|
|
124
|
+
_UNRESOLVED_ENTITY_PREFIX = "__slm_unresolved__:"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _ensure_unresolved_entity(db: Any, profile_id: str) -> str | None:
|
|
128
|
+
"""Return the placeholder entity id for ``profile_id``, creating it once.
|
|
129
|
+
|
|
130
|
+
``temporal_events.entity_id`` is NOT NULL with a foreign key to
|
|
131
|
+
``canonical_entities``, so an event for a fact with no recognised entity
|
|
132
|
+
needs something real to reference. This is that something.
|
|
133
|
+
|
|
134
|
+
It is deliberately inert as an entity:
|
|
135
|
+
|
|
136
|
+
* Its ``canonical_name`` is empty, and the only query that resolves an
|
|
137
|
+
entity by name (``retrieval/temporal_channel.py``, the "events for
|
|
138
|
+
entity X" lookup) is driven by names extracted from the user's
|
|
139
|
+
question, which are never empty.
|
|
140
|
+
* Nothing derives entity links from ``temporal_events``. The entity
|
|
141
|
+
channel builds its map from ``atomic_facts.canonical_entities_json``,
|
|
142
|
+
which this does not touch — so the placeholder cannot become the kind
|
|
143
|
+
of entity that links a quarter of the store and flattens entity
|
|
144
|
+
proximity. That failure mode is real (on the author's store 'State'
|
|
145
|
+
links 1,388 facts) and this is specifically shaped not to add to it.
|
|
146
|
+
* ``fact_count`` stays 0. It is a hook for a foreign key, not a concept.
|
|
147
|
+
|
|
148
|
+
Returns None if the row cannot be created, in which case the caller skips
|
|
149
|
+
the temporal event rather than failing the write — a missing temporal row
|
|
150
|
+
is a degraded memory, an aborted remember is a lost one.
|
|
151
|
+
"""
|
|
152
|
+
entity_id = f"{_UNRESOLVED_ENTITY_PREFIX}{profile_id}"
|
|
153
|
+
try:
|
|
154
|
+
db.execute(
|
|
155
|
+
"INSERT OR IGNORE INTO canonical_entities "
|
|
156
|
+
"(entity_id, profile_id, canonical_name, entity_type, fact_count) "
|
|
157
|
+
"VALUES (?, ?, '', 'unresolved', 0)",
|
|
158
|
+
(entity_id, profile_id),
|
|
159
|
+
)
|
|
160
|
+
return entity_id
|
|
161
|
+
except Exception as exc: # noqa: BLE001 — never fail a write over this
|
|
162
|
+
logger.debug(
|
|
163
|
+
"temporal placeholder entity unavailable for %s: %s",
|
|
164
|
+
profile_id, exc,
|
|
165
|
+
)
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
|
|
121
169
|
def _record_fact_entity_association(
|
|
122
170
|
db: DatabaseManager,
|
|
123
171
|
*,
|
|
@@ -1031,12 +1079,39 @@ def run_store(
|
|
|
1031
1079
|
if scene_builder:
|
|
1032
1080
|
scene_builder.assign_to_scene(fact, profile_id)
|
|
1033
1081
|
|
|
1034
|
-
# Populate temporal_events for temporal retrieval
|
|
1082
|
+
# Populate temporal_events for temporal retrieval.
|
|
1083
|
+
#
|
|
1084
|
+
# A DATE IS A DATE WHETHER OR NOT AN ENTITY WAS RESOLVED. This used to
|
|
1085
|
+
# read `if fact.canonical_entities and has_dates`, so a fact carrying a
|
|
1086
|
+
# perfectly good date but no recognised entity got no temporal row at
|
|
1087
|
+
# all. Measured on the author's store: 967 of 3,894 genuine facts have
|
|
1088
|
+
# no canonical entity (24.8%), and 958 of those have no temporal event
|
|
1089
|
+
# either — a quarter of the store that the temporal channel could only
|
|
1090
|
+
# reach through its created_at recency fallback, i.e. by being recent
|
|
1091
|
+
# rather than by being about the right time.
|
|
1092
|
+
#
|
|
1093
|
+
# The date-window query does not need the entity: it joins
|
|
1094
|
+
# temporal_events to atomic_facts and nothing else
|
|
1095
|
+
# (retrieval/temporal_channel.py). Only the "events for entity X"
|
|
1096
|
+
# lookup joins canonical_entities, and that lookup is correctly
|
|
1097
|
+
# uninterested in a fact with no entity.
|
|
1098
|
+
#
|
|
1099
|
+
# temporal_events.entity_id is NOT NULL with an FK to
|
|
1100
|
+
# canonical_entities, so an entity-less event needs a row to point at.
|
|
1101
|
+
# It gets a per-profile sentinel rather than a shared one, and the
|
|
1102
|
+
# sentinel is invisible to ranking: the entity channel builds its map
|
|
1103
|
+
# from atomic_facts.canonical_entities_json, which is untouched here, so
|
|
1104
|
+
# this cannot create the kind of entity that links a quarter of the
|
|
1105
|
+
# store and destroys entity proximity.
|
|
1035
1106
|
has_dates = (fact.observation_date or fact.referenced_date
|
|
1036
1107
|
or fact.interval_start)
|
|
1037
|
-
if
|
|
1108
|
+
if has_dates:
|
|
1038
1109
|
from superlocalmemory.storage.models import TemporalEvent
|
|
1039
|
-
|
|
1110
|
+
entity_ids = list(fact.canonical_entities)
|
|
1111
|
+
if not entity_ids:
|
|
1112
|
+
sentinel = _ensure_unresolved_entity(db, profile_id)
|
|
1113
|
+
entity_ids = [sentinel] if sentinel else []
|
|
1114
|
+
for eid in entity_ids:
|
|
1040
1115
|
event = TemporalEvent(
|
|
1041
1116
|
event_id=_ingestion_effect_id(
|
|
1042
1117
|
ingestion_operation_id,
|
|
@@ -327,6 +327,17 @@ class CognitiveConsolidator:
|
|
|
327
327
|
AND r.lifecycle_zone IN ('warm', 'cold')
|
|
328
328
|
AND r.retention_score < ?
|
|
329
329
|
AND f.lifecycle != 'forgotten'
|
|
330
|
+
-- Withheld rows are not candidates, and this is not tidiness.
|
|
331
|
+
-- 304 of them remain warm/cold in atomic_facts after 4.0.10
|
|
332
|
+
-- withholds them, and each still carries its cluster's POOLED
|
|
333
|
+
-- canonical_entities_json. CCQ clusters on entity overlap, so one
|
|
334
|
+
-- withheld summary naming ten entities joins a cluster of real
|
|
335
|
+
-- memories, contributes model prose to the gist, and then this
|
|
336
|
+
-- pass archives every source in the cluster -- including the real
|
|
337
|
+
-- memories, at scores M043's restore would not bring back.
|
|
338
|
+
-- Exactly the damage this release exists to stop, on a path the
|
|
339
|
+
-- release did not otherwise touch.
|
|
340
|
+
AND COALESCE(f.quarantined, 0) = 0
|
|
330
341
|
AND f.fact_id NOT IN (
|
|
331
342
|
SELECT je.value
|
|
332
343
|
FROM ccq_consolidated_blocks ccb,
|
|
@@ -494,7 +505,10 @@ class CognitiveConsolidator:
|
|
|
494
505
|
f"SELECT fact_id, content, importance, confidence, "
|
|
495
506
|
f" canonical_entities_json "
|
|
496
507
|
f"FROM atomic_facts "
|
|
497
|
-
f"WHERE fact_id IN ({placeholders}) AND profile_id = ?"
|
|
508
|
+
f"WHERE fact_id IN ({placeholders}) AND profile_id = ? "
|
|
509
|
+
# Belt and braces with the identify query above: a cluster assembled
|
|
510
|
+
# before a row was withheld must not contribute its text to a gist.
|
|
511
|
+
f" AND COALESCE(quarantined, 0) = 0",
|
|
498
512
|
(*cluster.fact_ids, profile_id),
|
|
499
513
|
)
|
|
500
514
|
|
|
@@ -282,7 +282,7 @@ register_optimize_tools(_target) # v3.6.11: Surface B Optimize tools (proxy-fre
|
|
|
282
282
|
from superlocalmemory.mcp.tools_loops import register_loop_tools
|
|
283
283
|
register_loop_tools(_target, get_engine) # v3.8.0: bounded-loop tools (CLI+command+MCP)
|
|
284
284
|
from superlocalmemory.mcp.tools_ops import register_ops_tools
|
|
285
|
-
register_ops_tools(_target, get_engine) #
|
|
285
|
+
register_ops_tools(_target, get_engine) # operational recovery & admin remediation
|
|
286
286
|
from superlocalmemory.mcp.tools_brain import register_brain_tools
|
|
287
287
|
register_brain_tools(_target, get_engine) # v4.0.2 portable Brain receipts
|
|
288
288
|
from superlocalmemory.mcp.tools_summaries import register_summary_tools
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
|
|
4
|
+
"""Work out which session a tool call belongs to, the same way every time.
|
|
5
|
+
|
|
6
|
+
``recall`` has resolved this through a four-step ladder since S9-DASH-10 — the
|
|
7
|
+
explicit argument, then the environment, then the hook registry, then a stable
|
|
8
|
+
per-agent fallback — so an engagement signal lands on the right pending outcome.
|
|
9
|
+
``remember`` never had it. It took ``session_id: str = ""`` and stored whatever
|
|
10
|
+
it was handed, which for a caller that does not pass one is nothing.
|
|
11
|
+
|
|
12
|
+
Measured on the author's store: **192 of 3,894 genuine facts carry a session_id
|
|
13
|
+
(4.9%)**, and 4 of the 200 most recent (2%). Every one of the rest was written
|
|
14
|
+
through a path that could have known and did not.
|
|
15
|
+
|
|
16
|
+
That is not bookkeeping. ``RetrievalEngine`` promotes results so the top of an
|
|
17
|
+
answer spans more than one session (its ``sessions_in_top`` pass), and a fact
|
|
18
|
+
with no session_id can never be promoted by it — so the diversity mechanism was
|
|
19
|
+
running against a corpus where 95% of rows were indistinguishable. It also means
|
|
20
|
+
"what did we discuss in that session" has almost nothing to match on.
|
|
21
|
+
|
|
22
|
+
One implementation, called by both tools, so the read path and the write path
|
|
23
|
+
cannot disagree about which session they are in.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
import os
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
__all__ = ["resolve_session_id", "SESSION_ENV_VARS"]
|
|
34
|
+
|
|
35
|
+
#: Checked in order. Hosts set one or the other; SLM's own takes precedence so a
|
|
36
|
+
#: user can override a host that sets its variable to something unhelpful.
|
|
37
|
+
SESSION_ENV_VARS = ("SLM_SESSION_ID", "CLAUDE_SESSION_ID")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def resolve_session_id(
|
|
41
|
+
explicit: str = "",
|
|
42
|
+
*,
|
|
43
|
+
agent_id: str = "unknown",
|
|
44
|
+
allow_agent_fallback: bool = True,
|
|
45
|
+
) -> str:
|
|
46
|
+
"""Best available session id for this call. Never raises.
|
|
47
|
+
|
|
48
|
+
Order, most to least specific:
|
|
49
|
+
|
|
50
|
+
1. ``explicit`` — what the caller passed. Always wins.
|
|
51
|
+
2. ``SLM_SESSION_ID`` / ``CLAUDE_SESSION_ID`` from the environment.
|
|
52
|
+
3. The hook registry: the session whose parent process is ours, else the
|
|
53
|
+
most recently active one inside 60 seconds. Parent-PID lookup is
|
|
54
|
+
collision-free across parallel host sessions, because each MCP
|
|
55
|
+
server's parent is the editor that spawned it.
|
|
56
|
+
4. ``mcp:<agent_id>`` — stable per agent, and deliberately NOT matched by
|
|
57
|
+
the Stop hook, so the reaper settles those outcomes at a neutral 0.5
|
|
58
|
+
rather than crediting or blaming a session that never existed.
|
|
59
|
+
|
|
60
|
+
``allow_agent_fallback=False`` stops before step 4 and returns "". Use it
|
|
61
|
+
where a synthetic id would be worse than none: grouping memories under
|
|
62
|
+
``mcp:<agent>`` would put every memory an agent ever wrote into one bucket
|
|
63
|
+
and make session-diversity promotion rank them as a single session, which
|
|
64
|
+
is the opposite of what it is for.
|
|
65
|
+
"""
|
|
66
|
+
if explicit and explicit.strip():
|
|
67
|
+
return explicit.strip()
|
|
68
|
+
|
|
69
|
+
for name in SESSION_ENV_VARS:
|
|
70
|
+
value = os.environ.get(name)
|
|
71
|
+
if value and value.strip():
|
|
72
|
+
return value.strip()
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
from superlocalmemory.hooks.session_registry import (
|
|
76
|
+
lookup_by_parent,
|
|
77
|
+
most_recent_active,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
found = (
|
|
81
|
+
lookup_by_parent(within_seconds=60)
|
|
82
|
+
or most_recent_active(agent_type="claude", within_seconds=60)
|
|
83
|
+
or ""
|
|
84
|
+
)
|
|
85
|
+
if found:
|
|
86
|
+
return found
|
|
87
|
+
except Exception as exc: # noqa: BLE001 — a hint must never fail a call
|
|
88
|
+
logger.debug("session registry lookup unavailable: %s", exc)
|
|
89
|
+
|
|
90
|
+
if allow_agent_fallback:
|
|
91
|
+
return f"mcp:{agent_id}"
|
|
92
|
+
return ""
|