superlocalmemory 4.0.3 → 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 +55 -0
- package/README.md +19 -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 +5 -4
- 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 +7 -6
- 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 +3 -2
- package/plugin-src/skills/slm-loop/SKILL.md +1 -1
- package/plugin-src/skills/slm-mesh/SKILL.md +1 -1
- package/plugin-src/skills/slm-profile/SKILL.md +5 -4
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-scope/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- 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/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 +185 -0
- package/src/superlocalmemory/learning/database.py +2 -1
- package/src/superlocalmemory/mcp/profiles.py +25 -7
- package/src/superlocalmemory/mcp/server.py +7 -2
- package/src/superlocalmemory/mcp/tools_brain.py +138 -9
- 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 +21 -1
- package/src/superlocalmemory/server/routes/memories.py +129 -3
- package/src/superlocalmemory/storage/_migration_internals.py +8 -0
- package/src/superlocalmemory/storage/_schema_version.py +2 -2
- package/src/superlocalmemory/storage/agent_experience.py +26 -4
- package/src/superlocalmemory/storage/correction_cases.py +670 -0
- package/src/superlocalmemory/storage/database.py +194 -24
- package/src/superlocalmemory/storage/external_evidence.py +359 -0
- package/src/superlocalmemory/storage/migration_runner.py +12 -0
- package/src/superlocalmemory/storage/migrations/M041_external_evidence_receipts.py +189 -0
- package/src/superlocalmemory/storage/migrations/M042_correction_case_ledger.py +245 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +4 -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 -19
|
@@ -77,13 +77,13 @@ def reset_engine():
|
|
|
77
77
|
|
|
78
78
|
# Register tools and resources -------------------------------------------------
|
|
79
79
|
#
|
|
80
|
-
# Essential-only default:
|
|
80
|
+
# Essential-only default: 41 base tools + 8 mesh tools = 49 registered.
|
|
81
81
|
# when mesh is enabled. Set ``SLM_MCP_ALL_TOOLS=1`` to expose the full
|
|
82
82
|
# toolset. Rationale: IDEs cap at 50-100 tools total (Cursor,
|
|
83
83
|
# Antigravity, Windsurf) and a maximal SLM registration crowds out
|
|
84
84
|
# other MCP servers the user may have installed.
|
|
85
85
|
# Admin/diagnostics tools remain available via CLI (`slm <command>`).
|
|
86
|
-
# Set SLM_MCP_ALL_TOOLS=1 to enable all
|
|
86
|
+
# Set SLM_MCP_ALL_TOOLS=1 to enable all 94 tools (power users).
|
|
87
87
|
|
|
88
88
|
import os as _os_reg
|
|
89
89
|
|
|
@@ -100,6 +100,11 @@ _ESSENTIAL_TOOLS: set[str] = {
|
|
|
100
100
|
# v4.0.2 portable Brain evidence: profile-scoped receipt reads/writes.
|
|
101
101
|
"get_brain_evidence_status", "record_agent_experience",
|
|
102
102
|
"record_cognitive_turn", "finalize_cognitive_turn",
|
|
103
|
+
# v4.0.4: explicit, optional observation from the separately installed
|
|
104
|
+
# Bounded Loops MCP producer. It never participates in recall/ranking.
|
|
105
|
+
"observe_bounded_loop_evidence",
|
|
106
|
+
# Update, review, and list form one core correction lifecycle.
|
|
107
|
+
"review_correction", "list_corrections",
|
|
103
108
|
# Memory management (2)
|
|
104
109
|
"forget", "run_maintenance",
|
|
105
110
|
# NOTE: prestage_context IS registered (see register_prestage_tool below)
|
|
@@ -9,21 +9,29 @@ or changes a memory answer.
|
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
+
import asyncio
|
|
13
|
+
import sqlite3
|
|
12
14
|
from pathlib import Path
|
|
13
15
|
from typing import Any, Callable
|
|
14
16
|
|
|
15
17
|
from mcp.types import ToolAnnotations
|
|
16
18
|
|
|
19
|
+
from superlocalmemory.brain.truth import BrainTruthService
|
|
17
20
|
from superlocalmemory.core.admission import admits
|
|
18
21
|
from superlocalmemory.core.operation_request import OperationKind
|
|
19
22
|
from superlocalmemory.infra.data_root import state_path
|
|
23
|
+
from superlocalmemory.integrations.bounded_loops_mcp import BridgeUnavailable, observe_installed
|
|
20
24
|
from superlocalmemory.storage.agent_experience import (
|
|
21
25
|
AgentExperienceConflictError,
|
|
22
26
|
AgentExperienceStore,
|
|
23
27
|
CognitiveTurnTransitionError,
|
|
24
28
|
LearningWriteBusyError,
|
|
25
29
|
ProfileAdmissionError,
|
|
26
|
-
|
|
30
|
+
)
|
|
31
|
+
from superlocalmemory.storage.external_evidence import (
|
|
32
|
+
ExternalEvidenceConflictError,
|
|
33
|
+
ExternalEvidenceStore,
|
|
34
|
+
ExternalEvidenceValidationError,
|
|
27
35
|
)
|
|
28
36
|
|
|
29
37
|
|
|
@@ -35,6 +43,56 @@ def _store_for(engine: Any) -> AgentExperienceStore:
|
|
|
35
43
|
)
|
|
36
44
|
|
|
37
45
|
|
|
46
|
+
def _external_store_for(engine: Any) -> ExternalEvidenceStore:
|
|
47
|
+
active_profile = engine.profile_id
|
|
48
|
+
return ExternalEvidenceStore(
|
|
49
|
+
Path(state_path("learning.db")),
|
|
50
|
+
is_profile_active=lambda profile_id: profile_id == active_profile,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _brain_truth_for(engine: Any) -> dict[str, Any]:
|
|
55
|
+
"""Read the portable truth snapshot without opening an engine or a writer."""
|
|
56
|
+
return BrainTruthService(
|
|
57
|
+
memory_db_path=state_path("memory.db"),
|
|
58
|
+
learning_db_path=state_path("learning.db"),
|
|
59
|
+
).snapshot(engine.profile_id)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _legacy_agent_experience(truth: dict[str, Any]) -> dict[str, Any]:
|
|
63
|
+
"""Keep the v4.0.4 MCP alias during the one-release transition window."""
|
|
64
|
+
evidence = truth["agent_experience"]
|
|
65
|
+
available = evidence["availability"] == "available"
|
|
66
|
+
claimed = evidence["claimed_experiences_total"]
|
|
67
|
+
turns = evidence["cognitive_turns_total"]
|
|
68
|
+
states = evidence["cognitive_turns_by_state"]
|
|
69
|
+
return {
|
|
70
|
+
"is_real": available,
|
|
71
|
+
"availability": evidence["availability"],
|
|
72
|
+
"experiences_total": claimed if available else 0,
|
|
73
|
+
"turns_total": turns if available else 0,
|
|
74
|
+
"turns_by_state": states if available else {},
|
|
75
|
+
# The old name remains an alias only. BrainTruth deliberately calls
|
|
76
|
+
# these declared claims, never independently verified learning.
|
|
77
|
+
"claimed_evidence_experiences": claimed if available else 0,
|
|
78
|
+
"source": evidence["source"],
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _legacy_external_evidence(truth: dict[str, Any]) -> dict[str, Any]:
|
|
83
|
+
"""Keep the v4.0.4 external-graph alias without a second database read."""
|
|
84
|
+
evidence = truth["external_evidence"]
|
|
85
|
+
available = evidence["availability"] == "available"
|
|
86
|
+
return {
|
|
87
|
+
"is_real": available,
|
|
88
|
+
"availability": evidence["availability"],
|
|
89
|
+
"total": evidence["receipts_total"] if available else 0,
|
|
90
|
+
"by_run_state": evidence["receipts_by_run_state"] if available else {},
|
|
91
|
+
"demonstrations": evidence["demonstrations_total"] if available else 0,
|
|
92
|
+
"control_plane": "observation_only",
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
38
96
|
def _require_active_profile(engine: Any, payload: dict[str, Any]) -> str | None:
|
|
39
97
|
supplied = payload.get("profile_id")
|
|
40
98
|
if supplied != engine.profile_id:
|
|
@@ -42,6 +100,27 @@ def _require_active_profile(engine: Any, payload: dict[str, Any]) -> str | None:
|
|
|
42
100
|
return None
|
|
43
101
|
|
|
44
102
|
|
|
103
|
+
class _ExternalEvidenceWriteError(Exception):
|
|
104
|
+
"""Preserve committed receipt count when a later snapshot item fails."""
|
|
105
|
+
|
|
106
|
+
def __init__(self, created: int, cause: Exception) -> None:
|
|
107
|
+
super().__init__(str(cause))
|
|
108
|
+
self.created = created
|
|
109
|
+
self.cause = cause
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _record_external_evidence(store: ExternalEvidenceStore, observed: list[dict[str, Any]]) -> int:
|
|
113
|
+
"""Write bounded evidence off the async MCP loop; return durable inserts."""
|
|
114
|
+
created = 0
|
|
115
|
+
for payload in observed:
|
|
116
|
+
try:
|
|
117
|
+
if store.record(payload):
|
|
118
|
+
created += 1
|
|
119
|
+
except Exception as exc:
|
|
120
|
+
raise _ExternalEvidenceWriteError(created, exc) from exc
|
|
121
|
+
return created
|
|
122
|
+
|
|
123
|
+
|
|
45
124
|
def register_brain_tools(server: Any, get_engine: Callable[[], Any]) -> None:
|
|
46
125
|
"""Register transport-neutral receipt reads and writes.
|
|
47
126
|
|
|
@@ -52,15 +131,21 @@ def register_brain_tools(server: Any, get_engine: Callable[[], Any]) -> None:
|
|
|
52
131
|
|
|
53
132
|
@server.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
54
133
|
async def get_brain_evidence_status() -> dict[str, Any]:
|
|
55
|
-
"""Get profile-scoped
|
|
134
|
+
"""Get profile-scoped, observation-only Living Brain evidence totals.
|
|
135
|
+
|
|
136
|
+
``brain_truth`` is the canonical v1 payload. The legacy aliases are
|
|
137
|
+
retained for one release so existing hosts can move independently.
|
|
138
|
+
"""
|
|
56
139
|
engine = get_engine()
|
|
140
|
+
truth = _brain_truth_for(engine)
|
|
57
141
|
return {
|
|
58
142
|
"success": True,
|
|
59
143
|
"profile_id": engine.profile_id,
|
|
60
|
-
"
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
"
|
|
144
|
+
"brain_truth": truth,
|
|
145
|
+
"agent_experience": _legacy_agent_experience(truth),
|
|
146
|
+
"external_evidence": truth["external_evidence"],
|
|
147
|
+
"external_graph_evidence": _legacy_external_evidence(truth),
|
|
148
|
+
"control_plane": truth["control_plane"],
|
|
64
149
|
}
|
|
65
150
|
|
|
66
151
|
@server.tool()
|
|
@@ -110,9 +195,7 @@ def register_brain_tools(server: Any, get_engine: Callable[[], Any]) -> None:
|
|
|
110
195
|
|
|
111
196
|
@server.tool()
|
|
112
197
|
@admits(OperationKind.REMEMBER)
|
|
113
|
-
async def finalize_cognitive_turn(
|
|
114
|
-
receipt_id: str, outcome: dict[str, Any]
|
|
115
|
-
) -> dict[str, Any]:
|
|
198
|
+
async def finalize_cognitive_turn(receipt_id: str, outcome: dict[str, Any]) -> dict[str, Any]:
|
|
116
199
|
"""Finalize an active-profile cognitive turn with outcome evidence."""
|
|
117
200
|
engine = get_engine()
|
|
118
201
|
try:
|
|
@@ -130,3 +213,49 @@ def register_brain_tools(server: Any, get_engine: Callable[[], Any]) -> None:
|
|
|
130
213
|
except (TypeError, ValueError) as exc:
|
|
131
214
|
return {"success": False, "durable": False, "error": str(exc)}
|
|
132
215
|
return {"success": True, "durable": True, "finalized": finalized}
|
|
216
|
+
|
|
217
|
+
@server.tool()
|
|
218
|
+
@admits(OperationKind.REMEMBER)
|
|
219
|
+
async def observe_bounded_loop_evidence(workspace: str) -> dict[str, Any]:
|
|
220
|
+
"""Import one explicit, read-only snapshot from installed Bounded Loops.
|
|
221
|
+
|
|
222
|
+
Bounded Loops remains optional. This tool negotiates its public MCP
|
|
223
|
+
contract at runtime, records only compatible terminal evidence, and
|
|
224
|
+
never changes recall, ranking, routing, or learned behaviour.
|
|
225
|
+
"""
|
|
226
|
+
engine = get_engine()
|
|
227
|
+
created = 0
|
|
228
|
+
try:
|
|
229
|
+
observed = await observe_installed(workspace=workspace, profile_id=engine.profile_id)
|
|
230
|
+
store = _external_store_for(engine)
|
|
231
|
+
created = await asyncio.to_thread(_record_external_evidence, store, observed)
|
|
232
|
+
except _ExternalEvidenceWriteError as exc:
|
|
233
|
+
return {
|
|
234
|
+
"success": False,
|
|
235
|
+
"durable": exc.created > 0,
|
|
236
|
+
"created": exc.created,
|
|
237
|
+
"retryable": isinstance(exc.cause, LearningWriteBusyError),
|
|
238
|
+
"error": str(exc.cause),
|
|
239
|
+
}
|
|
240
|
+
except (
|
|
241
|
+
BridgeUnavailable,
|
|
242
|
+
ExternalEvidenceConflictError,
|
|
243
|
+
ExternalEvidenceValidationError,
|
|
244
|
+
ProfileAdmissionError,
|
|
245
|
+
sqlite3.Error,
|
|
246
|
+
) as exc:
|
|
247
|
+
return {
|
|
248
|
+
"success": False,
|
|
249
|
+
"durable": created > 0,
|
|
250
|
+
"created": created,
|
|
251
|
+
"error": str(exc),
|
|
252
|
+
}
|
|
253
|
+
except LearningWriteBusyError as exc:
|
|
254
|
+
return {"success": False, "durable": False, "retryable": True, "error": str(exc)}
|
|
255
|
+
return {
|
|
256
|
+
"success": True,
|
|
257
|
+
"durable": True,
|
|
258
|
+
"observed": len(observed),
|
|
259
|
+
"created": created,
|
|
260
|
+
"control_plane": "observation_only",
|
|
261
|
+
}
|
|
@@ -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
|