superlocalmemory 4.0.10 → 4.1.2
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/.claude-plugin/marketplace.json +12 -2
- package/CHANGELOG.md +244 -0
- package/README.md +40 -75
- package/package.json +6 -3
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +4 -4
- 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 +2 -2
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +5 -5
- package/plugin/skills/slm-recall/SKILL.md +102 -15
- package/plugin/skills/slm-remember/SKILL.md +35 -3
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +29 -3
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +16 -8
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-governance/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-loop/SKILL.md +2 -2
- package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
- package/plugin-src/skills/slm-profile/SKILL.md +5 -5
- package/plugin-src/skills/slm-recall/SKILL.md +102 -15
- package/plugin-src/skills/slm-remember/SKILL.md +35 -3
- package/plugin-src/skills/slm-scope/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +29 -3
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +357 -18
- package/src/superlocalmemory/cli/daemon.py +30 -0
- package/src/superlocalmemory/cli/db_migrate.py +71 -1
- package/src/superlocalmemory/cli/gdpr_cmd.py +15 -2
- package/src/superlocalmemory/cli/main.py +24 -2
- package/src/superlocalmemory/code_graph/database.py +44 -0
- package/src/superlocalmemory/compliance/gdpr.py +449 -39
- package/src/superlocalmemory/core/admission.py +231 -11
- package/src/superlocalmemory/core/backend_orchestrator.py +190 -84
- package/src/superlocalmemory/core/config.py +90 -11
- package/src/superlocalmemory/core/consolidation_engine.py +34 -0
- package/src/superlocalmemory/core/engine.py +140 -11
- package/src/superlocalmemory/core/graph_analyzer.py +76 -112
- package/src/superlocalmemory/core/graph_metrics.py +597 -0
- package/src/superlocalmemory/core/graph_pruner.py +121 -0
- package/src/superlocalmemory/core/maintenance_scheduler.py +205 -0
- package/src/superlocalmemory/core/mode_capability.py +111 -0
- package/src/superlocalmemory/core/ollama_validator.py +315 -0
- package/src/superlocalmemory/core/projection_drain.py +380 -0
- package/src/superlocalmemory/core/recall_pipeline.py +390 -3
- package/src/superlocalmemory/core/recall_worker.py +6 -3
- package/src/superlocalmemory/core/scale_autopromote.py +196 -0
- package/src/superlocalmemory/core/scale_engine.py +16 -2
- package/src/superlocalmemory/core/score_contract.py +21 -1
- package/src/superlocalmemory/core/session_identity.py +85 -0
- package/src/superlocalmemory/core/status_contract.py +108 -0
- package/src/superlocalmemory/core/worker_pool.py +4 -4
- package/src/superlocalmemory/core/working_memory.py +288 -0
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +36 -6
- package/src/superlocalmemory/encoding/context_generator.py +1 -1
- package/src/superlocalmemory/encoding/entity_resolver.py +38 -0
- package/src/superlocalmemory/encoding/fact_extractor.py +18 -14
- package/src/superlocalmemory/encoding/prospective_markers.py +262 -0
- package/src/superlocalmemory/encoding/type_router.py +12 -12
- package/src/superlocalmemory/evolution/mutation_generator.py +30 -4
- package/src/superlocalmemory/graph/cozo_adjacency.py +122 -0
- package/src/superlocalmemory/graph/cozo_backend.py +103 -138
- package/src/superlocalmemory/hooks/portable_kit.py +10 -2
- package/src/superlocalmemory/learning/bandit.py +43 -0
- package/src/superlocalmemory/learning/consolidation_worker.py +54 -0
- package/src/superlocalmemory/learning/database.py +60 -3
- package/src/superlocalmemory/learning/entity_compiler.py +21 -58
- package/src/superlocalmemory/learning/feedback.py +3 -1
- package/src/superlocalmemory/learning/outcomes.py +47 -16
- package/src/superlocalmemory/learning/pattern_miner.py +28 -3
- package/src/superlocalmemory/learning/pattern_miner_constants.py +43 -0
- package/src/superlocalmemory/learning/pcos.py +291 -0
- package/src/superlocalmemory/learning/reward_from_outcomes.py +365 -0
- package/src/superlocalmemory/learning/reward_proxy.py +100 -10
- package/src/superlocalmemory/learning/signal_kinds.py +79 -0
- package/src/superlocalmemory/mcp/profiles.py +14 -2
- package/src/superlocalmemory/mcp/tools_active.py +2 -1
- package/src/superlocalmemory/mcp/tools_core.py +31 -3
- package/src/superlocalmemory/mcp/tools_v28.py +20 -1
- package/src/superlocalmemory/parameterization/pattern_extractor.py +14 -1
- package/src/superlocalmemory/parameterization/soft_prompt_generator.py +98 -0
- package/src/superlocalmemory/retrieval/bm25_channel.py +64 -3
- package/src/superlocalmemory/retrieval/channel_status.py +117 -0
- package/src/superlocalmemory/retrieval/engine.py +106 -11
- package/src/superlocalmemory/retrieval/entity_channel.py +210 -256
- package/src/superlocalmemory/retrieval/graph_adjacency.py +219 -0
- package/src/superlocalmemory/retrieval/scope_policy.py +20 -0
- package/src/superlocalmemory/retrieval/semantic_channel.py +47 -5
- package/src/superlocalmemory/retrieval/spreading.py +288 -0
- package/src/superlocalmemory/server/api.py +24 -5
- package/src/superlocalmemory/server/bandit_loops.py +17 -1
- package/src/superlocalmemory/server/rbac_enforce.py +26 -6
- package/src/superlocalmemory/server/recall_health.py +87 -10
- package/src/superlocalmemory/server/recall_serializer.py +9 -0
- package/src/superlocalmemory/server/routes/behavioral.py +75 -10
- package/src/superlocalmemory/server/routes/compliance.py +98 -18
- package/src/superlocalmemory/server/routes/config_api.py +186 -4
- package/src/superlocalmemory/server/routes/evolution.py +178 -0
- package/src/superlocalmemory/server/routes/ingest.py +8 -0
- package/src/superlocalmemory/server/routes/learning_telemetry.py +2 -1
- package/src/superlocalmemory/server/routes/memories.py +49 -7
- package/src/superlocalmemory/server/routes/timeline.py +4 -0
- package/src/superlocalmemory/server/routes/v3_api.py +191 -15
- package/src/superlocalmemory/server/ui.py +20 -4
- package/src/superlocalmemory/server/unified_daemon.py +241 -7
- package/src/superlocalmemory/storage/_migration_internals.py +54 -2
- package/src/superlocalmemory/storage/_schema_version.py +24 -3
- package/src/superlocalmemory/storage/database.py +477 -59
- package/src/superlocalmemory/storage/embedding_codec.py +71 -0
- package/src/superlocalmemory/storage/lineage_retention.py +236 -0
- package/src/superlocalmemory/storage/logical_edges.py +43 -2
- package/src/superlocalmemory/storage/migration_runner.py +119 -0
- package/src/superlocalmemory/storage/migrations/M043_quarantine_display_summaries.py +60 -36
- package/src/superlocalmemory/storage/migrations/M044_play_carries_its_own_evidence.py +127 -0
- package/src/superlocalmemory/storage/migrations/M045_fact_outcome_score.py +158 -0
- package/src/superlocalmemory/storage/migrations/M046_prospective_memory_has_its_own_name.py +620 -0
- package/src/superlocalmemory/storage/migrations/M047_fisher_vectors_are_stored_like_every_other_vector.py +306 -0
- package/src/superlocalmemory/storage/migrations/M048_upcoming_holds_only_what_is_upcoming.py +207 -0
- package/src/superlocalmemory/storage/migrations/M049_a_schema_version_marker_is_one_row.py +201 -0
- package/src/superlocalmemory/storage/migrations.py +18 -2
- package/src/superlocalmemory/storage/models.py +40 -1
- package/src/superlocalmemory/storage/projection_outbox.py +346 -0
- package/src/superlocalmemory/storage/retention_policy.py +860 -0
- package/src/superlocalmemory/storage/schema.py +35 -1
- package/src/superlocalmemory/storage/write_coordinator.py +19 -2
- package/src/superlocalmemory/trust/scorer.py +43 -1
- package/src/superlocalmemory/ui/index.html +9 -18
- package/src/superlocalmemory/ui/js/event-delegation.js +12 -1
- package/src/superlocalmemory/ui/js/od-health.js +28 -6
- package/src/superlocalmemory/ui/js/od-memories.js +19 -0
- package/src/superlocalmemory/ui/js/od-settings.js +87 -1
- package/src/superlocalmemory/ui/js/recall-lab.js +78 -3
|
@@ -21,6 +21,27 @@ from pathlib import Path
|
|
|
21
21
|
logger = logging.getLogger(__name__)
|
|
22
22
|
|
|
23
23
|
|
|
24
|
+
def _cli_projection_queue_depth(db_path: object) -> int:
|
|
25
|
+
"""Facts queued for the graph and vector projections, read straight from disk.
|
|
26
|
+
|
|
27
|
+
The no-daemon branch of ``slm status`` has no engine, so it opens the store
|
|
28
|
+
read-only rather than building one. A store that predates the queue reports
|
|
29
|
+
zero, which is the truth for it.
|
|
30
|
+
"""
|
|
31
|
+
import sqlite3
|
|
32
|
+
|
|
33
|
+
from superlocalmemory.core.status_contract import projection_queue_depth
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
|
37
|
+
except sqlite3.Error:
|
|
38
|
+
return 0
|
|
39
|
+
try:
|
|
40
|
+
return projection_queue_depth(conn)
|
|
41
|
+
finally:
|
|
42
|
+
conn.close()
|
|
43
|
+
|
|
44
|
+
|
|
24
45
|
def _daemon_unavailable(command: str, use_json: bool) -> None:
|
|
25
46
|
"""Exit a mutation client without opening a process-local writer.
|
|
26
47
|
|
|
@@ -70,14 +91,127 @@ def _cmd_db_dispatch(args: Namespace) -> None:
|
|
|
70
91
|
if sub == "reembed":
|
|
71
92
|
_cmd_db_reembed(args)
|
|
72
93
|
return
|
|
94
|
+
if sub == "regraph":
|
|
95
|
+
_cmd_db_regraph(args)
|
|
96
|
+
return
|
|
73
97
|
print(
|
|
74
98
|
"Usage: slm db migrate [--status] [--dry-run] "
|
|
75
99
|
"| slm db scale <action> "
|
|
100
|
+
"| slm db regraph [--check] [--profile NAME] "
|
|
76
101
|
"| slm db reembed [--missing-only] [--all-profiles] [--limit N]"
|
|
77
102
|
)
|
|
78
103
|
sys.exit(2)
|
|
79
104
|
|
|
80
105
|
|
|
106
|
+
def _cmd_db_regraph(args: Namespace) -> None:
|
|
107
|
+
"""Re-derive the graph copy of your memories from the store.
|
|
108
|
+
|
|
109
|
+
Your memories live in one place; a second copy of the connections between
|
|
110
|
+
them is kept in a graph store so that searching them is fast. The two are
|
|
111
|
+
kept in step by a queue. If the queue is empty and the two still disagree
|
|
112
|
+
-- after a crash, a disk problem, or a copy restored from elsewhere --
|
|
113
|
+
nothing notices, because "the queue is empty" is what "in step" looks like.
|
|
114
|
+
|
|
115
|
+
This is how you say so out loud. ``--check`` reports the difference and
|
|
116
|
+
changes nothing; without it, every memory is queued to be re-derived and
|
|
117
|
+
the background worker rebuilds the copy from the store, which is the one
|
|
118
|
+
that is authoritative.
|
|
119
|
+
"""
|
|
120
|
+
from superlocalmemory.core.config import SLMConfig
|
|
121
|
+
from superlocalmemory.storage import projection_outbox
|
|
122
|
+
from superlocalmemory.storage.database import DatabaseManager
|
|
123
|
+
from superlocalmemory.storage.logical_edges import count_logical_edges
|
|
124
|
+
|
|
125
|
+
config = SLMConfig.load()
|
|
126
|
+
db = DatabaseManager(config.db_path)
|
|
127
|
+
wanted = str(getattr(args, "profile", "") or "").strip()
|
|
128
|
+
try:
|
|
129
|
+
rows = db.execute(
|
|
130
|
+
"SELECT DISTINCT profile_id FROM atomic_facts ORDER BY profile_id"
|
|
131
|
+
)
|
|
132
|
+
profiles = [dict(r)["profile_id"] for r in rows]
|
|
133
|
+
except Exception as exc: # noqa: BLE001
|
|
134
|
+
print(f"[slm] could not list workspaces: {exc}")
|
|
135
|
+
sys.exit(1)
|
|
136
|
+
if wanted:
|
|
137
|
+
profiles = [p for p in profiles if p == wanted]
|
|
138
|
+
if not profiles:
|
|
139
|
+
print(f"[slm] no workspace named {wanted!r}")
|
|
140
|
+
sys.exit(1)
|
|
141
|
+
|
|
142
|
+
backend = None
|
|
143
|
+
try:
|
|
144
|
+
from superlocalmemory.core.backend_orchestrator import get_orchestrator
|
|
145
|
+
|
|
146
|
+
orchestrator = get_orchestrator()
|
|
147
|
+
backend = orchestrator.get_graph_backend() if orchestrator else None
|
|
148
|
+
except Exception as exc: # noqa: BLE001
|
|
149
|
+
print(f"[slm] no graph copy is configured on this store ({exc}).")
|
|
150
|
+
return
|
|
151
|
+
if backend is None:
|
|
152
|
+
print("[slm] no graph copy is configured on this store; nothing to do.")
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
total_queued = 0
|
|
156
|
+
for profile_id in profiles:
|
|
157
|
+
with db.raw_connection() as conn:
|
|
158
|
+
stored = count_logical_edges(conn, profile_id)
|
|
159
|
+
try:
|
|
160
|
+
result = backend._db.run(
|
|
161
|
+
"?[count(a)] := *edge{from_id: a, profile_id: $pid}",
|
|
162
|
+
{"pid": profile_id},
|
|
163
|
+
)
|
|
164
|
+
copied = int(result.values.tolist()[0][0]) if len(result) else 0
|
|
165
|
+
except Exception as exc: # noqa: BLE001
|
|
166
|
+
print(f" {profile_id}: the graph copy could not be read ({exc})")
|
|
167
|
+
continue
|
|
168
|
+
drift = abs(copied - stored) / float(stored) if stored else 0.0
|
|
169
|
+
pending = 0
|
|
170
|
+
try:
|
|
171
|
+
pending_rows = db.execute(
|
|
172
|
+
"SELECT COUNT(*) AS c FROM projection_outbox WHERE profile_id = ?",
|
|
173
|
+
(profile_id,),
|
|
174
|
+
)
|
|
175
|
+
pending = int(dict(pending_rows[0])["c"]) if pending_rows else 0
|
|
176
|
+
except Exception: # noqa: BLE001
|
|
177
|
+
pending = -1
|
|
178
|
+
print(
|
|
179
|
+
f" {profile_id}: {stored} connections stored, {copied} in the "
|
|
180
|
+
f"graph copy ({drift:.1%} apart), {pending} waiting to be applied"
|
|
181
|
+
)
|
|
182
|
+
if getattr(args, "check", False):
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
fact_rows = db.execute(
|
|
186
|
+
"SELECT fact_id FROM atomic_facts WHERE profile_id = ?", (profile_id,)
|
|
187
|
+
)
|
|
188
|
+
fact_ids = [dict(r)["fact_id"] for r in fact_rows]
|
|
189
|
+
if not fact_ids:
|
|
190
|
+
continue
|
|
191
|
+
if not projection_outbox.is_available(db):
|
|
192
|
+
print(" (this store has no queue to rebuild through)")
|
|
193
|
+
continue
|
|
194
|
+
for start in range(0, len(fact_ids), 500):
|
|
195
|
+
projection_outbox.enqueue_many(
|
|
196
|
+
db, fact_ids[start:start + 500], profile_id,
|
|
197
|
+
op=projection_outbox.OP_UPSERT,
|
|
198
|
+
)
|
|
199
|
+
total_queued += len(fact_ids)
|
|
200
|
+
print(f" {profile_id}: queued {len(fact_ids)} memories to re-derive")
|
|
201
|
+
|
|
202
|
+
db.close()
|
|
203
|
+
if getattr(args, "check", False):
|
|
204
|
+
return
|
|
205
|
+
if total_queued:
|
|
206
|
+
print(
|
|
207
|
+
f"[slm] {total_queued} memories queued. The background worker "
|
|
208
|
+
f"rebuilds the copy from the store; watch `slm status` until the "
|
|
209
|
+
f"waiting count reaches zero."
|
|
210
|
+
)
|
|
211
|
+
else:
|
|
212
|
+
print("[slm] nothing to re-derive.")
|
|
213
|
+
|
|
214
|
+
|
|
81
215
|
def _cmd_db_reembed(args: Namespace) -> None:
|
|
82
216
|
"""Backfill NULL embeddings on atomic_facts.
|
|
83
217
|
|
|
@@ -424,7 +558,21 @@ def dispatch(args: Namespace) -> None:
|
|
|
424
558
|
}
|
|
425
559
|
handler = handlers.get(args.command)
|
|
426
560
|
if handler:
|
|
427
|
-
|
|
561
|
+
from superlocalmemory.cli.daemon import DaemonRefused
|
|
562
|
+
|
|
563
|
+
try:
|
|
564
|
+
handler(args)
|
|
565
|
+
except DaemonRefused as refusal:
|
|
566
|
+
# One place, because a command added next year will not remember to
|
|
567
|
+
# do this. A refusal is an answer: say so and stop, rather than
|
|
568
|
+
# printing a traceback or -- worse -- letting a command fall back to
|
|
569
|
+
# writing locally what the workspace has just declined.
|
|
570
|
+
print(
|
|
571
|
+
f"[slm] {refusal}. This workspace requires authentication; "
|
|
572
|
+
"set SLM_USER_SESSION or log in, then try again.",
|
|
573
|
+
flush=True,
|
|
574
|
+
)
|
|
575
|
+
sys.exit(1)
|
|
428
576
|
else:
|
|
429
577
|
print(f"Unknown command: {args.command}")
|
|
430
578
|
sys.exit(1)
|
|
@@ -821,7 +969,13 @@ def cmd_config(args: Namespace) -> None:
|
|
|
821
969
|
"evolution.mutation_model", "evolution.verify_model",
|
|
822
970
|
"evolution.confirm_model",
|
|
823
971
|
"mesh_enabled", "daemon_idle_timeout", "entity_compilation_enabled",
|
|
824
|
-
"graph_backend", "vector_backend",
|
|
972
|
+
"graph_backend", "vector_backend",
|
|
973
|
+
# scale_engine_state is deliberately NOT settable. It is a record of
|
|
974
|
+
# what has been done to the store, not a preference: writing
|
|
975
|
+
# "promoted" by hand makes the daemon skip projecting a store that
|
|
976
|
+
# was never projected, and writing "local_core" onto a promoted one
|
|
977
|
+
# makes it project again over live backends. A real installation was
|
|
978
|
+
# found declaring two backends it had never built.
|
|
825
979
|
"scope.default_scope", "scope.recall_include_global",
|
|
826
980
|
"scope.recall_include_shared",
|
|
827
981
|
}
|
|
@@ -1339,7 +1493,7 @@ def cmd_migrate(args: Namespace) -> None:
|
|
|
1339
1493
|
|
|
1340
1494
|
def cmd_list(args: Namespace) -> None:
|
|
1341
1495
|
"""List recent memories chronologically."""
|
|
1342
|
-
from superlocalmemory.core.config import SLMConfig
|
|
1496
|
+
from superlocalmemory.core.config import CANONICAL_LIST_LIMIT, SLMConfig
|
|
1343
1497
|
from superlocalmemory.core.engine import MemoryEngine
|
|
1344
1498
|
|
|
1345
1499
|
use_json = getattr(args, 'json', False)
|
|
@@ -1348,10 +1502,10 @@ def cmd_list(args: Namespace) -> None:
|
|
|
1348
1502
|
engine = MemoryEngine(config)
|
|
1349
1503
|
engine.initialize()
|
|
1350
1504
|
|
|
1351
|
-
limit = getattr(args, "limit",
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
facts =
|
|
1505
|
+
limit = getattr(args, "limit", CANONICAL_LIST_LIMIT)
|
|
1506
|
+
# The query already returns newest-first; pushing the bound into SQL
|
|
1507
|
+
# keeps this from deserializing the whole table to show twenty rows.
|
|
1508
|
+
facts = engine._db.get_all_facts(engine.profile_id, limit=limit)
|
|
1355
1509
|
except Exception as exc:
|
|
1356
1510
|
if use_json:
|
|
1357
1511
|
from superlocalmemory.cli.json_output import json_print
|
|
@@ -1879,6 +2033,9 @@ def cmd_review_correction(args: Namespace) -> None:
|
|
|
1879
2033
|
def cmd_status(args: Namespace) -> None:
|
|
1880
2034
|
"""Show system status."""
|
|
1881
2035
|
from superlocalmemory.core.config import SLMConfig
|
|
2036
|
+
# Same source the other two status surfaces read, so all three cannot
|
|
2037
|
+
# disagree about which version answered.
|
|
2038
|
+
from superlocalmemory.server.routes.helpers import SLM_VERSION
|
|
1882
2039
|
|
|
1883
2040
|
config = SLMConfig.load()
|
|
1884
2041
|
daemon_status = None
|
|
@@ -1903,7 +2060,10 @@ def cmd_status(args: Namespace) -> None:
|
|
|
1903
2060
|
|
|
1904
2061
|
if daemon_status is not None:
|
|
1905
2062
|
data = {
|
|
1906
|
-
|
|
2063
|
+
# Lowercase is what the daemon, the HTTP surface and MCP all
|
|
2064
|
+
# report. Uppercasing it here made one field disagree across
|
|
2065
|
+
# surfaces for anyone comparing the two answers.
|
|
2066
|
+
"mode": str(daemon_status.get("mode", "unknown")),
|
|
1907
2067
|
"provider": daemon_status.get("provider", "none"),
|
|
1908
2068
|
"profile": daemon_status["profile"],
|
|
1909
2069
|
"base_dir": daemon_status.get("base_dir", str(config.base_dir)),
|
|
@@ -1915,6 +2075,10 @@ def cmd_status(args: Namespace) -> None:
|
|
|
1915
2075
|
"profile_generation": int(
|
|
1916
2076
|
daemon_status.get("profile_generation", 0)
|
|
1917
2077
|
),
|
|
2078
|
+
"version": SLM_VERSION,
|
|
2079
|
+
"projection_queue_depth": int(
|
|
2080
|
+
daemon_status.get("projection_queue_depth", 0)
|
|
2081
|
+
),
|
|
1918
2082
|
}
|
|
1919
2083
|
json_print("status", data=data, next_actions=[
|
|
1920
2084
|
{"command": "slm health --json", "description": "Check math layer health"},
|
|
@@ -1965,7 +2129,7 @@ def cmd_status(args: Namespace) -> None:
|
|
|
1965
2129
|
pass
|
|
1966
2130
|
|
|
1967
2131
|
data = {
|
|
1968
|
-
"mode": config.mode.value
|
|
2132
|
+
"mode": config.mode.value,
|
|
1969
2133
|
"provider": config.llm.provider or "none",
|
|
1970
2134
|
"profile": config.active_profile,
|
|
1971
2135
|
"base_dir": str(config.base_dir),
|
|
@@ -1975,6 +2139,8 @@ def cmd_status(args: Namespace) -> None:
|
|
|
1975
2139
|
"entity_count": entity_count,
|
|
1976
2140
|
"edge_count": edge_count,
|
|
1977
2141
|
"profile_generation": 0,
|
|
2142
|
+
"version": SLM_VERSION,
|
|
2143
|
+
"projection_queue_depth": _cli_projection_queue_depth(config.db_path),
|
|
1978
2144
|
}
|
|
1979
2145
|
json_print("status", data=data, next_actions=[
|
|
1980
2146
|
{"command": "slm health --json", "description": "Check math layer health"},
|
|
@@ -2068,7 +2234,7 @@ def cmd_health(args: Namespace) -> None:
|
|
|
2068
2234
|
"total_facts": len(facts),
|
|
2069
2235
|
"similarity_indexed": fisher_count,
|
|
2070
2236
|
"lifecycle_positioned": langevin_count,
|
|
2071
|
-
"mode": config.mode.value
|
|
2237
|
+
"mode": config.mode.value,
|
|
2072
2238
|
}, next_actions=[
|
|
2073
2239
|
{"command": "slm status --json", "description": "Check system status"},
|
|
2074
2240
|
{"command": "slm recall '<query>' --json", "description": "Test retrieval"},
|
|
@@ -2411,6 +2577,56 @@ def _migration_error_logs() -> list:
|
|
|
2411
2577
|
return []
|
|
2412
2578
|
|
|
2413
2579
|
|
|
2580
|
+
def _slm_version() -> str:
|
|
2581
|
+
"""The installed package version, or "unknown"."""
|
|
2582
|
+
try:
|
|
2583
|
+
from importlib.metadata import version
|
|
2584
|
+
return version("superlocalmemory")
|
|
2585
|
+
except Exception: # noqa: BLE001
|
|
2586
|
+
return "unknown"
|
|
2587
|
+
|
|
2588
|
+
|
|
2589
|
+
def _installed_plugin_versions() -> dict:
|
|
2590
|
+
"""Version of each editor plugin found on this machine, by install name.
|
|
2591
|
+
|
|
2592
|
+
The skills, agents and commands live in the editor's plugin channel rather
|
|
2593
|
+
than in the Python package, so upgrading with pip leaves them exactly where
|
|
2594
|
+
they were. This looks for them where each editor puts them, and returns an
|
|
2595
|
+
empty mapping when none is installed -- which is itself the answer worth
|
|
2596
|
+
reporting, because it means pip is the only thing being upgraded.
|
|
2597
|
+
|
|
2598
|
+
Best effort by design: an editor this does not know about should produce
|
|
2599
|
+
"not detected", never an error.
|
|
2600
|
+
"""
|
|
2601
|
+
import json
|
|
2602
|
+
from pathlib import Path
|
|
2603
|
+
|
|
2604
|
+
found: dict[str, str] = {}
|
|
2605
|
+
roots = (
|
|
2606
|
+
# Claude Code: marketplace installs and directly-added plugins.
|
|
2607
|
+
Path.home() / ".claude" / "plugins",
|
|
2608
|
+
# Codex and VS Code copies, when placed by hand.
|
|
2609
|
+
Path.home() / ".codex" / "plugins",
|
|
2610
|
+
Path.home() / ".vscode" / "extensions",
|
|
2611
|
+
)
|
|
2612
|
+
for root in roots:
|
|
2613
|
+
if not root.is_dir():
|
|
2614
|
+
continue
|
|
2615
|
+
for manifest in list(root.glob("*/.claude-plugin/plugin.json")) + \
|
|
2616
|
+
list(root.glob("*/plugin.json")) + \
|
|
2617
|
+
list(root.glob("*/*/.claude-plugin/plugin.json")):
|
|
2618
|
+
try:
|
|
2619
|
+
data = json.loads(manifest.read_text(encoding="utf-8"))
|
|
2620
|
+
except Exception: # noqa: BLE001 — a sibling plugin's bad json is not ours
|
|
2621
|
+
continue
|
|
2622
|
+
if str(data.get("name", "")) != "superlocalmemory":
|
|
2623
|
+
continue
|
|
2624
|
+
found[str(manifest.parent.parent.name)] = str(
|
|
2625
|
+
data.get("version", "unknown")
|
|
2626
|
+
)
|
|
2627
|
+
return found
|
|
2628
|
+
|
|
2629
|
+
|
|
2414
2630
|
def _detect_all_installs() -> list:
|
|
2415
2631
|
"""Thin shim so cmd_doctor can be tested without importing install_detector."""
|
|
2416
2632
|
try:
|
|
@@ -2567,14 +2783,22 @@ def cmd_doctor(args: Namespace) -> None:
|
|
|
2567
2783
|
"pip install " + " ".join(core_modules[m] for m in missing))
|
|
2568
2784
|
|
|
2569
2785
|
# 3. Search deps
|
|
2786
|
+
#
|
|
2787
|
+
# Presence only. Importing these executes torch, which cost ~4 s of every
|
|
2788
|
+
# doctor run — a quarter of the whole command — to learn something the
|
|
2789
|
+
# embedding-worker check below already proves by actually running them.
|
|
2790
|
+
# find_spec answers "is it installed" without executing a line of it.
|
|
2791
|
+
# A package that is present but broken on import is caught by check 7,
|
|
2792
|
+
# which spawns the worker and waits for it to answer.
|
|
2570
2793
|
search_mods = {"sentence_transformers": "sentence-transformers", "torch": "torch",
|
|
2571
2794
|
"sklearn": "scikit-learn"}
|
|
2572
2795
|
search_ok = []
|
|
2573
2796
|
for mod, pkg in search_mods.items():
|
|
2574
2797
|
try:
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2798
|
+
import importlib.util as _ilu
|
|
2799
|
+
if _ilu.find_spec(mod) is not None:
|
|
2800
|
+
search_ok.append(mod)
|
|
2801
|
+
except Exception: # a broken meta-path finder must not fail the check
|
|
2578
2802
|
pass
|
|
2579
2803
|
if len(search_ok) == len(search_mods):
|
|
2580
2804
|
_check("Search deps", "PASS", "sentence-transformers, torch, sklearn")
|
|
@@ -2761,19 +2985,74 @@ def cmd_doctor(args: Namespace) -> None:
|
|
|
2761
2985
|
try:
|
|
2762
2986
|
from superlocalmemory.storage.memory_write import memory_read
|
|
2763
2987
|
|
|
2988
|
+
# integrity_check reads every page. On a 610 MB store that is
|
|
2989
|
+
# 10-15 s — over half of this command — and it ran on every
|
|
2990
|
+
# invocation, including the ones a user makes twice in a row while
|
|
2991
|
+
# fixing something else. quick_check walks the same B-trees and
|
|
2992
|
+
# catches structural damage; what it skips is page-level checksum
|
|
2993
|
+
# work that only finds hardware bit rot, which surfaces as read
|
|
2994
|
+
# errors long before anyone runs a doctor.
|
|
2995
|
+
#
|
|
2996
|
+
# The trade is named rather than hidden: the output says which
|
|
2997
|
+
# check ran, and --deep runs the exhaustive one.
|
|
2998
|
+
deep = bool(getattr(args, "deep", False))
|
|
2999
|
+
pragma = "integrity_check" if deep else "quick_check"
|
|
2764
3000
|
with memory_read(db_path) as conn:
|
|
2765
|
-
result = conn.execute("PRAGMA
|
|
3001
|
+
result = conn.execute(f"PRAGMA {pragma}").fetchone()
|
|
2766
3002
|
if result and result[0] == "ok":
|
|
2767
3003
|
size_mb = db_path.stat().st_size / (1024 * 1024)
|
|
2768
|
-
|
|
3004
|
+
detail = f"OK ({size_mb:.2f} MB, {pragma})"
|
|
3005
|
+
if not deep:
|
|
3006
|
+
detail += " — run `slm doctor --deep` for a full page scan"
|
|
3007
|
+
_check("Database", "PASS", detail)
|
|
2769
3008
|
else:
|
|
2770
|
-
_check("Database", "FAIL", f"
|
|
3009
|
+
_check("Database", "FAIL", f"{pragma}: {result}",
|
|
2771
3010
|
"Backup and recreate database")
|
|
2772
3011
|
except Exception as exc:
|
|
2773
3012
|
_check("Database", "FAIL", str(exc))
|
|
2774
3013
|
else:
|
|
2775
3014
|
_check("Database", "PASS", "not yet created (will initialize on first use)")
|
|
2776
3015
|
|
|
3016
|
+
# 10b. Projection queue. The graph and the vectors live in other storage
|
|
3017
|
+
# engines, and a queue that stops draining is the one failure that produces
|
|
3018
|
+
# no error at all: the memory is safely in SQLite, nothing raises, and
|
|
3019
|
+
# recall quietly stops finding it. Depth alone cannot distinguish a busy
|
|
3020
|
+
# queue from a wedged one, so the check keys on attempts — a row that has
|
|
3021
|
+
# been tried and refused is a defect with a fact id attached.
|
|
3022
|
+
if db_path.exists():
|
|
3023
|
+
try:
|
|
3024
|
+
from superlocalmemory.storage.memory_write import memory_read
|
|
3025
|
+
from superlocalmemory.storage.projection_outbox import DEPTH_SQL
|
|
3026
|
+
|
|
3027
|
+
with memory_read(db_path) as conn:
|
|
3028
|
+
depth = int(conn.execute(DEPTH_SQL).fetchone()[0])
|
|
3029
|
+
stalled = [
|
|
3030
|
+
row[0] for row in conn.execute(
|
|
3031
|
+
"SELECT fact_id FROM projection_outbox "
|
|
3032
|
+
"WHERE attempts >= 3 ORDER BY attempts DESC LIMIT 5"
|
|
3033
|
+
)
|
|
3034
|
+
]
|
|
3035
|
+
if stalled:
|
|
3036
|
+
_check(
|
|
3037
|
+
"Projection queue", "FAIL",
|
|
3038
|
+
f"{len(stalled)}+ memories refused by the graph or vector "
|
|
3039
|
+
f"store (e.g. {', '.join(f[:12] for f in stalled)})",
|
|
3040
|
+
"Check `slm logs` for the projection error, then restart "
|
|
3041
|
+
"the daemon to retry",
|
|
3042
|
+
)
|
|
3043
|
+
elif depth:
|
|
3044
|
+
_check(
|
|
3045
|
+
"Projection queue", "PASS",
|
|
3046
|
+
f"{depth} memory/memories queued — the worker drains these "
|
|
3047
|
+
"in the background",
|
|
3048
|
+
)
|
|
3049
|
+
else:
|
|
3050
|
+
_check("Projection queue", "PASS", "empty (graph is up to date)")
|
|
3051
|
+
except Exception:
|
|
3052
|
+
# No table means a store that predates the queue. Nothing is
|
|
3053
|
+
# pending on it by definition, so this is not a finding.
|
|
3054
|
+
_check("Projection queue", "PASS", "not applicable to this store")
|
|
3055
|
+
|
|
2777
3056
|
# 11. PEP 668 advisory: detect EXTERNALLY-MANAGED marker and
|
|
2778
3057
|
# recommend pipx when the system Python is managed by the OS package
|
|
2779
3058
|
# manager (e.g. Homebrew, Debian/Ubuntu, Fedora 38+).
|
|
@@ -2864,6 +3143,50 @@ def cmd_doctor(args: Namespace) -> None:
|
|
|
2864
3143
|
except Exception as _inst_exc: # noqa: BLE001 — never break doctor
|
|
2865
3144
|
_check("install_versions", "WARN", f"could not probe installs: {_inst_exc}")
|
|
2866
3145
|
|
|
3146
|
+
# 14. The skills, agents and commands are NOT in the Python package.
|
|
3147
|
+
# They ship through the editor's own plugin channel -- `plugin/` in the
|
|
3148
|
+
# repository -- so `pip install --upgrade` cannot move them, and until now
|
|
3149
|
+
# nothing said so. 4.1 changed 76 files across those trees; a user who
|
|
3150
|
+
# upgraded the package and read a clean `slm doctor` had every reason to
|
|
3151
|
+
# believe they had all of it, and no way to find out otherwise.
|
|
3152
|
+
try:
|
|
3153
|
+
_pkg_version = _slm_version()
|
|
3154
|
+
_pl = _installed_plugin_versions()
|
|
3155
|
+
if not _pl:
|
|
3156
|
+
_check(
|
|
3157
|
+
"plugin_skills",
|
|
3158
|
+
"WARN",
|
|
3159
|
+
f"package is {_pkg_version}; no editor plugin detected, so the "
|
|
3160
|
+
f"skills, agents and commands are not installed or updated by "
|
|
3161
|
+
f"pip",
|
|
3162
|
+
fix="Claude Code: claude plugin marketplace add "
|
|
3163
|
+
"qualixar/superlocalmemory && claude plugin install "
|
|
3164
|
+
"superlocalmemory@qualixar "
|
|
3165
|
+
"Codex / VS Code: copy codex-plugin/ or copilot-plugin/ "
|
|
3166
|
+
"from the tag you are on",
|
|
3167
|
+
)
|
|
3168
|
+
else:
|
|
3169
|
+
_stale = {n: v for n, v in _pl.items() if v != _pkg_version}
|
|
3170
|
+
if _stale:
|
|
3171
|
+
_check(
|
|
3172
|
+
"plugin_skills",
|
|
3173
|
+
"WARN",
|
|
3174
|
+
"package is %s; plugin content still at %s" % (
|
|
3175
|
+
_pkg_version,
|
|
3176
|
+
", ".join(f"{n}={v}" for n, v in sorted(_stale.items())),
|
|
3177
|
+
),
|
|
3178
|
+
fix="claude plugin marketplace update qualixar && "
|
|
3179
|
+
"claude plugin update superlocalmemory@qualixar",
|
|
3180
|
+
)
|
|
3181
|
+
else:
|
|
3182
|
+
_check(
|
|
3183
|
+
"plugin_skills",
|
|
3184
|
+
"PASS",
|
|
3185
|
+
f"plugin content matches the package ({_pkg_version})",
|
|
3186
|
+
)
|
|
3187
|
+
except Exception as _pl_exc: # noqa: BLE001 — never break doctor
|
|
3188
|
+
_check("plugin_skills", "WARN", f"could not probe plugins: {_pl_exc}")
|
|
3189
|
+
|
|
2867
3190
|
# 14. Migration error logs — surface any unresolved failure from a previous
|
|
2868
3191
|
# upgrade attempt. The daemon writes these and they persist until the
|
|
2869
3192
|
# user takes action; doctor is the right place to surface them.
|
|
@@ -3387,7 +3710,12 @@ def cmd_profile(args: Namespace) -> None:
|
|
|
3387
3710
|
if action in ("switch", "create"):
|
|
3388
3711
|
from superlocalmemory.core.admission import gate_cli_mutation
|
|
3389
3712
|
from superlocalmemory.core.operation_request import OperationKind
|
|
3390
|
-
|
|
3713
|
+
# The role that decides this is the one held on the workspace being
|
|
3714
|
+
# switched to or created, not on whichever one happens to be active.
|
|
3715
|
+
gate_cli_mutation(
|
|
3716
|
+
OperationKind.PROFILE_SWITCH,
|
|
3717
|
+
profile=str(getattr(args, "name", "") or ""),
|
|
3718
|
+
)
|
|
3391
3719
|
|
|
3392
3720
|
from superlocalmemory.core.config import SLMConfig
|
|
3393
3721
|
from superlocalmemory.storage.database import DatabaseManager
|
|
@@ -3901,7 +4229,7 @@ def cmd_session_context(args: Namespace) -> None:
|
|
|
3901
4229
|
json_print("session-context", data={
|
|
3902
4230
|
"context": context,
|
|
3903
4231
|
"memory_count": len(inj_mems),
|
|
3904
|
-
"mode": config.mode.value
|
|
4232
|
+
"mode": config.mode.value,
|
|
3905
4233
|
}, next_actions=[
|
|
3906
4234
|
{"command": "slm recall --json <query>", "description": "Search memories"},
|
|
3907
4235
|
])
|
|
@@ -3989,6 +4317,7 @@ def cmd_observe(args: Namespace) -> None:
|
|
|
3989
4317
|
|
|
3990
4318
|
# V3.3.28: Route through daemon (singleton engine, single embedding worker).
|
|
3991
4319
|
# This is the P0 fix for the memory blast incident of April 7, 2026.
|
|
4320
|
+
from superlocalmemory.cli.daemon import DaemonRefused
|
|
3992
4321
|
try:
|
|
3993
4322
|
from superlocalmemory.cli.daemon import is_daemon_running, daemon_request, ensure_daemon
|
|
3994
4323
|
if is_daemon_running() or ensure_daemon():
|
|
@@ -4002,6 +4331,16 @@ def cmd_observe(args: Namespace) -> None:
|
|
|
4002
4331
|
reason = result.get("reason", "no patterns matched")
|
|
4003
4332
|
print(f"Not captured: {reason}")
|
|
4004
4333
|
return
|
|
4334
|
+
except DaemonRefused as refusal:
|
|
4335
|
+
# The workspace declined this write. The fallback below exists for a
|
|
4336
|
+
# daemon that is not running -- not for one that answered no.
|
|
4337
|
+
print(
|
|
4338
|
+
f"[slm] Not captured: {refusal}. "
|
|
4339
|
+
"This workspace requires authentication; set SLM_USER_SESSION or "
|
|
4340
|
+
"log in, then try again.",
|
|
4341
|
+
flush=True,
|
|
4342
|
+
)
|
|
4343
|
+
sys.exit(1)
|
|
4005
4344
|
except Exception:
|
|
4006
4345
|
pass # Fall through to direct engine
|
|
4007
4346
|
|
|
@@ -322,6 +322,25 @@ def _get_port() -> int:
|
|
|
322
322
|
return _DEFAULT_PORT
|
|
323
323
|
|
|
324
324
|
|
|
325
|
+
class DaemonRefused(RuntimeError):
|
|
326
|
+
"""The daemon answered, and the answer was no.
|
|
327
|
+
|
|
328
|
+
Raised for HTTP 401 and 403 only. Distinct from ``daemon_request``
|
|
329
|
+
returning ``None``, which means the daemon could not be reached or did not
|
|
330
|
+
answer usefully. Callers that fall back to a direct engine write MUST let
|
|
331
|
+
this propagate or exit on it: falling back after a refusal performs, as the
|
|
332
|
+
machine owner, exactly the write the workspace just declined.
|
|
333
|
+
"""
|
|
334
|
+
|
|
335
|
+
def __init__(self, status: int, path: str = "") -> None:
|
|
336
|
+
self.status = int(status)
|
|
337
|
+
self.path = path
|
|
338
|
+
super().__init__(
|
|
339
|
+
f"the daemon refused this request (HTTP {status})"
|
|
340
|
+
+ (f" for {path}" if path else "")
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
325
344
|
def daemon_request(
|
|
326
345
|
method: str,
|
|
327
346
|
path: str,
|
|
@@ -376,6 +395,7 @@ def daemon_request(
|
|
|
376
395
|
return {key: value for key, value in legacy.items() if key != "_legacy_port"}
|
|
377
396
|
port = int(legacy["_legacy_port"])
|
|
378
397
|
try:
|
|
398
|
+
import urllib.error
|
|
379
399
|
import urllib.request
|
|
380
400
|
url = f"http://127.0.0.1:{port}{path}"
|
|
381
401
|
data = json.dumps(body).encode() if body else None
|
|
@@ -393,6 +413,16 @@ def daemon_request(
|
|
|
393
413
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
394
414
|
resp = urllib.request.urlopen(req, timeout=timeout_seconds)
|
|
395
415
|
return json.loads(resp.read().decode())
|
|
416
|
+
except urllib.error.HTTPError as exc:
|
|
417
|
+
# A refusal is an answer, not a failure to get one. Returning None here
|
|
418
|
+
# made "you are not allowed to do this" indistinguishable from "the
|
|
419
|
+
# daemon is not running", and every caller that falls back to a local
|
|
420
|
+
# engine write treated the first as the second -- so a workspace that
|
|
421
|
+
# required a login refused the write over HTTP and then performed it
|
|
422
|
+
# locally as the machine owner.
|
|
423
|
+
if exc.code in (401, 403):
|
|
424
|
+
raise DaemonRefused(exc.code, path) from exc
|
|
425
|
+
return None
|
|
396
426
|
except Exception:
|
|
397
427
|
return None
|
|
398
428
|
|
|
@@ -35,6 +35,59 @@ def _resolve_paths(args: Namespace) -> tuple[Path, Path]:
|
|
|
35
35
|
return Path(learning), Path(memory)
|
|
36
36
|
|
|
37
37
|
|
|
38
|
+
def _end_state_disagreements(learning_db, memory_db) -> dict[str, str]:
|
|
39
|
+
"""Migrations recorded complete whose own verification no longer passes.
|
|
40
|
+
|
|
41
|
+
``--status`` reads ``migration_log``, which records what was *done*. The
|
|
42
|
+
daemon re-runs each completed migration's ``verify()`` on every start, which
|
|
43
|
+
checks whether what was done still *holds*. When those two answers differ
|
|
44
|
+
the log says ``complete`` and the health endpoint says the migration failed,
|
|
45
|
+
and until now nothing on any surface showed the two were even asking
|
|
46
|
+
different questions -- so the only available reading was that one of them
|
|
47
|
+
was lying. Reported as #125.
|
|
48
|
+
|
|
49
|
+
Read-only and fail-quiet: this is a diagnostic printed beside a status line,
|
|
50
|
+
and it must never be the reason a status command exits non-zero.
|
|
51
|
+
"""
|
|
52
|
+
import sqlite3
|
|
53
|
+
|
|
54
|
+
from superlocalmemory.storage._migration_internals import _MODULES
|
|
55
|
+
from superlocalmemory.storage.migration_runner import MIGRATIONS
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
from superlocalmemory.storage.migration_runner import DEFERRED_MIGRATIONS
|
|
59
|
+
except ImportError: # pragma: no cover — older layouts
|
|
60
|
+
DEFERRED_MIGRATIONS = ()
|
|
61
|
+
|
|
62
|
+
out: dict[str, str] = {}
|
|
63
|
+
for migration in list(MIGRATIONS) + list(DEFERRED_MIGRATIONS):
|
|
64
|
+
verify_fn = getattr(_MODULES.get(migration.name), "verify", None)
|
|
65
|
+
if not callable(verify_fn):
|
|
66
|
+
continue
|
|
67
|
+
db_path = memory_db if migration.db_target != "learning" else learning_db
|
|
68
|
+
try:
|
|
69
|
+
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
|
70
|
+
except sqlite3.Error:
|
|
71
|
+
continue
|
|
72
|
+
try:
|
|
73
|
+
row = conn.execute(
|
|
74
|
+
"SELECT status FROM migration_log WHERE name = ? LIMIT 1",
|
|
75
|
+
(migration.name,),
|
|
76
|
+
).fetchone()
|
|
77
|
+
if not row or row[0] != "complete":
|
|
78
|
+
continue
|
|
79
|
+
if not bool(verify_fn(conn)):
|
|
80
|
+
out[migration.name] = " <- recorded complete, end-state no longer holds"
|
|
81
|
+
except Exception: # noqa: BLE001 — a diagnostic must not break status
|
|
82
|
+
pass
|
|
83
|
+
finally:
|
|
84
|
+
try:
|
|
85
|
+
conn.close()
|
|
86
|
+
except sqlite3.Error: # pragma: no cover
|
|
87
|
+
pass
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
38
91
|
def cmd_db_migrate(args: Namespace) -> int:
|
|
39
92
|
"""Apply pending migrations or report status.
|
|
40
93
|
|
|
@@ -57,8 +110,25 @@ def cmd_db_migrate(args: Namespace) -> int:
|
|
|
57
110
|
if not report:
|
|
58
111
|
print("(no migrations registered)")
|
|
59
112
|
else:
|
|
113
|
+
disagreements = _end_state_disagreements(learning_db, memory_db)
|
|
60
114
|
for name, state in report.items():
|
|
61
|
-
|
|
115
|
+
note = disagreements.get(name, "")
|
|
116
|
+
print(f" {name}: {state}{note}")
|
|
117
|
+
if disagreements:
|
|
118
|
+
print()
|
|
119
|
+
print(
|
|
120
|
+
" A migration marked complete is re-checked on every start "
|
|
121
|
+
"by its own\n"
|
|
122
|
+
" verification. The ones flagged above are recorded as done "
|
|
123
|
+
"and their\n"
|
|
124
|
+
" end-state no longer holds, which is what the daemon "
|
|
125
|
+
"reports as a\n"
|
|
126
|
+
" migration failure while this log still reads complete. "
|
|
127
|
+
"Run `slm db\n"
|
|
128
|
+
" migrate` to let each one try to repair itself, and see "
|
|
129
|
+
"`migration_failure_reasons`\n"
|
|
130
|
+
" in `slm health --json` for what specifically did not hold."
|
|
131
|
+
)
|
|
62
132
|
return 0
|
|
63
133
|
|
|
64
134
|
dry_run = bool(getattr(args, "dry_run", False))
|