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
|
@@ -68,19 +68,35 @@ async def _reward_proxy_loop(
|
|
|
68
68
|
interval_sec: float,
|
|
69
69
|
) -> None:
|
|
70
70
|
"""Run the proxy settler on a steady interval. Never raises."""
|
|
71
|
+
from superlocalmemory.learning.reward_from_outcomes import (
|
|
72
|
+
settle_from_outcomes,
|
|
73
|
+
)
|
|
71
74
|
from superlocalmemory.learning.reward_proxy import settle_stale_plays
|
|
72
75
|
|
|
73
76
|
while True:
|
|
74
77
|
try:
|
|
75
78
|
await asyncio.sleep(interval_sec)
|
|
79
|
+
# Reported outcomes FIRST. The proxy defaults a play once its
|
|
80
|
+
# window closes; if it ran first it would claim every play and a
|
|
81
|
+
# real outcome arriving later would find nothing to settle.
|
|
82
|
+
real = await asyncio.to_thread(
|
|
83
|
+
settle_from_outcomes,
|
|
84
|
+
profile_id, learning_db, memory_db,
|
|
85
|
+
)
|
|
76
86
|
# The settler is synchronous + fast; run in a thread to avoid
|
|
77
87
|
# blocking the event loop on unusual DB lock stalls.
|
|
78
88
|
n = await asyncio.to_thread(
|
|
79
89
|
settle_stale_plays,
|
|
80
90
|
profile_id, learning_db, memory_db,
|
|
81
91
|
)
|
|
92
|
+
# INFO, not DEBUG, when a real outcome moved an arm: this is the
|
|
93
|
+
# only line that distinguishes a learning loop that is running
|
|
94
|
+
# from one that merely starts. Its absence for a month is what
|
|
95
|
+
# made this defect invisible.
|
|
96
|
+
if real:
|
|
97
|
+
logger.info("bandit settled %d play(s) from outcomes", real)
|
|
82
98
|
if n:
|
|
83
|
-
logger.debug("bandit.reward_proxy settled=%d", n)
|
|
99
|
+
logger.debug("bandit.reward_proxy settled=%d (default)", n)
|
|
84
100
|
except asyncio.CancelledError: # pragma: no cover — lifecycle
|
|
85
101
|
raise
|
|
86
102
|
except Exception as exc: # pragma: no cover — defensive
|
|
@@ -27,6 +27,9 @@ from typing import Any
|
|
|
27
27
|
from fastapi import HTTPException, Request
|
|
28
28
|
|
|
29
29
|
from superlocalmemory.access.rbac import Permission, Role, permissions_for_role
|
|
30
|
+
import logging
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
30
33
|
|
|
31
34
|
_SESSION_HEADER = "X-SLM-User-Session"
|
|
32
35
|
_SESSION_COOKIE = "slm_session"
|
|
@@ -132,12 +135,29 @@ def resolve_actor_roles(request: Request, *, profile: str | None = None):
|
|
|
132
135
|
if rbac is not None:
|
|
133
136
|
try:
|
|
134
137
|
role = rbac.get_role(principal["user_id"], profile or _active_profile())
|
|
135
|
-
except Exception:
|
|
136
|
-
#
|
|
137
|
-
#
|
|
138
|
-
#
|
|
139
|
-
#
|
|
140
|
-
|
|
138
|
+
except Exception as exc: # noqa: BLE001
|
|
139
|
+
# A lookup that failed is not a lookup that said yes.
|
|
140
|
+
#
|
|
141
|
+
# This used to return MEMBER, on the reasoning that the caller had
|
|
142
|
+
# already passed a coarser permission check so a transient database
|
|
143
|
+
# error should not deny an authorised write. The effect was that any
|
|
144
|
+
# error in the role lookup -- a write-lock timeout, a checkpoint, a
|
|
145
|
+
# corrupt page -- promoted a viewer to a role that can write, at
|
|
146
|
+
# exactly the moment the store was under stress. A caller able to
|
|
147
|
+
# provoke lock contention could provoke the promotion.
|
|
148
|
+
#
|
|
149
|
+
# "Ask again in a moment" is the honest answer and the one the
|
|
150
|
+
# caller can act on. It is neither a denial nor a grant.
|
|
151
|
+
from fastapi import HTTPException
|
|
152
|
+
|
|
153
|
+
logger.warning(
|
|
154
|
+
"rbac: the role for this caller could not be read (%s); "
|
|
155
|
+
"answering 503 rather than assuming one", exc,
|
|
156
|
+
)
|
|
157
|
+
raise HTTPException(
|
|
158
|
+
status_code=503,
|
|
159
|
+
detail="the workspace's roles are temporarily unreadable; retry",
|
|
160
|
+
) from exc
|
|
141
161
|
mapped = {
|
|
142
162
|
Role.ADMIN: ActorRole.ADMIN,
|
|
143
163
|
Role.MEMBER: ActorRole.MEMBER,
|
|
@@ -39,6 +39,7 @@ from __future__ import annotations
|
|
|
39
39
|
|
|
40
40
|
import logging
|
|
41
41
|
import threading
|
|
42
|
+
import time
|
|
42
43
|
from contextlib import nullcontext
|
|
43
44
|
from dataclasses import dataclass
|
|
44
45
|
|
|
@@ -67,6 +68,17 @@ class RecallHealth:
|
|
|
67
68
|
checks: int = 0
|
|
68
69
|
last_semantic_score: float = 0.0
|
|
69
70
|
last_error: str = ""
|
|
71
|
+
#: When the last tick finished, as a unix timestamp. A tick that finds
|
|
72
|
+
#: nothing wrong logs nothing, which is correct -- a monitor that narrates
|
|
73
|
+
#: every success is a monitor whose real warnings get skimmed past. But it
|
|
74
|
+
#: left no way to tell a monitor that is ticking quietly from a thread that
|
|
75
|
+
#: died or never started, and that ambiguity cost someone an hour of looking
|
|
76
|
+
#: for log lines that were never going to appear. So the fact of the tick is
|
|
77
|
+
#: recorded here and surfaced on /health, where it can be checked instead of
|
|
78
|
+
#: inferred.
|
|
79
|
+
last_tick_at: float = 0.0
|
|
80
|
+
#: Whether the embedder could produce a vector at the last tick.
|
|
81
|
+
embedder_alive: bool = True
|
|
70
82
|
|
|
71
83
|
|
|
72
84
|
def _max_semantic(results) -> float:
|
|
@@ -91,6 +103,38 @@ def _get_embedder(engine):
|
|
|
91
103
|
return emb
|
|
92
104
|
|
|
93
105
|
|
|
106
|
+
def _embedder_is_dead(engine) -> bool:
|
|
107
|
+
"""Can the embedder produce a vector right now?
|
|
108
|
+
|
|
109
|
+
Asked directly, because it cannot be inferred from a recall. The monitor
|
|
110
|
+
used to decide the embedder was fine whenever the probe recall came back
|
|
111
|
+
with no results at all -- and a dead embedder is one of the reasons a recall
|
|
112
|
+
comes back with no results, so the one symptom that should have triggered a
|
|
113
|
+
heal was read as proof that none was needed.
|
|
114
|
+
|
|
115
|
+
That is not hypothetical. An idle-timeout kill leaves no worker; the next
|
|
116
|
+
probe finds nothing by meaning, finds nothing by keyword either because the
|
|
117
|
+
probe phrase appears in nobody's memories, and returns zero results. The
|
|
118
|
+
monitor then recorded "healthy", logged nothing, and never respawned the
|
|
119
|
+
worker -- so ``readiness.embedding`` stayed false and the daemon sat in
|
|
120
|
+
``warming`` until someone restarted it by hand, with not one line in the log
|
|
121
|
+
to say why.
|
|
122
|
+
|
|
123
|
+
Fails safe in the opposite direction from before: an embedder this cannot
|
|
124
|
+
reach is reported dead, so the worst case is one unnecessary re-warm rather
|
|
125
|
+
than a silent outage.
|
|
126
|
+
"""
|
|
127
|
+
emb = _get_embedder(engine)
|
|
128
|
+
if emb is None:
|
|
129
|
+
return False # BM25-only by configuration; nothing to heal.
|
|
130
|
+
warm = getattr(emb, "is_warm", None)
|
|
131
|
+
if warm is not None and not warm:
|
|
132
|
+
return True
|
|
133
|
+
if getattr(emb, "_available", True) is False:
|
|
134
|
+
return True
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
|
|
94
138
|
def _heal_embedder(engine, *, log) -> bool:
|
|
95
139
|
"""Tier 3: reset the cached availability flag and re-exercise the embedder.
|
|
96
140
|
|
|
@@ -169,10 +213,27 @@ def run_health_tick(engine, state: RecallHealth, *, probe: str = DEFAULT_PROBE,
|
|
|
169
213
|
results = list(getattr(resp, "results", []) or [])
|
|
170
214
|
sem = _max_semantic(results)
|
|
171
215
|
state.last_semantic_score = sem
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
#
|
|
175
|
-
|
|
216
|
+
state.last_tick_at = time.time()
|
|
217
|
+
|
|
218
|
+
# Tier 2: readiness. Two independent signatures, and the second one is why
|
|
219
|
+
# this monitor exists.
|
|
220
|
+
#
|
|
221
|
+
# * rows present but semantic never fired -> warm-but-broken
|
|
222
|
+
# * the embedder cannot produce a vector -> dead, whatever the recall said
|
|
223
|
+
#
|
|
224
|
+
# The second used to be missing, and its absence was load-bearing: zero
|
|
225
|
+
# results was treated as "not this signature", so the case where the embedder
|
|
226
|
+
# is dead AND the probe matches nothing by keyword -- which is the normal
|
|
227
|
+
# shape of an idle-timeout kill -- came out as healthy, silently.
|
|
228
|
+
dead = _embedder_is_dead(engine)
|
|
229
|
+
state.embedder_alive = not dead
|
|
230
|
+
broken = dead or (bool(results) and sem <= 0.0)
|
|
231
|
+
if dead:
|
|
232
|
+
log.critical(
|
|
233
|
+
"recall-health: embedder cannot produce a vector (%d probe results) "
|
|
234
|
+
"— attempting self-heal",
|
|
235
|
+
len(results),
|
|
236
|
+
)
|
|
176
237
|
if not broken:
|
|
177
238
|
if not state.healthy:
|
|
178
239
|
log.warning(
|
|
@@ -184,12 +245,16 @@ def run_health_tick(engine, state: RecallHealth, *, probe: str = DEFAULT_PROBE,
|
|
|
184
245
|
state.last_error = ""
|
|
185
246
|
return state
|
|
186
247
|
|
|
187
|
-
# Tier 3: self-heal.
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
248
|
+
# Tier 3: self-heal. The dead-embedder case already said so above; saying
|
|
249
|
+
# "semantic channel DEAD (max semantic=0.0)" as well would be a second,
|
|
250
|
+
# differently-worded CRITICAL about the same tick, and one of the two would
|
|
251
|
+
# be describing a symptom the reader does not have.
|
|
252
|
+
if not dead:
|
|
253
|
+
log.critical(
|
|
254
|
+
"recall-health: semantic channel DEAD (%d results, max semantic=0.0) "
|
|
255
|
+
"— embedder returning None; attempting self-heal",
|
|
256
|
+
len(results),
|
|
257
|
+
)
|
|
193
258
|
if _heal_embedder(engine, log=log):
|
|
194
259
|
state.total_heals += 1
|
|
195
260
|
state.healthy = True
|
|
@@ -256,6 +321,7 @@ def start_recall_health_monitor(engine, *, interval_s: int = DEFAULT_INTERVAL_S,
|
|
|
256
321
|
def get_recall_health() -> dict:
|
|
257
322
|
"""Snapshot for /health surfacing (visibility — never silent degradation)."""
|
|
258
323
|
s = _GLOBAL_STATE
|
|
324
|
+
now = time.time()
|
|
259
325
|
return {
|
|
260
326
|
"recall_healthy": s.healthy,
|
|
261
327
|
"consecutive_failures": s.consecutive_failures,
|
|
@@ -263,4 +329,15 @@ def get_recall_health() -> dict:
|
|
|
263
329
|
"checks": s.checks,
|
|
264
330
|
"last_semantic_score": round(s.last_semantic_score, 4),
|
|
265
331
|
"last_error": s.last_error,
|
|
332
|
+
# Proof of life. A tick that finds nothing wrong logs nothing, so there
|
|
333
|
+
# was no way to tell this monitor apart from a thread that never started
|
|
334
|
+
# -- someone spent an hour reading logs for lines that were never going
|
|
335
|
+
# to be written. These two answer that without needing the log at all:
|
|
336
|
+
# if seconds_since_last_tick keeps climbing past the interval, the thread
|
|
337
|
+
# is gone.
|
|
338
|
+
"last_tick_at": round(s.last_tick_at, 3) if s.last_tick_at else None,
|
|
339
|
+
"seconds_since_last_tick": (
|
|
340
|
+
round(now - s.last_tick_at, 1) if s.last_tick_at else None
|
|
341
|
+
),
|
|
342
|
+
"embedder_alive": s.embedder_alive,
|
|
266
343
|
}
|
|
@@ -291,6 +291,10 @@ def recall_response_metadata(response: Any) -> dict:
|
|
|
291
291
|
"score_contract_version": getattr(response, "score_contract_version", "2"),
|
|
292
292
|
"calibration_status": getattr(response, "calibration_status", "uncalibrated"),
|
|
293
293
|
"calibration_id": getattr(response, "calibration_id", None),
|
|
294
|
+
# The name of this answer. A caller that reports back how the answer
|
|
295
|
+
# went can quote it, and the report then joins to this exact recall
|
|
296
|
+
# instead of being matched by overlapping memory ids.
|
|
297
|
+
"query_id": getattr(response, "query_id", "") or "",
|
|
294
298
|
"answer_confidence": getattr(response, "answer_confidence", None),
|
|
295
299
|
"abstained": bool(getattr(response, "abstained", False)),
|
|
296
300
|
"abstention_reason": getattr(response, "abstention_reason", None),
|
|
@@ -308,4 +312,9 @@ def recall_response_metadata(response: Any) -> dict:
|
|
|
308
312
|
"incomplete_channels": list(
|
|
309
313
|
getattr(response, "incomplete_channels", ()) or ()
|
|
310
314
|
),
|
|
315
|
+
# What became of every channel. Travels with the answer for the same
|
|
316
|
+
# reason as the field above: a caller comparing two runs, or an
|
|
317
|
+
# operator looking at a thin result set, otherwise cannot tell a store
|
|
318
|
+
# with nothing to say from a retrieval path that is partly down.
|
|
319
|
+
"channel_status": dict(getattr(response, "channel_status", {}) or {}),
|
|
311
320
|
}
|
|
@@ -29,6 +29,66 @@ _MAX_OUTCOME_FACT_IDS = 100
|
|
|
29
29
|
_MAX_FACT_ID_LENGTH = 200
|
|
30
30
|
|
|
31
31
|
|
|
32
|
+
def _outcome_insert(
|
|
33
|
+
conn,
|
|
34
|
+
*,
|
|
35
|
+
outcome_id: str,
|
|
36
|
+
profile: str,
|
|
37
|
+
fact_ids_json: str,
|
|
38
|
+
outcome: str,
|
|
39
|
+
context_json: str,
|
|
40
|
+
now_iso: str,
|
|
41
|
+
reward: float,
|
|
42
|
+
recall_query_id: str,
|
|
43
|
+
) -> tuple[str, tuple]:
|
|
44
|
+
"""Build the ``action_outcomes`` INSERT from the columns that exist.
|
|
45
|
+
|
|
46
|
+
``reward``, ``settled``, ``settled_at`` and ``recall_query_id`` all arrive
|
|
47
|
+
with M006, which is a DEFERRED migration — it runs after engine init, not
|
|
48
|
+
during ``apply_all``. This handler has always named them unconditionally, so
|
|
49
|
+
on a store where M006 has not run it raised ``no such column`` and returned
|
|
50
|
+
``success: false`` for a write that could perfectly well have been recorded
|
|
51
|
+
without them.
|
|
52
|
+
|
|
53
|
+
``recall_query_id`` is the one that matters for learning: it is what lets a
|
|
54
|
+
bandit play be settled from this report. The column has existed since M006
|
|
55
|
+
and was never written — 0 of 162 rows on a live store carry one — so
|
|
56
|
+
every play fell through to the neutral 120-second default and 165 arms sat
|
|
57
|
+
at alpha == beta. Recording it is the join key that closes the loop.
|
|
58
|
+
|
|
59
|
+
Degrading per column rather than per statement means an unmigrated store
|
|
60
|
+
still records the outcome; it just cannot attribute it to a play, which is
|
|
61
|
+
the honest reduction in capability.
|
|
62
|
+
"""
|
|
63
|
+
columns = ["outcome_id", "profile_id", "query", "fact_ids_json",
|
|
64
|
+
"outcome", "context_json", "timestamp"]
|
|
65
|
+
values: list = [outcome_id, profile, "", fact_ids_json, outcome,
|
|
66
|
+
context_json, now_iso]
|
|
67
|
+
try:
|
|
68
|
+
present = {
|
|
69
|
+
row[1] for row in conn.execute("PRAGMA table_info(action_outcomes)")
|
|
70
|
+
}
|
|
71
|
+
except Exception: # pragma: no cover — defensive
|
|
72
|
+
present = set()
|
|
73
|
+
|
|
74
|
+
for name, value in (
|
|
75
|
+
("reward", reward),
|
|
76
|
+
("settled", 1),
|
|
77
|
+
("settled_at", now_iso),
|
|
78
|
+
("recall_query_id", recall_query_id),
|
|
79
|
+
):
|
|
80
|
+
if name in present:
|
|
81
|
+
columns.append(name)
|
|
82
|
+
values.append(value)
|
|
83
|
+
|
|
84
|
+
placeholders = ", ".join("?" * len(columns))
|
|
85
|
+
sql = (
|
|
86
|
+
f"INSERT INTO action_outcomes ({', '.join(columns)}) "
|
|
87
|
+
f"VALUES ({placeholders})"
|
|
88
|
+
)
|
|
89
|
+
return sql, tuple(values)
|
|
90
|
+
|
|
91
|
+
|
|
32
92
|
class ReportOutcomeRequest(BaseModel):
|
|
33
93
|
"""Bounded explicit outcome payload accepted from dashboard clients."""
|
|
34
94
|
|
|
@@ -41,6 +101,11 @@ class ReportOutcomeRequest(BaseModel):
|
|
|
41
101
|
outcome: Literal["success", "failure", "partial"]
|
|
42
102
|
action_type: StrictStr = Field(default="other", max_length=80)
|
|
43
103
|
context: StrictStr = Field(default="", max_length=1000)
|
|
104
|
+
#: Which recall this outcome judges, if the caller knows. Optional because
|
|
105
|
+
#: recall does not currently return its query_id to callers, so nothing can
|
|
106
|
+
#: supply one yet; when it is absent the settler falls back to overlap
|
|
107
|
+
#: between ``memory_ids`` and the memories a play recorded showing.
|
|
108
|
+
recall_query_id: StrictStr = Field(default="", max_length=64)
|
|
44
109
|
|
|
45
110
|
@field_validator("memory_ids")
|
|
46
111
|
@classmethod
|
|
@@ -394,16 +459,16 @@ def report_outcome(request: Request, data: ReportOutcomeRequest):
|
|
|
394
459
|
fact_ids=memory_ids,
|
|
395
460
|
)
|
|
396
461
|
conn.execute(
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
json.dumps(
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
462
|
+
*_outcome_insert(
|
|
463
|
+
conn,
|
|
464
|
+
outcome_id=outcome_id,
|
|
465
|
+
profile=profile,
|
|
466
|
+
fact_ids_json=json.dumps(memory_ids),
|
|
467
|
+
outcome=outcome,
|
|
468
|
+
context_json=json.dumps(context_dict),
|
|
469
|
+
now_iso=now_iso,
|
|
470
|
+
reward=reward,
|
|
471
|
+
recall_query_id=data.recall_query_id,
|
|
407
472
|
),
|
|
408
473
|
)
|
|
409
474
|
|
|
@@ -40,9 +40,33 @@ except ImportError:
|
|
|
40
40
|
logger.info("V3 compliance engine not available")
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
def _require_manage(request) -> None:
|
|
44
|
+
"""Require MANAGE on the active workspace, where the workspace has roles.
|
|
45
|
+
|
|
46
|
+
A store with no roles configured has nobody to check against, and refusing
|
|
47
|
+
there would break every personal install; the loopback boundary is the
|
|
48
|
+
control in that case.
|
|
49
|
+
"""
|
|
50
|
+
try:
|
|
51
|
+
from superlocalmemory.access.rbac import Permission
|
|
52
|
+
from superlocalmemory.server.rbac_enforce import require_permission
|
|
53
|
+
|
|
54
|
+
require_permission(request, Permission.MANAGE, profile=get_active_profile())
|
|
55
|
+
except ImportError: # pragma: no cover -- roles are always present in-tree
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
|
|
43
59
|
@router.get("/api/compliance/status")
|
|
44
|
-
async def compliance_status():
|
|
60
|
+
async def compliance_status(request: Request):
|
|
45
61
|
"""Get compliance engine status for active profile."""
|
|
62
|
+
# This returns which operations ran and what categories of memory exist.
|
|
63
|
+
# That is operational metadata about someone's store; it had no gate at all.
|
|
64
|
+
from superlocalmemory.server.write_identity import require_http_mutation_actor
|
|
65
|
+
|
|
66
|
+
require_http_mutation_actor(
|
|
67
|
+
request, getattr(request.app.state, "daemon_descriptor", None),
|
|
68
|
+
actor_kind="compliance-read",
|
|
69
|
+
)
|
|
46
70
|
if not COMPLIANCE_AVAILABLE:
|
|
47
71
|
return {"available": False, "message": "Compliance engine not available"}
|
|
48
72
|
|
|
@@ -132,8 +156,43 @@ async def query_audit_trail(
|
|
|
132
156
|
return {"available": False, "error": "Internal server error"}
|
|
133
157
|
|
|
134
158
|
|
|
159
|
+
def _erasure_succeeded(result: dict) -> bool:
|
|
160
|
+
"""One place that decides, so the surfaces cannot disagree.
|
|
161
|
+
|
|
162
|
+
The erasure reports two things, because they are two questions: whether the
|
|
163
|
+
data is gone, and whether that can be shown afterwards. This route used to
|
|
164
|
+
inline its own list of failure markers and the CLI used a different one, so
|
|
165
|
+
the API could say failure while the command line printed COMPLETE for the
|
|
166
|
+
same erasure.
|
|
167
|
+
|
|
168
|
+
Anything that leaves either question unanswered is a failure here. A caller
|
|
169
|
+
that wants the finer distinction reads ``erasure_complete`` and
|
|
170
|
+
``erasure_provable`` from the body, which are both in it.
|
|
171
|
+
"""
|
|
172
|
+
markers = (
|
|
173
|
+
"vector_store_failures",
|
|
174
|
+
"audit_completion_failed",
|
|
175
|
+
"audit_request_failed",
|
|
176
|
+
"receipt_persist_failed",
|
|
177
|
+
"table_delete_failures",
|
|
178
|
+
"code_graph_failed",
|
|
179
|
+
"fact_expansion_fts_failed",
|
|
180
|
+
"working_sets_failed",
|
|
181
|
+
"residue_recount_failed",
|
|
182
|
+
"backup_scan_failed",
|
|
183
|
+
)
|
|
184
|
+
if any(result.get(marker) for marker in markers):
|
|
185
|
+
return False
|
|
186
|
+
# Present on the profile path; absent on the entity path, where its absence
|
|
187
|
+
# must not be read as a failure.
|
|
188
|
+
for verdict in ("erasure_complete", "erasure_provable"):
|
|
189
|
+
if verdict in result and not result.get(verdict):
|
|
190
|
+
return False
|
|
191
|
+
return True
|
|
192
|
+
|
|
193
|
+
|
|
135
194
|
@router.post("/api/compliance/retention-policy")
|
|
136
|
-
async def create_retention_policy(data: dict):
|
|
195
|
+
async def create_retention_policy(request: Request, data: dict):
|
|
137
196
|
"""Create a compliance retention policy.
|
|
138
197
|
|
|
139
198
|
Body: {
|
|
@@ -144,6 +203,17 @@ async def create_retention_policy(data: dict):
|
|
|
144
203
|
applies_to: dict (optional)
|
|
145
204
|
}
|
|
146
205
|
"""
|
|
206
|
+
# A retention rule decides what is kept and for how long, so changing one
|
|
207
|
+
# is a persistent configuration change on someone else's data. It is gated
|
|
208
|
+
# on the same loopback-trusted boundary the audit read uses, and on MANAGE
|
|
209
|
+
# in a workspace that has roles -- it had neither.
|
|
210
|
+
from superlocalmemory.server.write_identity import require_http_mutation_actor
|
|
211
|
+
|
|
212
|
+
require_http_mutation_actor(
|
|
213
|
+
request, getattr(request.app.state, "daemon_descriptor", None),
|
|
214
|
+
actor_kind="retention-policy",
|
|
215
|
+
)
|
|
216
|
+
_require_manage(request)
|
|
147
217
|
if not COMPLIANCE_AVAILABLE:
|
|
148
218
|
return {"success": False, "error": "Compliance engine not available"}
|
|
149
219
|
|
|
@@ -183,8 +253,19 @@ async def create_retention_policy(data: dict):
|
|
|
183
253
|
|
|
184
254
|
|
|
185
255
|
@router.delete("/api/compliance/retention-policy")
|
|
186
|
-
async def delete_retention_policy(name: str = Query(...)):
|
|
256
|
+
async def delete_retention_policy(request: Request, name: str = Query(...)):
|
|
187
257
|
"""Delete a retention policy by name for the active profile."""
|
|
258
|
+
# A retention rule decides what is kept and for how long, so changing one
|
|
259
|
+
# is a persistent configuration change on someone else's data. It is gated
|
|
260
|
+
# on the same loopback-trusted boundary the audit read uses, and on MANAGE
|
|
261
|
+
# in a workspace that has roles -- it had neither.
|
|
262
|
+
from superlocalmemory.server.write_identity import require_http_mutation_actor
|
|
263
|
+
|
|
264
|
+
require_http_mutation_actor(
|
|
265
|
+
request, getattr(request.app.state, "daemon_descriptor", None),
|
|
266
|
+
actor_kind="retention-policy",
|
|
267
|
+
)
|
|
268
|
+
_require_manage(request)
|
|
188
269
|
if not COMPLIANCE_AVAILABLE:
|
|
189
270
|
return {"success": False, "error": "Compliance engine not available"}
|
|
190
271
|
try:
|
|
@@ -202,12 +283,23 @@ async def delete_retention_policy(name: str = Query(...)):
|
|
|
202
283
|
|
|
203
284
|
|
|
204
285
|
@router.post("/api/compliance/retention/enforce")
|
|
205
|
-
async def enforce_retention():
|
|
286
|
+
async def enforce_retention(request: Request):
|
|
206
287
|
"""Run all retention policies for the active profile now.
|
|
207
288
|
|
|
208
289
|
Moves expired facts to their rule's terminal lifecycle zone (archive/
|
|
209
290
|
tombstone) or counts them (notify). Soft-state only — never a raw delete.
|
|
210
291
|
"""
|
|
292
|
+
# A retention rule decides what is kept and for how long, so changing one
|
|
293
|
+
# is a persistent configuration change on someone else's data. It is gated
|
|
294
|
+
# on the same loopback-trusted boundary the audit read uses, and on MANAGE
|
|
295
|
+
# in a workspace that has roles -- it had neither.
|
|
296
|
+
from superlocalmemory.server.write_identity import require_http_mutation_actor
|
|
297
|
+
|
|
298
|
+
require_http_mutation_actor(
|
|
299
|
+
request, getattr(request.app.state, "daemon_descriptor", None),
|
|
300
|
+
actor_kind="retention-policy",
|
|
301
|
+
)
|
|
302
|
+
_require_manage(request)
|
|
211
303
|
if not COMPLIANCE_AVAILABLE:
|
|
212
304
|
return {"success": False, "error": "Compliance engine not available"}
|
|
213
305
|
try:
|
|
@@ -302,13 +394,7 @@ async def gdpr_erase(request: Request, data: dict = {}):
|
|
|
302
394
|
result = GDPRCompliance(engine._db, engine=engine).forget_profile(profile)
|
|
303
395
|
authorization.complete()
|
|
304
396
|
result = result or {}
|
|
305
|
-
|
|
306
|
-
"vector_store_failures",
|
|
307
|
-
"audit_completion_failed",
|
|
308
|
-
"audit_request_failed",
|
|
309
|
-
"receipt_persist_failed",
|
|
310
|
-
)
|
|
311
|
-
success = not any(result.get(marker) for marker in failure_markers)
|
|
397
|
+
success = _erasure_succeeded(result)
|
|
312
398
|
return {"success": success, "active_profile": profile, **result}
|
|
313
399
|
except Exception:
|
|
314
400
|
logger.exception("gdpr_erase error")
|
|
@@ -361,13 +447,7 @@ async def gdpr_erase_entity(request: Request, data: dict = {}):
|
|
|
361
447
|
result = GDPRCompliance(engine._db, engine=engine).forget_entity(entity_name, profile)
|
|
362
448
|
authorization.complete()
|
|
363
449
|
result = result or {}
|
|
364
|
-
|
|
365
|
-
"vector_store_failures",
|
|
366
|
-
"audit_completion_failed",
|
|
367
|
-
"audit_request_failed",
|
|
368
|
-
"receipt_persist_failed",
|
|
369
|
-
)
|
|
370
|
-
success = not any(result.get(marker) for marker in failure_markers)
|
|
450
|
+
success = _erasure_succeeded(result)
|
|
371
451
|
return {
|
|
372
452
|
"success": success, "active_profile": profile,
|
|
373
453
|
"entity_name": entity_name, **result,
|