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.
Files changed (90) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +23 -14
  3. package/ide/configs/codex-mcp.toml +2 -2
  4. package/package.json +3 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.mcp.json +1 -0
  7. package/plugin/CLAUDE.md +3 -3
  8. package/plugin/agents/slm-governance-advisor.md +1 -1
  9. package/plugin/agents/slm-loop-runner.md +1 -1
  10. package/plugin/agents/slm-memory-advisor.md +1 -1
  11. package/plugin/agents/slm-optimize-advisor.md +1 -1
  12. package/plugin/requirements.txt +1 -1
  13. package/plugin/skills/slm-cache/SKILL.md +1 -1
  14. package/plugin/skills/slm-compress/SKILL.md +1 -1
  15. package/plugin/skills/slm-governance/SKILL.md +1 -1
  16. package/plugin/skills/slm-graph/SKILL.md +3 -2
  17. package/plugin/skills/slm-loop/SKILL.md +1 -1
  18. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  19. package/plugin/skills/slm-profile/SKILL.md +2 -1
  20. package/plugin/skills/slm-recall/SKILL.md +1 -1
  21. package/plugin/skills/slm-remember/SKILL.md +1 -1
  22. package/plugin/skills/slm-scope/SKILL.md +1 -1
  23. package/plugin/skills/slm-session/SKILL.md +1 -1
  24. package/plugin/skills/slm-status/SKILL.md +1 -1
  25. package/plugin-src/rules/AGENTS.md +6 -5
  26. package/plugin-src/skills/slm-graph/SKILL.md +2 -1
  27. package/plugin-src/skills/slm-profile/SKILL.md +1 -0
  28. package/pyproject.toml +1 -1
  29. package/src/superlocalmemory/__init__.py +1 -1
  30. package/src/superlocalmemory/access/rbac.py +106 -0
  31. package/src/superlocalmemory/brain/__init__.py +5 -0
  32. package/src/superlocalmemory/brain/truth.py +418 -0
  33. package/src/superlocalmemory/cli/__main__.py +17 -0
  34. package/src/superlocalmemory/cli/commands.py +96 -28
  35. package/src/superlocalmemory/cli/gdpr_cmd.py +779 -0
  36. package/src/superlocalmemory/cli/gdpr_io.py +109 -0
  37. package/src/superlocalmemory/cli/main.py +88 -0
  38. package/src/superlocalmemory/code_graph/extractors/__init__.py +17 -0
  39. package/src/superlocalmemory/code_graph/graph_store.py +180 -3
  40. package/src/superlocalmemory/code_graph/parser.py +280 -100
  41. package/src/superlocalmemory/compliance/gdpr.py +358 -0
  42. package/src/superlocalmemory/core/config.py +44 -1
  43. package/src/superlocalmemory/core/context_cache.py +58 -1
  44. package/src/superlocalmemory/core/engine_wiring.py +5 -1
  45. package/src/superlocalmemory/core/maintenance.py +43 -1
  46. package/src/superlocalmemory/core/mutations.py +155 -25
  47. package/src/superlocalmemory/core/recall_pipeline.py +6 -10
  48. package/src/superlocalmemory/core/recall_worker.py +33 -12
  49. package/src/superlocalmemory/core/remember_runtime.py +271 -2
  50. package/src/superlocalmemory/core/store_pipeline.py +100 -38
  51. package/src/superlocalmemory/encoding/consolidator.py +17 -47
  52. package/src/superlocalmemory/encoding/temporal_validator.py +14 -18
  53. package/src/superlocalmemory/hooks/user_prompt_hook.py +1 -1
  54. package/src/superlocalmemory/infra/backup.py +138 -0
  55. package/src/superlocalmemory/infra/backup_obligations.py +423 -0
  56. package/src/superlocalmemory/integrations/bounded_loops_mcp.py +4 -3
  57. package/src/superlocalmemory/learning/engagement.py +165 -0
  58. package/src/superlocalmemory/mcp/profiles.py +19 -7
  59. package/src/superlocalmemory/mcp/server.py +4 -2
  60. package/src/superlocalmemory/mcp/tools_brain.py +54 -10
  61. package/src/superlocalmemory/mcp/tools_code_graph.py +31 -4
  62. package/src/superlocalmemory/mcp/tools_core.py +88 -3
  63. package/src/superlocalmemory/mcp/tools_v3.py +20 -6
  64. package/src/superlocalmemory/retrieval/engine.py +28 -10
  65. package/src/superlocalmemory/retrieval/remote_reranker.py +108 -11
  66. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +119 -19
  67. package/src/superlocalmemory/server/routes/brain.py +297 -14
  68. package/src/superlocalmemory/server/routes/learning.py +13 -25
  69. package/src/superlocalmemory/server/routes/memories.py +129 -3
  70. package/src/superlocalmemory/server/routes/v3_api.py +171 -60
  71. package/src/superlocalmemory/storage/_migration_internals.py +4 -0
  72. package/src/superlocalmemory/storage/_schema_version.py +2 -2
  73. package/src/superlocalmemory/storage/correction_cases.py +670 -0
  74. package/src/superlocalmemory/storage/database.py +230 -24
  75. package/src/superlocalmemory/storage/migration_runner.py +7 -0
  76. package/src/superlocalmemory/storage/migrations/M042_correction_case_ledger.py +245 -0
  77. package/src/superlocalmemory/storage/migrations/__init__.py +2 -0
  78. package/src/superlocalmemory/storage/models.py +12 -4
  79. package/src/superlocalmemory/storage/write_coordinator.py +4 -0
  80. package/src/superlocalmemory/summaries/__init__.py +37 -0
  81. package/src/superlocalmemory/summaries/base.py +108 -0
  82. package/src/superlocalmemory/summaries/daily_reflection.py +293 -0
  83. package/src/superlocalmemory/summaries/project_work_log.py +424 -0
  84. package/src/superlocalmemory/summaries/session_summary.py +307 -0
  85. package/src/superlocalmemory/ui/css/design-system.css +76 -1
  86. package/src/superlocalmemory/ui/index.html +28 -11
  87. package/src/superlocalmemory/ui/js/brain.js +43 -7
  88. package/src/superlocalmemory/ui/js/od-agents.js +49 -5
  89. package/src/superlocalmemory/ui/js/od-brain.js +280 -84
  90. 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({ # 14
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({ # 29
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
- _PROFILE_FULL: frozenset[str] = frozenset({ # 39 base — EXPLICIT literal, NOT runtime _ESSENTIAL_TOOLS (OQ-2)
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 # 47
68
+ }) | _PROFILE_FULL_MESH # 49
65
69
 
66
- _PROFILE_POWER: frozenset[str] = _PROFILE_FULL | frozenset({ # 59
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": "Core + code graph, portable Brain evidence, and profile switching (default for IDE coding agents)",
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: 39 base tools + 8 mesh tools = 47 registered.
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 92 tools (power users).
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
- "agent_experience": get_profile_receipt_summary(
99
- state_path("learning.db"), engine.profile_id
100
- ),
101
- "external_graph_evidence": get_profile_external_evidence_summary(
102
- state_path("learning.db"), engine.profile_id
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
- for fp, (ns, es, fr) in file_groups.items():
208
- store.store_file_nodes_edges(fp, ns, es, fr)
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
- store.store_file_nodes_edges(fp, file_nodes, file_edges, fr)
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, "fact_id": fact_id,
932
- "content": content.strip(),
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 {"success": True, "fact_id": fact_id, "content": content.strip()}
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: Local Guardian (zero LLM, local embeddings only).
59
- Mode B: Smart Local (local Ollama LLM, on-device inference).
60
- Mode C: Full Power (configured cloud LLM provider, best accuracy).
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": "Local Guardian: zero LLM, local embeddings only",
381
- "b": "Smart Local: local Ollama LLM, on-device inference",
382
- "c": "Full Power: configured cloud LLM provider, best accuracy",
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