superlocalmemory 4.0.4 → 4.0.5
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 +33 -0
- package/README.md +18 -13
- package/ide/configs/codex-mcp.toml +2 -2
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.mcp.json +1 -0
- 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 +3 -2
- 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 +2 -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 +6 -5
- package/plugin-src/skills/slm-graph/SKILL.md +2 -1
- package/plugin-src/skills/slm-profile/SKILL.md +1 -0
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/brain/__init__.py +5 -0
- package/src/superlocalmemory/brain/truth.py +348 -0
- package/src/superlocalmemory/cli/commands.py +82 -25
- package/src/superlocalmemory/cli/main.py +12 -0
- package/src/superlocalmemory/core/context_cache.py +58 -1
- package/src/superlocalmemory/core/mutations.py +155 -25
- package/src/superlocalmemory/core/recall_pipeline.py +6 -10
- package/src/superlocalmemory/core/remember_runtime.py +271 -2
- package/src/superlocalmemory/core/store_pipeline.py +100 -38
- package/src/superlocalmemory/encoding/consolidator.py +17 -47
- package/src/superlocalmemory/encoding/temporal_validator.py +14 -18
- package/src/superlocalmemory/hooks/user_prompt_hook.py +1 -1
- package/src/superlocalmemory/integrations/bounded_loops_mcp.py +4 -3
- package/src/superlocalmemory/mcp/profiles.py +19 -7
- package/src/superlocalmemory/mcp/server.py +4 -2
- package/src/superlocalmemory/mcp/tools_brain.py +54 -10
- package/src/superlocalmemory/mcp/tools_core.py +88 -3
- package/src/superlocalmemory/retrieval/engine.py +7 -10
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +119 -19
- package/src/superlocalmemory/server/routes/brain.py +15 -0
- package/src/superlocalmemory/server/routes/memories.py +129 -3
- package/src/superlocalmemory/storage/_migration_internals.py +4 -0
- package/src/superlocalmemory/storage/_schema_version.py +2 -2
- package/src/superlocalmemory/storage/correction_cases.py +670 -0
- package/src/superlocalmemory/storage/database.py +194 -24
- package/src/superlocalmemory/storage/migration_runner.py +7 -0
- package/src/superlocalmemory/storage/migrations/M042_correction_case_ledger.py +245 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
- package/src/superlocalmemory/storage/write_coordinator.py +4 -0
- package/src/superlocalmemory/ui/js/brain.js +43 -7
- package/src/superlocalmemory/ui/js/od-brain.js +44 -28
|
@@ -928,8 +928,11 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
928
928
|
)
|
|
929
929
|
if isinstance(result, dict) and result.get("success"):
|
|
930
930
|
return {
|
|
931
|
-
"success": True,
|
|
932
|
-
"
|
|
931
|
+
"success": True,
|
|
932
|
+
"predecessor_fact_id": result.get("predecessor_fact_id", fact_id),
|
|
933
|
+
"successor_fact_id": result.get("successor_fact_id"),
|
|
934
|
+
"correction_case": result.get("correction_case"),
|
|
935
|
+
"review_required": bool(result.get("review_required", False)),
|
|
933
936
|
}
|
|
934
937
|
return {
|
|
935
938
|
"success": False,
|
|
@@ -947,12 +950,94 @@ def register_core_tools(server, get_engine: Callable) -> None:
|
|
|
947
950
|
})
|
|
948
951
|
if result.get("ok"):
|
|
949
952
|
logger.info("Memory updated: %s by agent: %s", fact_id[:16], agent_id)
|
|
950
|
-
return {
|
|
953
|
+
return {
|
|
954
|
+
"success": True,
|
|
955
|
+
"predecessor_fact_id": result.get("predecessor_fact_id", fact_id),
|
|
956
|
+
"successor_fact_id": result.get("successor_fact_id"),
|
|
957
|
+
"correction_case": result.get("correction_case"),
|
|
958
|
+
"review_required": bool(result.get("review_required", False)),
|
|
959
|
+
}
|
|
951
960
|
return {"success": False, "error": result.get("error", "Update failed")}
|
|
952
961
|
except Exception as exc:
|
|
953
962
|
logger.exception("update_memory failed")
|
|
954
963
|
return {"success": False, "error": str(exc)}
|
|
955
964
|
|
|
965
|
+
@server.tool(annotations=ToolAnnotations(idempotentHint=True))
|
|
966
|
+
@admits(OperationKind.CORRECT)
|
|
967
|
+
async def review_correction(
|
|
968
|
+
case_id: str,
|
|
969
|
+
action: str,
|
|
970
|
+
expected_version: int,
|
|
971
|
+
event_valid_until: str | None = None,
|
|
972
|
+
) -> dict:
|
|
973
|
+
"""Apply, reject, or roll back a review-gated correction case.
|
|
974
|
+
|
|
975
|
+
The active daemon derives reviewer identity and profile from its local
|
|
976
|
+
authenticated MCP boundary. Clients provide only a case address, a
|
|
977
|
+
CAS version, and an optional reviewer-approved event-time boundary.
|
|
978
|
+
"""
|
|
979
|
+
if action not in {"apply", "reject", "rollback"}:
|
|
980
|
+
return {"success": False, "error": "action must be apply, reject, or rollback"}
|
|
981
|
+
if not isinstance(expected_version, int) or isinstance(expected_version, bool):
|
|
982
|
+
return {"success": False, "error": "expected_version must be an integer"}
|
|
983
|
+
if expected_version < 0:
|
|
984
|
+
return {"success": False, "error": "expected_version must be non-negative"}
|
|
985
|
+
try:
|
|
986
|
+
import asyncio
|
|
987
|
+
import urllib.parse
|
|
988
|
+
|
|
989
|
+
from superlocalmemory.cli.daemon import daemon_request, is_daemon_running
|
|
990
|
+
|
|
991
|
+
if not await asyncio.to_thread(is_daemon_running):
|
|
992
|
+
return {
|
|
993
|
+
"success": False,
|
|
994
|
+
"retryable": True,
|
|
995
|
+
"error": "correction review requires the resident canonical daemon",
|
|
996
|
+
}
|
|
997
|
+
payload: dict[str, object] = {"expected_version": expected_version}
|
|
998
|
+
if event_valid_until is not None:
|
|
999
|
+
payload["event_valid_until"] = event_valid_until
|
|
1000
|
+
path = "/api/corrections/" + urllib.parse.quote(case_id, safe="") + "/" + action
|
|
1001
|
+
result = await asyncio.to_thread(daemon_request, "POST", path, payload)
|
|
1002
|
+
if isinstance(result, dict) and result.get("success"):
|
|
1003
|
+
return result
|
|
1004
|
+
return {
|
|
1005
|
+
"success": False,
|
|
1006
|
+
"retryable": True,
|
|
1007
|
+
"error": "resident daemon rejected the correction review",
|
|
1008
|
+
}
|
|
1009
|
+
except Exception:
|
|
1010
|
+
logger.exception("review_correction failed")
|
|
1011
|
+
return {"success": False, "retryable": True, "error": "correction review unavailable"}
|
|
1012
|
+
|
|
1013
|
+
@server.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
1014
|
+
async def list_corrections(limit: int = 100) -> dict:
|
|
1015
|
+
"""List active-profile correction cases for a human or host reviewer."""
|
|
1016
|
+
if not isinstance(limit, int) or isinstance(limit, bool) or not 1 <= limit <= 500:
|
|
1017
|
+
return {"success": False, "error": "limit must be an integer from 1 to 500"}
|
|
1018
|
+
try:
|
|
1019
|
+
import asyncio
|
|
1020
|
+
|
|
1021
|
+
from superlocalmemory.cli.daemon import daemon_request, is_daemon_running
|
|
1022
|
+
|
|
1023
|
+
if not await asyncio.to_thread(is_daemon_running):
|
|
1024
|
+
return {
|
|
1025
|
+
"success": False,
|
|
1026
|
+
"retryable": True,
|
|
1027
|
+
"error": "correction review requires the resident canonical daemon",
|
|
1028
|
+
}
|
|
1029
|
+
result = await asyncio.to_thread(daemon_request, "GET", f"/api/corrections?limit={limit}")
|
|
1030
|
+
if isinstance(result, dict) and result.get("success"):
|
|
1031
|
+
return result
|
|
1032
|
+
return {
|
|
1033
|
+
"success": False,
|
|
1034
|
+
"retryable": True,
|
|
1035
|
+
"error": "resident daemon rejected correction listing",
|
|
1036
|
+
}
|
|
1037
|
+
except Exception:
|
|
1038
|
+
logger.exception("list_corrections failed")
|
|
1039
|
+
return {"success": False, "retryable": True, "error": "correction listing unavailable"}
|
|
1040
|
+
|
|
956
1041
|
@server.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
957
1042
|
async def get_attribution() -> dict:
|
|
958
1043
|
"""Get system attribution: author, version, license, and provenance metadata."""
|
|
@@ -29,6 +29,7 @@ from superlocalmemory.core.config import ChannelWeights, RetrievalConfig
|
|
|
29
29
|
from superlocalmemory.retrieval.fusion import FusionResult, weighted_rrf
|
|
30
30
|
from superlocalmemory.retrieval.strategy import QueryStrategy, QueryStrategyClassifier
|
|
31
31
|
from superlocalmemory.retrieval.temporal_validity_filter import (
|
|
32
|
+
CorrectionAdmissionCache,
|
|
32
33
|
admit_correction_candidates,
|
|
33
34
|
admit_correction_fusion_results,
|
|
34
35
|
)
|
|
@@ -232,6 +233,10 @@ class RetrievalEngine:
|
|
|
232
233
|
include_unknown=include_unknown,
|
|
233
234
|
)
|
|
234
235
|
_em("run_channels")
|
|
236
|
+
# One request may need admission before fusion and again after optional
|
|
237
|
+
# bridge/scene expansion. Cache only the IDs checked during this one
|
|
238
|
+
# request; every newly expanded candidate remains a hard DB lookup.
|
|
239
|
+
correction_admission = CorrectionAdmissionCache()
|
|
235
240
|
if profile_hits:
|
|
236
241
|
ch_results["profile"] = profile_hits
|
|
237
242
|
# The profile shortcut bypasses _run_channels(), so it needs the same
|
|
@@ -241,6 +246,7 @@ class RetrievalEngine:
|
|
|
241
246
|
known_as_of=known_as_of, valid_at=valid_at,
|
|
242
247
|
include_unknown=include_unknown,
|
|
243
248
|
include_global=include_global, include_shared=include_shared,
|
|
249
|
+
lifecycle_cache=correction_admission,
|
|
244
250
|
)
|
|
245
251
|
total = sum(len(v) for v in ch_results.values())
|
|
246
252
|
|
|
@@ -357,6 +363,7 @@ class RetrievalEngine:
|
|
|
357
363
|
known_as_of=known_as_of, valid_at=valid_at,
|
|
358
364
|
include_unknown=include_unknown,
|
|
359
365
|
include_global=include_global, include_shared=include_shared,
|
|
366
|
+
lifecycle_cache=correction_admission,
|
|
360
367
|
)
|
|
361
368
|
|
|
362
369
|
_em("expand+entity_enh")
|
|
@@ -949,16 +956,6 @@ class RetrievalEngine:
|
|
|
949
956
|
except Exception as exc:
|
|
950
957
|
logger.warning("Post-retrieval filter failed: %s", exc)
|
|
951
958
|
|
|
952
|
-
# The legacy temporal filter preserves its score-demotion semantics for
|
|
953
|
-
# compatibility. Admission is separate and hard: no current
|
|
954
|
-
# system-superseded fact may seed fusion, bridge discovery, or rerank.
|
|
955
|
-
out = admit_correction_candidates(
|
|
956
|
-
out, profile_id, self._db, as_of=as_of,
|
|
957
|
-
known_as_of=known_as_of, valid_at=valid_at,
|
|
958
|
-
include_unknown=include_unknown,
|
|
959
|
-
include_global=include_global, include_shared=include_shared,
|
|
960
|
-
)
|
|
961
|
-
|
|
962
959
|
return out
|
|
963
960
|
|
|
964
961
|
def close(self, *, wait: bool = False) -> None:
|
|
@@ -49,8 +49,12 @@ All demotions are non-destructive (P5-INT-01): facts stay in the candidate
|
|
|
49
49
|
list but rank below valid facts. A factor of 0.0 restores the legacy hide
|
|
50
50
|
behaviour (a score of zero is gated out by the evidence floor).
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
fail-
|
|
52
|
+
All correction-admission lookups are bounded (candidate ids only), chunked,
|
|
53
|
+
and indexed. Admission is deliberately **fail-closed**: if SLM cannot prove
|
|
54
|
+
which candidates are invalidated, it returns no candidates rather than let an
|
|
55
|
+
approved stale fact re-enter recall. The legacy score-demotion filter follows
|
|
56
|
+
the same rule for its system-invalidated lookup. Event-time demotion remains a
|
|
57
|
+
best-effort ranking signal; it is not the correction authority.
|
|
54
58
|
|
|
55
59
|
Integrates with ChannelRegistry.register_filter() using the FilterFn signature:
|
|
56
60
|
(all_channel_results, profile_id, context) -> filtered_results
|
|
@@ -62,6 +66,7 @@ License: AGPL-3.0-or-later
|
|
|
62
66
|
from __future__ import annotations
|
|
63
67
|
|
|
64
68
|
import logging
|
|
69
|
+
from dataclasses import dataclass, field
|
|
65
70
|
from typing import TYPE_CHECKING, Any
|
|
66
71
|
|
|
67
72
|
if TYPE_CHECKING:
|
|
@@ -79,6 +84,21 @@ logger = logging.getLogger(__name__)
|
|
|
79
84
|
_EVENT_TIME_DEMOTION_FACTOR: float = 0.5
|
|
80
85
|
|
|
81
86
|
|
|
87
|
+
@dataclass
|
|
88
|
+
class CorrectionAdmissionCache:
|
|
89
|
+
"""Per-recall lifecycle admission cache.
|
|
90
|
+
|
|
91
|
+
The retrieval engine performs a mandatory second admission after bridge or
|
|
92
|
+
scene expansion. Facts already checked before fusion do not need a second
|
|
93
|
+
database read in the *same* recall, while any newly expanded id is checked
|
|
94
|
+
immediately. The cache never crosses requests, profiles, or DB writes.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
checked_fact_ids: set[str] = field(default_factory=set)
|
|
98
|
+
inadmissible_fact_ids: set[str] = field(default_factory=set)
|
|
99
|
+
unavailable: bool = False
|
|
100
|
+
|
|
101
|
+
|
|
82
102
|
def _normalized_as_of(as_of: str | None) -> str | None:
|
|
83
103
|
"""Normalize an optional transaction-time boundary at one boundary.
|
|
84
104
|
|
|
@@ -100,26 +120,69 @@ def _invalidated_candidate_ids(
|
|
|
100
120
|
as_of: str | None = None,
|
|
101
121
|
include_global: bool = False,
|
|
102
122
|
include_shared: bool = False,
|
|
123
|
+
lifecycle_cache: CorrectionAdmissionCache | None = None,
|
|
103
124
|
) -> set[str] | None:
|
|
104
|
-
"""Return
|
|
125
|
+
"""Return correction failures, or ``None`` when admission is unprovable.
|
|
105
126
|
|
|
106
|
-
``None``
|
|
107
|
-
|
|
108
|
-
|
|
127
|
+
``None`` is intentionally distinct from an empty set. Callers MUST turn
|
|
128
|
+
it into an abstention (an empty candidate path), never treat it as "nothing
|
|
129
|
+
invalidated". Treating an unavailable lifecycle read as an empty set is a
|
|
130
|
+
fail-open path through which an approved stale fact can be re-admitted.
|
|
109
131
|
"""
|
|
110
132
|
if not fact_ids:
|
|
111
133
|
return set()
|
|
134
|
+
if lifecycle_cache is not None and lifecycle_cache.unavailable:
|
|
135
|
+
return None
|
|
136
|
+
unchecked = (
|
|
137
|
+
fact_ids - lifecycle_cache.checked_fact_ids
|
|
138
|
+
if lifecycle_cache is not None
|
|
139
|
+
else fact_ids
|
|
140
|
+
)
|
|
141
|
+
if not unchecked:
|
|
142
|
+
return (
|
|
143
|
+
lifecycle_cache.inadmissible_fact_ids & fact_ids
|
|
144
|
+
if lifecycle_cache is not None
|
|
145
|
+
else set()
|
|
146
|
+
)
|
|
112
147
|
try:
|
|
113
148
|
kwargs: dict[str, Any] = {"as_of": _normalized_as_of(as_of)}
|
|
114
149
|
if include_global:
|
|
115
150
|
kwargs["include_global"] = True
|
|
116
151
|
if include_shared:
|
|
117
152
|
kwargs["include_shared"] = True
|
|
118
|
-
|
|
153
|
+
# Do not infer support from a permissive mock's dynamic attributes.
|
|
154
|
+
# The concrete storage manager owns this optimized contract; older
|
|
155
|
+
# adapters continue through the two focused public queries below.
|
|
156
|
+
combined = getattr(type(db), "get_correction_inadmissible_fact_ids", None)
|
|
157
|
+
if callable(combined):
|
|
158
|
+
invalid = db.get_correction_inadmissible_fact_ids(
|
|
159
|
+
list(unchecked), profile_id, **kwargs,
|
|
160
|
+
)
|
|
161
|
+
else:
|
|
162
|
+
invalid = db.get_invalidated_fact_ids(list(unchecked), profile_id, **kwargs)
|
|
163
|
+
pending_successors = db.get_nonapplied_correction_successor_ids(
|
|
164
|
+
list(unchecked),
|
|
165
|
+
profile_id,
|
|
166
|
+
include_global=include_global,
|
|
167
|
+
include_shared=include_shared,
|
|
168
|
+
)
|
|
169
|
+
if not isinstance(pending_successors, set):
|
|
170
|
+
return None
|
|
171
|
+
invalid |= pending_successors
|
|
119
172
|
except Exception as exc:
|
|
120
173
|
logger.warning("Correction admission lookup failed: %s", exc)
|
|
174
|
+
if lifecycle_cache is not None:
|
|
175
|
+
lifecycle_cache.unavailable = True
|
|
121
176
|
return None
|
|
122
|
-
|
|
177
|
+
if not isinstance(invalid, set):
|
|
178
|
+
if lifecycle_cache is not None:
|
|
179
|
+
lifecycle_cache.unavailable = True
|
|
180
|
+
return None
|
|
181
|
+
if lifecycle_cache is not None:
|
|
182
|
+
lifecycle_cache.checked_fact_ids.update(unchecked)
|
|
183
|
+
lifecycle_cache.inadmissible_fact_ids.update(invalid)
|
|
184
|
+
return lifecycle_cache.inadmissible_fact_ids & fact_ids
|
|
185
|
+
return invalid
|
|
123
186
|
|
|
124
187
|
|
|
125
188
|
def _strict_temporal_candidate_ids(
|
|
@@ -133,7 +196,7 @@ def _strict_temporal_candidate_ids(
|
|
|
133
196
|
include_global: bool = False,
|
|
134
197
|
include_shared: bool = False,
|
|
135
198
|
) -> set[str] | None:
|
|
136
|
-
"""Return strict two-clock
|
|
199
|
+
"""Return strict two-clock failures, or ``None`` when admission is unprovable."""
|
|
137
200
|
if not fact_ids or (known_as_of is None and valid_at is None):
|
|
138
201
|
return set()
|
|
139
202
|
try:
|
|
@@ -151,6 +214,26 @@ def _strict_temporal_candidate_ids(
|
|
|
151
214
|
return invalid if isinstance(invalid, set) else None
|
|
152
215
|
|
|
153
216
|
|
|
217
|
+
def _abstain_candidates(
|
|
218
|
+
all_results: dict[str, list[tuple[str, float]]],
|
|
219
|
+
*,
|
|
220
|
+
stage: str,
|
|
221
|
+
) -> dict[str, list[tuple[str, float]]]:
|
|
222
|
+
"""Fail closed without changing the retrieval filter public contract.
|
|
223
|
+
|
|
224
|
+
``ChannelRegistry`` and the retrieval engine both expect the original
|
|
225
|
+
channel-result shape. Returning the same channel names with empty lists
|
|
226
|
+
is an explicit candidate-level abstention: downstream fusion naturally
|
|
227
|
+
produces no materializable facts, while callers keep their stable response
|
|
228
|
+
schema and can report ``no_confident_match``.
|
|
229
|
+
"""
|
|
230
|
+
logger.error(
|
|
231
|
+
"Temporal correction admission unavailable at %s; abstaining from recall candidates",
|
|
232
|
+
stage,
|
|
233
|
+
)
|
|
234
|
+
return {channel_name: [] for channel_name in all_results}
|
|
235
|
+
|
|
236
|
+
|
|
154
237
|
def admit_correction_candidates(
|
|
155
238
|
all_results: dict[str, list[tuple[str, float]]],
|
|
156
239
|
profile_id: str,
|
|
@@ -162,6 +245,7 @@ def admit_correction_candidates(
|
|
|
162
245
|
include_unknown: bool = False,
|
|
163
246
|
include_global: bool = False,
|
|
164
247
|
include_shared: bool = False,
|
|
248
|
+
lifecycle_cache: CorrectionAdmissionCache | None = None,
|
|
165
249
|
) -> dict[str, list[tuple[str, float]]]:
|
|
166
250
|
"""Hard-exclude system-superseded facts before candidate fusion.
|
|
167
251
|
|
|
@@ -178,15 +262,20 @@ def admit_correction_candidates(
|
|
|
178
262
|
invalid = _invalidated_candidate_ids(
|
|
179
263
|
db, fact_ids, profile_id, as_of=as_of,
|
|
180
264
|
include_global=include_global, include_shared=include_shared,
|
|
265
|
+
lifecycle_cache=lifecycle_cache,
|
|
181
266
|
)
|
|
267
|
+
if invalid is None:
|
|
268
|
+
return _abstain_candidates(all_results, stage="pre_fusion.lifecycle")
|
|
182
269
|
strict = _strict_temporal_candidate_ids(
|
|
183
270
|
db, fact_ids, profile_id,
|
|
184
271
|
known_as_of=known_as_of, valid_at=valid_at,
|
|
185
272
|
include_unknown=include_unknown,
|
|
186
273
|
include_global=include_global, include_shared=include_shared,
|
|
187
274
|
)
|
|
275
|
+
if strict is None:
|
|
276
|
+
return _abstain_candidates(all_results, stage="pre_fusion.strict_temporal")
|
|
188
277
|
if strict:
|
|
189
|
-
invalid
|
|
278
|
+
invalid |= strict
|
|
190
279
|
if not invalid:
|
|
191
280
|
return all_results
|
|
192
281
|
return {
|
|
@@ -210,21 +299,35 @@ def admit_correction_fusion_results(
|
|
|
210
299
|
include_unknown: bool = False,
|
|
211
300
|
include_global: bool = False,
|
|
212
301
|
include_shared: bool = False,
|
|
302
|
+
lifecycle_cache: CorrectionAdmissionCache | None = None,
|
|
213
303
|
) -> list[Any]:
|
|
214
304
|
"""Re-apply correction admission after graph/scene candidate expansion."""
|
|
215
305
|
fact_ids = {result.fact_id for result in fused_results}
|
|
216
306
|
invalid = _invalidated_candidate_ids(
|
|
217
307
|
db, fact_ids, profile_id, as_of=as_of,
|
|
218
308
|
include_global=include_global, include_shared=include_shared,
|
|
309
|
+
lifecycle_cache=lifecycle_cache,
|
|
219
310
|
)
|
|
311
|
+
if invalid is None:
|
|
312
|
+
logger.error(
|
|
313
|
+
"Temporal correction admission unavailable at post_fusion.lifecycle; "
|
|
314
|
+
"abstaining from recall candidates",
|
|
315
|
+
)
|
|
316
|
+
return []
|
|
220
317
|
strict = _strict_temporal_candidate_ids(
|
|
221
318
|
db, fact_ids, profile_id,
|
|
222
319
|
known_as_of=known_as_of, valid_at=valid_at,
|
|
223
320
|
include_unknown=include_unknown,
|
|
224
321
|
include_global=include_global, include_shared=include_shared,
|
|
225
322
|
)
|
|
323
|
+
if strict is None:
|
|
324
|
+
logger.error(
|
|
325
|
+
"Temporal correction admission unavailable at post_fusion.strict_temporal; "
|
|
326
|
+
"abstaining from recall candidates",
|
|
327
|
+
)
|
|
328
|
+
return []
|
|
226
329
|
if strict:
|
|
227
|
-
invalid
|
|
330
|
+
invalid |= strict
|
|
228
331
|
if not invalid:
|
|
229
332
|
return fused_results
|
|
230
333
|
return [result for result in fused_results if result.fact_id not in invalid]
|
|
@@ -311,14 +414,11 @@ class TemporalValidityFilter:
|
|
|
311
414
|
# When as_of is set: only supersessions that occurred AT OR BEFORE
|
|
312
415
|
# as_of contribute (Phase 4b bi-temporal fix). Supersessions after
|
|
313
416
|
# as_of are invisible — the fact was still valid at the query point.
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
# Fail-open: a validity-lookup error must never break retrieval.
|
|
320
|
-
logger.warning("Temporal validity lookup failed: %s", exc)
|
|
321
|
-
return all_results
|
|
417
|
+
invalid = _invalidated_candidate_ids(
|
|
418
|
+
self._db, all_fact_ids, profile_id, as_of=as_of,
|
|
419
|
+
)
|
|
420
|
+
if invalid is None:
|
|
421
|
+
return _abstain_candidates(all_results, stage="legacy_filter.lifecycle")
|
|
322
422
|
|
|
323
423
|
# --- Axis 2: Event-time expiry (Phase 4 T1b) ---
|
|
324
424
|
# Guard: skip event-time demotion when the caller signals it wants
|
|
@@ -52,6 +52,7 @@ from typing import Any
|
|
|
52
52
|
|
|
53
53
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
54
54
|
from superlocalmemory import __version__
|
|
55
|
+
from superlocalmemory.brain import BrainTruthService
|
|
55
56
|
|
|
56
57
|
from superlocalmemory.core.security_primitives import (
|
|
57
58
|
redact_secrets,
|
|
@@ -1130,11 +1131,16 @@ async def get_brain(request: Request, profile_id: str | None = None) -> dict:
|
|
|
1130
1131
|
# "default" — the Brain must reflect whichever profile is active.
|
|
1131
1132
|
profile_id = _authorized_profile(request, profile_id)
|
|
1132
1133
|
lrn_db = LearningDatabase(_learning_db_path())
|
|
1134
|
+
truth_service = BrainTruthService(
|
|
1135
|
+
memory_db_path=_memory_dir() / "memory.db",
|
|
1136
|
+
learning_db_path=_learning_db_path(),
|
|
1137
|
+
)
|
|
1133
1138
|
|
|
1134
1139
|
(
|
|
1135
1140
|
preferences, learning, usage, bandit_snap, cache,
|
|
1136
1141
|
cross_platform, outcomes_preview, evolution, active_clients,
|
|
1137
1142
|
feedback_loop, source_quality, graph_summary, agent_experience,
|
|
1143
|
+
brain_truth,
|
|
1138
1144
|
) = await asyncio.gather(
|
|
1139
1145
|
asyncio.to_thread(_compute_preferences, profile_id),
|
|
1140
1146
|
asyncio.to_thread(_compute_learning_status, profile_id, lrn_db),
|
|
@@ -1152,6 +1158,7 @@ async def get_brain(request: Request, profile_id: str | None = None) -> dict:
|
|
|
1152
1158
|
asyncio.to_thread(_compute_source_quality, profile_id),
|
|
1153
1159
|
asyncio.to_thread(_compute_graph_summary, profile_id),
|
|
1154
1160
|
asyncio.to_thread(_compute_agent_experience, profile_id),
|
|
1161
|
+
asyncio.to_thread(truth_service.snapshot, profile_id),
|
|
1155
1162
|
return_exceptions=True,
|
|
1156
1163
|
)
|
|
1157
1164
|
|
|
@@ -1213,6 +1220,14 @@ async def get_brain(request: Request, profile_id: str | None = None) -> dict:
|
|
|
1213
1220
|
"source_quality": source_quality,
|
|
1214
1221
|
"graph": graph_summary,
|
|
1215
1222
|
"agent_experience": agent_experience,
|
|
1223
|
+
# Additive v4.0.5 contract. Keep the preceding legacy keys for
|
|
1224
|
+
# existing dashboard/API clients; new clients should render this
|
|
1225
|
+
# one shared, unavailable-aware snapshot.
|
|
1226
|
+
"brain_truth": _ok(brain_truth, {
|
|
1227
|
+
"availability": "unavailable",
|
|
1228
|
+
"source": "BrainTruth service unavailable",
|
|
1229
|
+
"control_plane": "observation_only",
|
|
1230
|
+
}),
|
|
1216
1231
|
"source": "local durable stores + ephemeral session registry",
|
|
1217
1232
|
},
|
|
1218
1233
|
"evolution_preview": _ok(evolution, {
|
|
@@ -1182,9 +1182,9 @@ async def merge_memory(request: Request, fact_id: str):
|
|
|
1182
1182
|
raise _canonical_mutation_error(exc, "Merge error")
|
|
1183
1183
|
|
|
1184
1184
|
|
|
1185
|
-
@router.patch("/api/memories/{fact_id}")
|
|
1185
|
+
@router.patch("/api/memories/{fact_id}", status_code=202)
|
|
1186
1186
|
async def edit_memory(request: Request, fact_id: str):
|
|
1187
|
-
"""
|
|
1187
|
+
"""Propose an immutable, review-required correction for one memory."""
|
|
1188
1188
|
try:
|
|
1189
1189
|
body = await request.json()
|
|
1190
1190
|
new_content = (body.get("content") or "").strip()
|
|
@@ -1210,13 +1210,139 @@ async def edit_memory(request: Request, fact_id: str):
|
|
|
1210
1210
|
)
|
|
1211
1211
|
if not result.get("ok"):
|
|
1212
1212
|
raise HTTPException(status_code=404, detail="Memory not found")
|
|
1213
|
-
|
|
1213
|
+
if result.get("unchanged"):
|
|
1214
|
+
return {"success": True, "fact_id": fact_id, "content": new_content, "unchanged": True}
|
|
1215
|
+
correction = result["correction_case"]
|
|
1216
|
+
return {
|
|
1217
|
+
"success": True,
|
|
1218
|
+
"fact_id": fact_id,
|
|
1219
|
+
"predecessor_fact_id": result["predecessor_fact_id"],
|
|
1220
|
+
"successor_fact_id": result["successor_fact_id"],
|
|
1221
|
+
"correction_case": correction,
|
|
1222
|
+
"review_required": True,
|
|
1223
|
+
"status": "proposed",
|
|
1224
|
+
}
|
|
1214
1225
|
except HTTPException:
|
|
1215
1226
|
raise
|
|
1216
1227
|
except Exception as exc:
|
|
1217
1228
|
raise _canonical_mutation_error(exc, "Edit error")
|
|
1218
1229
|
|
|
1219
1230
|
|
|
1231
|
+
@router.post("/api/corrections/{case_id}/{action}")
|
|
1232
|
+
async def review_correction(request: Request, case_id: str, action: str):
|
|
1233
|
+
"""Apply, reject, or roll back an active-profile correction case.
|
|
1234
|
+
|
|
1235
|
+
The caller authenticates through the daemon boundary. It cannot select a
|
|
1236
|
+
profile, fact scope, or trust tier; the canonical writer rechecks all of
|
|
1237
|
+
those fields in its one SQLite transaction.
|
|
1238
|
+
"""
|
|
1239
|
+
try:
|
|
1240
|
+
body = await request.json()
|
|
1241
|
+
if action not in {"apply", "reject", "rollback"}:
|
|
1242
|
+
raise HTTPException(422, detail="action must be apply, reject, or rollback")
|
|
1243
|
+
expected_version = body.get("expected_version") if isinstance(body, dict) else None
|
|
1244
|
+
if not isinstance(expected_version, int) or isinstance(expected_version, bool):
|
|
1245
|
+
raise HTTPException(422, detail="expected_version must be a non-negative integer")
|
|
1246
|
+
if expected_version < 0:
|
|
1247
|
+
raise HTTPException(422, detail="expected_version must be a non-negative integer")
|
|
1248
|
+
event_valid_until = body.get("event_valid_until") if isinstance(body, dict) else None
|
|
1249
|
+
if event_valid_until is not None and not isinstance(event_valid_until, str):
|
|
1250
|
+
raise HTTPException(422, detail="event_valid_until must be an RFC3339 timestamp")
|
|
1251
|
+
if event_valid_until is not None and action != "apply":
|
|
1252
|
+
raise HTTPException(422, detail="event_valid_until is permitted only for apply")
|
|
1253
|
+
engine, active_profile, hook_context = _authorize_memory_mutation(
|
|
1254
|
+
request, "update", case_id, run_pre_hook=False
|
|
1255
|
+
)
|
|
1256
|
+
result = _canonical_mutation_runtime(request).transition_correction(
|
|
1257
|
+
active_profile,
|
|
1258
|
+
case_id,
|
|
1259
|
+
action=action,
|
|
1260
|
+
expected_version=expected_version,
|
|
1261
|
+
actor_id=hook_context["agent_id"],
|
|
1262
|
+
event_valid_until=event_valid_until,
|
|
1263
|
+
idempotency_key=_mutation_idempotency_key(request),
|
|
1264
|
+
)
|
|
1265
|
+
if not result.get("ok"):
|
|
1266
|
+
raise HTTPException(404, detail="Correction case not found")
|
|
1267
|
+
if action in {"apply", "rollback"}:
|
|
1268
|
+
from superlocalmemory.core.mutations import purge_profile_context_cache
|
|
1269
|
+
|
|
1270
|
+
purge_profile_context_cache(engine, active_profile)
|
|
1271
|
+
engine._hooks.run_post("update", hook_context)
|
|
1272
|
+
return {"success": True, "correction_case": result}
|
|
1273
|
+
except HTTPException:
|
|
1274
|
+
raise
|
|
1275
|
+
except Exception as exc:
|
|
1276
|
+
raise _canonical_mutation_error(exc, "Correction review error")
|
|
1277
|
+
|
|
1278
|
+
|
|
1279
|
+
def _correction_case_response(case) -> dict[str, object]:
|
|
1280
|
+
"""Return review metadata only; correction ledgers never contain fact text."""
|
|
1281
|
+
return {
|
|
1282
|
+
"case_id": case.case_id,
|
|
1283
|
+
"profile_id": case.profile_id,
|
|
1284
|
+
"scope": case.scope,
|
|
1285
|
+
"predecessor_fact_id": case.predecessor_fact_id,
|
|
1286
|
+
"successor_fact_id": case.successor_fact_id,
|
|
1287
|
+
"reason_code": case.reason_code,
|
|
1288
|
+
"status": case.status,
|
|
1289
|
+
"version": case.version,
|
|
1290
|
+
"created_at": case.created_at,
|
|
1291
|
+
"updated_at": case.updated_at,
|
|
1292
|
+
"reviewed_at": case.reviewed_at,
|
|
1293
|
+
"applied_at": case.applied_at,
|
|
1294
|
+
"system_effective_at": case.system_effective_at,
|
|
1295
|
+
"event_valid_from": case.event_valid_from,
|
|
1296
|
+
"event_valid_until": case.event_valid_until,
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
def _correction_store_for(engine, active_profile: str):
|
|
1301
|
+
from superlocalmemory.storage.correction_cases import CorrectionCaseStore
|
|
1302
|
+
|
|
1303
|
+
return CorrectionCaseStore(
|
|
1304
|
+
engine._db.db_path,
|
|
1305
|
+
is_profile_active=lambda candidate: candidate == active_profile,
|
|
1306
|
+
# Read operations never invoke this callback; writes use the daemon's
|
|
1307
|
+
# canonical runtime, which derives the authenticated actor separately.
|
|
1308
|
+
is_actor_trusted=lambda _actor: False,
|
|
1309
|
+
)
|
|
1310
|
+
|
|
1311
|
+
|
|
1312
|
+
@router.get("/api/corrections")
|
|
1313
|
+
async def list_corrections(request: Request, limit: int = 100):
|
|
1314
|
+
"""List bounded review metadata for the active owning profile."""
|
|
1315
|
+
try:
|
|
1316
|
+
engine, active_profile, _context = _authorize_memory_mutation(
|
|
1317
|
+
request, "update", "correction-list", run_pre_hook=False
|
|
1318
|
+
)
|
|
1319
|
+
cases = _correction_store_for(engine, active_profile).list_cases(active_profile, limit=limit)
|
|
1320
|
+
return {"success": True, "corrections": [_correction_case_response(case) for case in cases]}
|
|
1321
|
+
except HTTPException:
|
|
1322
|
+
raise
|
|
1323
|
+
except Exception as exc:
|
|
1324
|
+
raise _canonical_mutation_error(exc, "Correction list error")
|
|
1325
|
+
|
|
1326
|
+
|
|
1327
|
+
@router.get("/api/corrections/{case_id}")
|
|
1328
|
+
async def get_correction(request: Request, case_id: str):
|
|
1329
|
+
"""Get one active-profile correction case without exposing raw memory text."""
|
|
1330
|
+
try:
|
|
1331
|
+
engine, active_profile, _context = _authorize_memory_mutation(
|
|
1332
|
+
request, "update", case_id, run_pre_hook=False
|
|
1333
|
+
)
|
|
1334
|
+
case = _correction_store_for(engine, active_profile).get_case(case_id)
|
|
1335
|
+
return {"success": True, "correction": _correction_case_response(case)}
|
|
1336
|
+
except HTTPException:
|
|
1337
|
+
raise
|
|
1338
|
+
except Exception as exc:
|
|
1339
|
+
from superlocalmemory.storage.correction_cases import CorrectionNotFoundError
|
|
1340
|
+
|
|
1341
|
+
if isinstance(exc, CorrectionNotFoundError):
|
|
1342
|
+
raise HTTPException(404, detail="Correction case not found") from exc
|
|
1343
|
+
raise _canonical_mutation_error(exc, "Correction lookup error")
|
|
1344
|
+
|
|
1345
|
+
|
|
1220
1346
|
_VALID_SCOPES = ("personal", "shared", "global")
|
|
1221
1347
|
|
|
1222
1348
|
|
|
@@ -150,6 +150,9 @@ from superlocalmemory.storage.migrations import (
|
|
|
150
150
|
from superlocalmemory.storage.migrations import (
|
|
151
151
|
M041_external_evidence_receipts as _M041,
|
|
152
152
|
)
|
|
153
|
+
from superlocalmemory.storage.migrations import (
|
|
154
|
+
M042_correction_case_ledger as _M042,
|
|
155
|
+
)
|
|
153
156
|
|
|
154
157
|
# Emit under the runner's logger name so operational log filters that key on
|
|
155
158
|
# "superlocalmemory.storage.migration_runner" keep matching after this split.
|
|
@@ -199,6 +202,7 @@ _MODULES = {
|
|
|
199
202
|
_M039.NAME: _M039,
|
|
200
203
|
_M040.NAME: _M040,
|
|
201
204
|
_M041.NAME: _M041,
|
|
205
|
+
_M042.NAME: _M042,
|
|
202
206
|
}
|
|
203
207
|
|
|
204
208
|
# Exact historical DDL fingerprints whose resulting schema is intentionally
|
|
@@ -21,9 +21,9 @@ import sqlite3
|
|
|
21
21
|
from pathlib import Path
|
|
22
22
|
|
|
23
23
|
#: Highest schema_version this runner can write. Matches the trailing serial
|
|
24
|
-
#: of the latest migration (
|
|
24
|
+
#: of the latest migration (M042). Increment when adding new migrations or
|
|
25
25
|
#: table-level breaking changes.
|
|
26
|
-
SUPPORTED_SCHEMA_VERSION: int =
|
|
26
|
+
SUPPORTED_SCHEMA_VERSION: int = 42
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
class SchemaVersionError(RuntimeError):
|