superlocalmemory 4.0.4 → 4.0.6
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 +90 -0
- package/README.md +23 -14
- package/ide/configs/codex-mcp.toml +2 -2
- package/package.json +3 -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/access/rbac.py +106 -0
- package/src/superlocalmemory/brain/__init__.py +5 -0
- package/src/superlocalmemory/brain/truth.py +418 -0
- package/src/superlocalmemory/cli/__main__.py +17 -0
- package/src/superlocalmemory/cli/commands.py +96 -28
- package/src/superlocalmemory/cli/gdpr_cmd.py +779 -0
- package/src/superlocalmemory/cli/gdpr_io.py +109 -0
- package/src/superlocalmemory/cli/main.py +88 -0
- package/src/superlocalmemory/code_graph/extractors/__init__.py +17 -0
- package/src/superlocalmemory/code_graph/graph_store.py +180 -3
- package/src/superlocalmemory/code_graph/parser.py +280 -100
- package/src/superlocalmemory/compliance/gdpr.py +358 -0
- package/src/superlocalmemory/core/config.py +44 -1
- package/src/superlocalmemory/core/context_cache.py +58 -1
- package/src/superlocalmemory/core/engine_wiring.py +5 -1
- package/src/superlocalmemory/core/maintenance.py +43 -1
- package/src/superlocalmemory/core/mutations.py +155 -25
- package/src/superlocalmemory/core/recall_pipeline.py +6 -10
- package/src/superlocalmemory/core/recall_worker.py +33 -12
- 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/infra/backup.py +138 -0
- package/src/superlocalmemory/infra/backup_obligations.py +423 -0
- package/src/superlocalmemory/integrations/bounded_loops_mcp.py +4 -3
- package/src/superlocalmemory/learning/engagement.py +165 -0
- 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_code_graph.py +31 -4
- package/src/superlocalmemory/mcp/tools_core.py +88 -3
- package/src/superlocalmemory/mcp/tools_v3.py +20 -6
- package/src/superlocalmemory/retrieval/engine.py +28 -10
- package/src/superlocalmemory/retrieval/remote_reranker.py +108 -11
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +119 -19
- package/src/superlocalmemory/server/routes/brain.py +297 -14
- package/src/superlocalmemory/server/routes/learning.py +13 -25
- package/src/superlocalmemory/server/routes/memories.py +129 -3
- package/src/superlocalmemory/server/routes/v3_api.py +171 -60
- 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 +230 -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/models.py +12 -4
- package/src/superlocalmemory/storage/write_coordinator.py +4 -0
- package/src/superlocalmemory/summaries/__init__.py +37 -0
- package/src/superlocalmemory/summaries/base.py +108 -0
- package/src/superlocalmemory/summaries/daily_reflection.py +293 -0
- package/src/superlocalmemory/summaries/project_work_log.py +424 -0
- package/src/superlocalmemory/summaries/session_summary.py +307 -0
- package/src/superlocalmemory/ui/css/design-system.css +76 -1
- package/src/superlocalmemory/ui/index.html +28 -11
- package/src/superlocalmemory/ui/js/brain.js +43 -7
- package/src/superlocalmemory/ui/js/od-agents.js +49 -5
- package/src/superlocalmemory/ui/js/od-brain.js +280 -84
- package/src/superlocalmemory/ui/js/od-graph.js +147 -6
|
@@ -17,10 +17,13 @@ from __future__ import annotations
|
|
|
17
17
|
# v3.6.14 WP-01: Named profile definitions
|
|
18
18
|
# ---------------------------------------------------------------------------
|
|
19
19
|
|
|
20
|
-
_PROFILE_CORE: frozenset[str] = frozenset({ #
|
|
20
|
+
_PROFILE_CORE: frozenset[str] = frozenset({ # 16
|
|
21
21
|
"remember", "recall", "search", "fetch", "list_recent", "update_memory", "forget",
|
|
22
22
|
"session_init", "close_session",
|
|
23
23
|
"slm_compress", "slm_retrieve", "slm_cache_set", "slm_cache_get", "slm_optimize_stats",
|
|
24
|
+
# A client that can propose a correction must be able to inspect and
|
|
25
|
+
# authenticate its review; otherwise the core lifecycle is incomplete.
|
|
26
|
+
"review_correction", "list_corrections",
|
|
24
27
|
})
|
|
25
28
|
|
|
26
29
|
# Portable Brain evidence must reach the coding-host profile shipped by the
|
|
@@ -31,7 +34,7 @@ _PROFILE_BRAIN: frozenset[str] = frozenset({
|
|
|
31
34
|
"observe_bounded_loop_evidence",
|
|
32
35
|
})
|
|
33
36
|
|
|
34
|
-
_PROFILE_CODE: frozenset[str] = _PROFILE_CORE | _PROFILE_BRAIN | frozenset({ #
|
|
37
|
+
_PROFILE_CODE: frozenset[str] = _PROFILE_CORE | _PROFILE_BRAIN | frozenset({ # 31
|
|
35
38
|
"build_code_graph", "get_blast_radius", "query_graph",
|
|
36
39
|
"semantic_search_code", "get_review_context", "detect_changes",
|
|
37
40
|
# switch_profile lets a plugin/IDE session change the active workspace over
|
|
@@ -48,22 +51,23 @@ _PROFILE_FULL_MESH: frozenset[str] = frozenset({ # 8
|
|
|
48
51
|
"mesh_state", "mesh_lock", "mesh_events", "mesh_status",
|
|
49
52
|
})
|
|
50
53
|
|
|
51
|
-
|
|
54
|
+
# 41 base — explicit literal, not runtime _ESSENTIAL_TOOLS (OQ-2).
|
|
55
|
+
_PROFILE_FULL: frozenset[str] = frozenset({
|
|
52
56
|
"remember", "recall", "search", "fetch", "list_recent", "delete_memory", "update_memory",
|
|
53
57
|
"get_status", "session_init", "observe", "close_session", "report_feedback", "forget",
|
|
54
58
|
"run_maintenance", "consolidate_cognitive", "get_soft_prompts", "set_mode", "report_outcome",
|
|
55
59
|
"log_tool_event", "get_assertions", "reinforce_assertion", "contradict_assertion",
|
|
56
60
|
"get_brain_evidence_status", "record_agent_experience",
|
|
57
61
|
"record_cognitive_turn", "finalize_cognitive_turn",
|
|
58
|
-
"observe_bounded_loop_evidence",
|
|
62
|
+
"observe_bounded_loop_evidence", "review_correction", "list_corrections",
|
|
59
63
|
"evolve_skill", "skill_health", "skill_lineage", "switch_profile",
|
|
60
64
|
"slm_compress", "slm_retrieve", "slm_cache_set", "slm_cache_get", "slm_optimize_stats",
|
|
61
65
|
# v3.8.0: bounded-loop tools (CLI + /slm-loop command + MCP).
|
|
62
66
|
"slm_loop_run", "slm_loop_history", "slm_loop_show",
|
|
63
67
|
# prestage_context remains registered but deliberately raw-server-only.
|
|
64
|
-
}) | _PROFILE_FULL_MESH #
|
|
68
|
+
}) | _PROFILE_FULL_MESH # 49
|
|
65
69
|
|
|
66
|
-
_PROFILE_POWER: frozenset[str] = _PROFILE_FULL | frozenset({ #
|
|
70
|
+
_PROFILE_POWER: frozenset[str] = _PROFILE_FULL | frozenset({ # 61
|
|
67
71
|
"get_version", "get_mode", "health", "consistency_check", "recall_trace",
|
|
68
72
|
"get_lifecycle_status", "set_retention_policy", "compact_memories",
|
|
69
73
|
"get_behavioral_patterns", "audit_trail", "quantize", "get_retention_stats",
|
|
@@ -86,6 +90,7 @@ _PROFILE_DEFINITIONS: dict[str, frozenset[str]] = {
|
|
|
86
90
|
# at server startup. Any other value is a configuration error (fail closed).
|
|
87
91
|
_PROFILE_ALIASES: dict[str, str] = {
|
|
88
92
|
"core14": "core",
|
|
93
|
+
"core16": "core",
|
|
89
94
|
# 3.8.0 and later additions grew code/full/power; every historical count
|
|
90
95
|
# power. Every historical count-suffixed name is kept so a v3.6/3.7/early-
|
|
91
96
|
# 3.8 config still resolves (back-compat); new 3.8.0 counts added alongside.
|
|
@@ -94,21 +99,25 @@ _PROFILE_ALIASES: dict[str, str] = {
|
|
|
94
99
|
"code24": "code",
|
|
95
100
|
"code28": "code",
|
|
96
101
|
"code29": "code",
|
|
102
|
+
"code31": "code",
|
|
97
103
|
"full38": "full",
|
|
98
104
|
"full39": "full",
|
|
99
105
|
"full42": "full",
|
|
100
106
|
"full46": "full",
|
|
101
107
|
"full47": "full",
|
|
108
|
+
"full49": "full",
|
|
102
109
|
"power50": "power",
|
|
103
110
|
"power51": "power",
|
|
104
111
|
"power54": "power",
|
|
105
112
|
"power58": "power",
|
|
106
113
|
"power59": "power",
|
|
114
|
+
"power61": "power",
|
|
107
115
|
"mesh8": "mesh",
|
|
108
116
|
"whole81": "whole",
|
|
109
117
|
"whole84": "whole",
|
|
110
118
|
"whole91": "whole",
|
|
111
119
|
"whole92": "whole",
|
|
120
|
+
"whole94": "whole",
|
|
112
121
|
}
|
|
113
122
|
|
|
114
123
|
# Plain-English descriptions for UI display.
|
|
@@ -116,7 +125,10 @@ _PROFILE_ALIASES: dict[str, str] = {
|
|
|
116
125
|
# one sentence, user-facing language only.
|
|
117
126
|
PROFILE_DESCRIPTIONS: dict[str, str] = {
|
|
118
127
|
"core": "Essential memory: store, recall, search, sessions",
|
|
119
|
-
"code":
|
|
128
|
+
"code": (
|
|
129
|
+
"Core + code graph, portable Brain evidence, and profile switching "
|
|
130
|
+
"(default for IDE coding agents)"
|
|
131
|
+
),
|
|
120
132
|
"full": "All everyday memory, portable Brain evidence, optimization, and mesh tools",
|
|
121
133
|
"power": "Everything in full plus advanced governance and behavioral tools",
|
|
122
134
|
"mesh": "Cross-device mesh coordination only",
|
|
@@ -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
|
|
|
@@ -103,6 +103,8 @@ _ESSENTIAL_TOOLS: set[str] = {
|
|
|
103
103
|
# v4.0.4: explicit, optional observation from the separately installed
|
|
104
104
|
# Bounded Loops MCP producer. It never participates in recall/ranking.
|
|
105
105
|
"observe_bounded_loop_evidence",
|
|
106
|
+
# Update, review, and list form one core correction lifecycle.
|
|
107
|
+
"review_correction", "list_corrections",
|
|
106
108
|
# Memory management (2)
|
|
107
109
|
"forget", "run_maintenance",
|
|
108
110
|
# NOTE: prestage_context IS registered (see register_prestage_tool below)
|
|
@@ -16,6 +16,7 @@ from typing import Any, Callable
|
|
|
16
16
|
|
|
17
17
|
from mcp.types import ToolAnnotations
|
|
18
18
|
|
|
19
|
+
from superlocalmemory.brain.truth import BrainTruthService
|
|
19
20
|
from superlocalmemory.core.admission import admits
|
|
20
21
|
from superlocalmemory.core.operation_request import OperationKind
|
|
21
22
|
from superlocalmemory.infra.data_root import state_path
|
|
@@ -26,13 +27,11 @@ from superlocalmemory.storage.agent_experience import (
|
|
|
26
27
|
CognitiveTurnTransitionError,
|
|
27
28
|
LearningWriteBusyError,
|
|
28
29
|
ProfileAdmissionError,
|
|
29
|
-
get_profile_receipt_summary,
|
|
30
30
|
)
|
|
31
31
|
from superlocalmemory.storage.external_evidence import (
|
|
32
32
|
ExternalEvidenceConflictError,
|
|
33
33
|
ExternalEvidenceStore,
|
|
34
34
|
ExternalEvidenceValidationError,
|
|
35
|
-
get_profile_external_evidence_summary,
|
|
36
35
|
)
|
|
37
36
|
|
|
38
37
|
|
|
@@ -52,6 +51,48 @@ def _external_store_for(engine: Any) -> ExternalEvidenceStore:
|
|
|
52
51
|
)
|
|
53
52
|
|
|
54
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
|
+
|
|
55
96
|
def _require_active_profile(engine: Any, payload: dict[str, Any]) -> str | None:
|
|
56
97
|
supplied = payload.get("profile_id")
|
|
57
98
|
if supplied != engine.profile_id:
|
|
@@ -90,18 +131,21 @@ def register_brain_tools(server: Any, get_engine: Callable[[], Any]) -> None:
|
|
|
90
131
|
|
|
91
132
|
@server.tool(annotations=ToolAnnotations(readOnlyHint=True))
|
|
92
133
|
async def get_brain_evidence_status() -> dict[str, Any]:
|
|
93
|
-
"""Get profile-scoped, observation-only Brain evidence totals.
|
|
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
|
+
"""
|
|
94
139
|
engine = get_engine()
|
|
140
|
+
truth = _brain_truth_for(engine)
|
|
95
141
|
return {
|
|
96
142
|
"success": True,
|
|
97
143
|
"profile_id": engine.profile_id,
|
|
98
|
-
"
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
"external_graph_evidence":
|
|
102
|
-
|
|
103
|
-
),
|
|
104
|
-
"control_plane": "observation_only",
|
|
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"],
|
|
105
149
|
}
|
|
106
150
|
|
|
107
151
|
@server.tool()
|
|
@@ -204,8 +204,12 @@ def register_code_graph_tools(server, get_engine: Callable) -> None:
|
|
|
204
204
|
if fp in file_groups:
|
|
205
205
|
file_groups[fp][1].append(e)
|
|
206
206
|
|
|
207
|
-
|
|
208
|
-
|
|
207
|
+
# Two-phase commit: all nodes first, then edges.
|
|
208
|
+
# This makes storage order-independent so cross-file CALLS edges
|
|
209
|
+
# (e.g., a.py calls bar() defined in b.py) are never silently
|
|
210
|
+
# dropped because of the file iteration order.
|
|
211
|
+
batch = [(fp, ns, es, fr) for fp, (ns, es, fr) in file_groups.items()]
|
|
212
|
+
store.commit_build_batch(batch)
|
|
209
213
|
|
|
210
214
|
# Build in-memory graph
|
|
211
215
|
engine = GraphEngine(store)
|
|
@@ -357,11 +361,14 @@ def register_code_graph_tools(server, get_engine: Callable) -> None:
|
|
|
357
361
|
continue
|
|
358
362
|
try:
|
|
359
363
|
source = full.read_bytes()
|
|
360
|
-
file_nodes, file_edges = parser.parse_file(
|
|
364
|
+
file_nodes, file_edges, file_import_map = parser.parse_file(
|
|
361
365
|
Path(fp), source, lang
|
|
362
366
|
)
|
|
363
367
|
import hashlib
|
|
364
368
|
from superlocalmemory.code_graph.models import FileRecord
|
|
369
|
+
from superlocalmemory.code_graph.parser import (
|
|
370
|
+
_clean_and_resolve_edges,
|
|
371
|
+
)
|
|
365
372
|
fr = FileRecord(
|
|
366
373
|
file_path=fp,
|
|
367
374
|
content_hash=hashlib.sha256(source).hexdigest(),
|
|
@@ -371,7 +378,27 @@ def register_code_graph_tools(server, get_engine: Callable) -> None:
|
|
|
371
378
|
edge_count=len(file_edges),
|
|
372
379
|
last_indexed=time.time(),
|
|
373
380
|
)
|
|
374
|
-
|
|
381
|
+
# Wire the resolver for the incremental path.
|
|
382
|
+
# parse_all has _clean_and_resolve_edges built in, but
|
|
383
|
+
# update_code_graph goes through parse_file which emits raw
|
|
384
|
+
# placeholder targets (__call__<name>). Without resolution,
|
|
385
|
+
# Fix B (defensive filter) drops ALL CALLS edges — silent
|
|
386
|
+
# data loss on every incremental update.
|
|
387
|
+
#
|
|
388
|
+
# Load the full DB node set as the resolution universe so
|
|
389
|
+
# Strategy 3 (global heuristic) can match cross-file calls.
|
|
390
|
+
db_nodes, _ = store.get_all_nodes_and_edges()
|
|
391
|
+
resolution_universe = list(file_nodes) + [
|
|
392
|
+
n for n in db_nodes if n.file_path != fp
|
|
393
|
+
]
|
|
394
|
+
resolved_edges = _clean_and_resolve_edges(
|
|
395
|
+
resolution_universe,
|
|
396
|
+
list(file_edges),
|
|
397
|
+
{fp: file_import_map},
|
|
398
|
+
repo,
|
|
399
|
+
config,
|
|
400
|
+
)
|
|
401
|
+
store.store_file_nodes_edges(fp, file_nodes, resolved_edges, fr)
|
|
375
402
|
except Exception as exc:
|
|
376
403
|
logger.warning("Failed to update %s: %s", fp, exc)
|
|
377
404
|
|
|
@@ -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."""
|
|
@@ -55,9 +55,12 @@ def register_v3_tools(server, get_engine: Callable) -> None:
|
|
|
55
55
|
async def set_mode(mode: str) -> dict:
|
|
56
56
|
"""Switch operating mode (a, b, or c).
|
|
57
57
|
|
|
58
|
-
Mode A
|
|
59
|
-
|
|
60
|
-
Mode
|
|
58
|
+
Mode A (Local Guardian): Nothing leaves this device. No AI language
|
|
59
|
+
model runs. Fastest and most private.
|
|
60
|
+
Mode B: All data stays on this device. Uses a local Ollama AI model
|
|
61
|
+
to improve recall quality. Requires Ollama installed.
|
|
62
|
+
Mode C: Uses a cloud AI provider (OpenAI, Anthropic, …) for best
|
|
63
|
+
recall quality. Queries leave this device; API key required.
|
|
61
64
|
|
|
62
65
|
Resets the engine to apply the new mode configuration.
|
|
63
66
|
|
|
@@ -377,8 +380,19 @@ def register_v3_tools(server, get_engine: Callable) -> None:
|
|
|
377
380
|
def _mode_description(mode: str) -> str:
|
|
378
381
|
"""Human-readable capability description for a mode (never a legal claim)."""
|
|
379
382
|
descriptions = {
|
|
380
|
-
"a":
|
|
381
|
-
|
|
382
|
-
|
|
383
|
+
"a": (
|
|
384
|
+
"Local Guardian — on-device only: no AI language model runs and "
|
|
385
|
+
"nothing leaves this device. Fastest and most private."
|
|
386
|
+
),
|
|
387
|
+
"b": (
|
|
388
|
+
"Smart Local — on-device plus a local Ollama model: better recall "
|
|
389
|
+
"quality, and nothing leaves this device. Requires Ollama to be "
|
|
390
|
+
"installed and running."
|
|
391
|
+
),
|
|
392
|
+
"c": (
|
|
393
|
+
"Full Power — uses a cloud AI provider (OpenAI, Anthropic, …) for "
|
|
394
|
+
"the best recall quality. Your queries leave this device and an "
|
|
395
|
+
"API key is required."
|
|
396
|
+
),
|
|
383
397
|
}
|
|
384
398
|
return descriptions.get(mode, "Unknown mode")
|
|
@@ -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:
|
|
@@ -1069,6 +1066,27 @@ class RetrievalEngine:
|
|
|
1069
1066
|
if not applied:
|
|
1070
1067
|
return fused, False, status
|
|
1071
1068
|
|
|
1069
|
+
# The worker can report applied=True while returning scores=null — the
|
|
1070
|
+
# subprocess answers, so the call "succeeded", but there is nothing to
|
|
1071
|
+
# score with. Iterating None here raised TypeError from OUTSIDE the
|
|
1072
|
+
# try/except above (which only wraps the rerank call itself), so the
|
|
1073
|
+
# error escaped into the recall path rather than degrading to the fused
|
|
1074
|
+
# ordering. Fail soft: reranking is a quality improvement on top of a
|
|
1075
|
+
# correct result set, never a correctness requirement.
|
|
1076
|
+
# `not scored` covers None AND an empty sequence. An empty list is the
|
|
1077
|
+
# same defect wearing different clothes: the worker says applied=True but
|
|
1078
|
+
# supplied nothing to rank with. Guarding only None would let [] through
|
|
1079
|
+
# to build an empty score_map, and every candidate would then be scored
|
|
1080
|
+
# against a degenerate min/max — silently shrinking the fused component
|
|
1081
|
+
# by (1 - alpha) while still reporting the rerank as applied.
|
|
1082
|
+
if not scored:
|
|
1083
|
+
logger.warning(
|
|
1084
|
+
"Cross-encoder worker reported applied=True with %s scores; "
|
|
1085
|
+
"falling back to fused ranking for this query.",
|
|
1086
|
+
"null" if scored is None else "empty",
|
|
1087
|
+
)
|
|
1088
|
+
return fused, False, "worker_null_scores"
|
|
1089
|
+
|
|
1072
1090
|
score_map = {fact.fact_id: score for fact, score in scored}
|
|
1073
1091
|
|
|
1074
1092
|
# Min-max normalize CE scores to [0, 1] within the batch instead of
|