superlocalmemory 3.8.0 → 3.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/CHANGELOG.md +112 -0
  2. package/README.md +32 -120
  3. package/package.json +9 -2
  4. package/plugin/.claude-plugin/plugin.json +1 -2
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +2 -2
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +3 -5
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +2 -1
  24. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  25. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-recall/SKILL.md +3 -5
  28. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  31. package/pyproject.toml +2 -1
  32. package/scripts/postinstall.js +7 -1
  33. package/src/superlocalmemory/__init__.py +1 -1
  34. package/src/superlocalmemory/cli/commands.py +494 -9
  35. package/src/superlocalmemory/cli/daemon.py +7 -0
  36. package/src/superlocalmemory/cli/loop_cmd.py +2 -7
  37. package/src/superlocalmemory/cli/main.py +72 -7
  38. package/src/superlocalmemory/cli/setup_wizard.py +142 -16
  39. package/src/superlocalmemory/cli/version_banner.py +17 -3
  40. package/src/superlocalmemory/core/backend_orchestrator.py +18 -16
  41. package/src/superlocalmemory/core/component_healer.py +144 -0
  42. package/src/superlocalmemory/core/component_registry.py +487 -0
  43. package/src/superlocalmemory/core/config.py +21 -0
  44. package/src/superlocalmemory/core/embedding_worker.py +4 -5
  45. package/src/superlocalmemory/core/embeddings.py +132 -45
  46. package/src/superlocalmemory/core/engine.py +29 -22
  47. package/src/superlocalmemory/core/engine_ingestion.py +332 -45
  48. package/src/superlocalmemory/core/ingestion_command.py +154 -25
  49. package/src/superlocalmemory/core/injection.py +12 -7
  50. package/src/superlocalmemory/core/maintenance.py +43 -0
  51. package/src/superlocalmemory/core/maintenance_scheduler.py +44 -6
  52. package/src/superlocalmemory/core/recall_pipeline.py +42 -4
  53. package/src/superlocalmemory/core/store_pipeline.py +195 -20
  54. package/src/superlocalmemory/hooks/hook_handlers.py +6 -1
  55. package/src/superlocalmemory/hooks/portable_kit.py +34 -2
  56. package/src/superlocalmemory/learning/model_rollback.py +3 -0
  57. package/src/superlocalmemory/learning/ranker_retrain_online.py +2 -0
  58. package/src/superlocalmemory/learning/reward.py +50 -0
  59. package/src/superlocalmemory/learning/source_quality.py +523 -1
  60. package/src/superlocalmemory/loops/ledger.py +25 -5
  61. package/src/superlocalmemory/mcp/_daemon_proxy.py +6 -2
  62. package/src/superlocalmemory/mcp/_pool_adapter.py +4 -1
  63. package/src/superlocalmemory/mcp/server.py +11 -30
  64. package/src/superlocalmemory/mcp/tools_active.py +1 -1
  65. package/src/superlocalmemory/mcp/tools_core.py +21 -5
  66. package/src/superlocalmemory/mcp/tools_learning.py +2 -2
  67. package/src/superlocalmemory/retrieval/bridge_discovery.py +14 -0
  68. package/src/superlocalmemory/retrieval/engine.py +53 -21
  69. package/src/superlocalmemory/retrieval/reranker.py +3 -4
  70. package/src/superlocalmemory/retrieval/spreading_activation.py +68 -38
  71. package/src/superlocalmemory/server/config_file.py +90 -0
  72. package/src/superlocalmemory/server/origin.py +50 -0
  73. package/src/superlocalmemory/server/routes/backup.py +293 -70
  74. package/src/superlocalmemory/server/routes/behavioral.py +342 -61
  75. package/src/superlocalmemory/server/routes/brain.py +57 -16
  76. package/src/superlocalmemory/server/routes/config_api.py +84 -82
  77. package/src/superlocalmemory/server/routes/entity.py +100 -23
  78. package/src/superlocalmemory/server/routes/evolution.py +103 -100
  79. package/src/superlocalmemory/server/routes/learning.py +286 -105
  80. package/src/superlocalmemory/server/routes/learning_telemetry.py +153 -0
  81. package/src/superlocalmemory/server/routes/memories.py +8 -3
  82. package/src/superlocalmemory/server/routes/mesh.py +121 -32
  83. package/src/superlocalmemory/server/routes/ratelimit.py +33 -25
  84. package/src/superlocalmemory/server/routes/stats.py +93 -155
  85. package/src/superlocalmemory/server/routes/token.py +3 -13
  86. package/src/superlocalmemory/server/routes/v3_api.py +184 -20
  87. package/src/superlocalmemory/server/unified_daemon.py +732 -41
  88. package/src/superlocalmemory/storage/embedding_migrator.py +235 -0
  89. package/src/superlocalmemory/storage/migration_runner.py +79 -1
  90. package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +5 -0
  91. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +270 -0
  92. package/src/superlocalmemory/storage/migrations/M029_behavioral_history_indexes.py +137 -0
  93. package/src/superlocalmemory/storage/migrations/M030_entity_explorer_indexes.py +93 -0
  94. package/src/superlocalmemory/storage/migrations/__init__.py +4 -0
  95. package/src/superlocalmemory/storage/schema.py +49 -1
  96. package/src/superlocalmemory/storage/schema_v32.py +2 -0
  97. package/src/superlocalmemory/storage/schema_v347.py +4 -0
  98. package/src/superlocalmemory/ui/index.html +6 -8
  99. package/src/superlocalmemory/ui/js/core.js +52 -9
  100. package/src/superlocalmemory/ui/js/dashboard.js +169 -82
  101. package/src/superlocalmemory/ui/js/od-backup.js +156 -65
  102. package/src/superlocalmemory/ui/js/od-brain.js +88 -51
  103. package/src/superlocalmemory/ui/js/od-components.js +147 -0
  104. package/src/superlocalmemory/ui/js/od-entities.js +65 -22
  105. package/src/superlocalmemory/ui/js/od-graph.js +46 -4
  106. package/src/superlocalmemory/ui/js/od-health.js +18 -0
  107. package/src/superlocalmemory/ui/js/od-memories.js +84 -5
  108. package/src/superlocalmemory/ui/js/od-mesh.js +23 -9
  109. package/src/superlocalmemory/ui/js/od-operations.js +36 -0
  110. package/src/superlocalmemory/ui/js/od-settings.js +186 -63
  111. package/src/superlocalmemory/ui/js/od-shell.js +249 -33
  112. package/src/superlocalmemory/ui/js/od-skills.js +44 -17
  113. package/src/superlocalmemory/ui/js/settings.js +15 -1
  114. package/plugin-src/.mcp.json +0 -12
  115. package/plugin-src/agents/slm-governance-advisor.md +0 -80
  116. package/plugin-src/agents/slm-loop-runner.md +0 -71
  117. package/plugin-src/agents/slm-memory-advisor.md +0 -49
  118. package/plugin-src/agents/slm-optimize-advisor.md +0 -44
  119. package/plugin-src/commands/slm-loop.md +0 -31
  120. package/plugin-src/hooks/.gitkeep +0 -0
  121. package/plugin-src/hooks/hooks.json +0 -102
  122. package/plugin-src/manifest.json +0 -30
  123. package/plugin-src/requirements.txt +0 -1
  124. package/plugin-src/rules/CLAUDE.md.fragment +0 -44
  125. package/plugin-src/scripts/ensure-venv.bat +0 -122
  126. package/plugin-src/scripts/ensure-venv.sh +0 -105
  127. package/plugin-src/scripts/slm-launch +0 -62
  128. package/plugin-src/scripts/slm-launch.bat +0 -23
  129. package/plugin-src/settings.json +0 -25
  130. package/plugin-src/skills/slm-governance/SKILL.md +0 -248
  131. package/plugin-src/skills/slm-loop/SKILL.md +0 -99
  132. package/plugin-src/skills/slm-mesh/SKILL.md +0 -282
  133. package/plugin-src/skills/slm-profile/SKILL.md +0 -148
  134. package/plugin-src/skills/slm-scope/SKILL.md +0 -176
@@ -903,13 +903,47 @@ def _action_outcomes_count(lrn_db: LearningDatabase,
903
903
  return 0
904
904
 
905
905
 
906
+ def _compute_action_outcomes_preview(profile_id: str) -> dict:
907
+ """Count profile-scoped action outcomes from their canonical database."""
908
+ empty = {
909
+ "action_outcomes_rows": 0,
910
+ "source": "memory.db:action_outcomes",
911
+ "is_real": True,
912
+ }
913
+ db_path = _memory_db_path()
914
+ if not db_path.exists():
915
+ return empty
916
+ try:
917
+ conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=1.0)
918
+ try:
919
+ row = conn.execute(
920
+ "SELECT COUNT(*) FROM action_outcomes WHERE profile_id = ?",
921
+ (profile_id,),
922
+ ).fetchone()
923
+ finally:
924
+ conn.close()
925
+ except sqlite3.Error:
926
+ return empty
927
+ return {**empty, "action_outcomes_rows": int(row[0] or 0) if row else 0}
928
+
929
+
906
930
  # ---------------------------------------------------------------------------
907
931
  # Routes
908
932
  # ---------------------------------------------------------------------------
909
933
 
910
934
 
935
+ def _authorized_profile(request: Request, profile_id: str | None) -> str:
936
+ """Resolve and authorize the exact Brain profile requested by the caller."""
937
+ from superlocalmemory.access.rbac import Permission
938
+ from superlocalmemory.server.rbac_enforce import require_permission
939
+
940
+ effective_profile = profile_id or get_active_profile()
941
+ require_permission(request, Permission.READ, profile=effective_profile)
942
+ return effective_profile
943
+
944
+
911
945
  @router.get("/brain", dependencies=[Depends(require_install_token)])
912
- async def get_brain(profile_id: str | None = None) -> dict:
946
+ async def get_brain(request: Request, profile_id: str | None = None) -> dict:
913
947
  """Unified Brain endpoint — LLD-04 §3.1.
914
948
 
915
949
  Fan-out: each section is a synchronous SQLite reader. Running them
@@ -926,12 +960,12 @@ async def get_brain(profile_id: str | None = None) -> dict:
926
960
 
927
961
  # Default to the ACTIVE profile (request runtime truth), never literal
928
962
  # "default" — the Brain must reflect whichever profile is active.
929
- profile_id = profile_id or get_active_profile()
963
+ profile_id = _authorized_profile(request, profile_id)
930
964
  lrn_db = LearningDatabase(_learning_db_path())
931
965
 
932
966
  (
933
967
  preferences, learning, usage, bandit_snap, cache,
934
- cross_platform, outcomes_rows, evolution,
968
+ cross_platform, outcomes_preview, evolution,
935
969
  ) = await asyncio.gather(
936
970
  asyncio.to_thread(_compute_preferences, profile_id),
937
971
  asyncio.to_thread(_compute_learning_status, profile_id, lrn_db),
@@ -939,7 +973,7 @@ async def get_brain(profile_id: str | None = None) -> dict:
939
973
  asyncio.to_thread(_compute_bandit_snapshot, profile_id, lrn_db),
940
974
  asyncio.to_thread(_compute_cache_stats),
941
975
  asyncio.to_thread(_compute_cross_platform),
942
- asyncio.to_thread(_action_outcomes_count, lrn_db, profile_id),
976
+ asyncio.to_thread(_compute_action_outcomes_preview, profile_id),
943
977
  asyncio.to_thread(
944
978
  _compute_evolution_timeseries, profile_id, lrn_db,
945
979
  days=_EVOLUTION_DEFAULT_DAYS,
@@ -977,11 +1011,11 @@ async def get_brain(profile_id: str | None = None) -> dict:
977
1011
  "is_real": True, "source": "learning_signals",
978
1012
  "days": _EVOLUTION_DEFAULT_DAYS, "total_signals": 0, "points": [],
979
1013
  }),
980
- "outcomes_preview": {
981
- "action_outcomes_rows":
982
- 0 if isinstance(outcomes_rows, Exception) else outcomes_rows,
983
- "ships_in": "3.4.22",
984
- },
1014
+ "outcomes_preview": _ok(outcomes_preview, {
1015
+ "action_outcomes_rows": 0,
1016
+ "source": "memory.db:action_outcomes",
1017
+ "is_real": True,
1018
+ }),
985
1019
  # S9-defer H-22: live tile data for the Reward / Shadow /
986
1020
  # Evolution-Cost dashboard tiles. Each block is a honest-empty
987
1021
  # default when the underlying table is missing (fresh install
@@ -1188,6 +1222,7 @@ def _compute_evolution_cost_preview(profile_id: str) -> dict:
1188
1222
  @router.get("/brain/evolution-timeseries",
1189
1223
  dependencies=[Depends(require_install_token)])
1190
1224
  async def get_brain_evolution_timeseries(
1225
+ request: Request,
1191
1226
  profile_id: str | None = None,
1192
1227
  days: int = _EVOLUTION_DEFAULT_DAYS,
1193
1228
  ) -> dict:
@@ -1198,7 +1233,7 @@ async def get_brain_evolution_timeseries(
1198
1233
  """
1199
1234
  import asyncio
1200
1235
 
1201
- profile_id = profile_id or get_active_profile()
1236
+ profile_id = _authorized_profile(request, profile_id)
1202
1237
  lrn_db = LearningDatabase(_learning_db_path())
1203
1238
  result = await asyncio.to_thread(
1204
1239
  _compute_evolution_timeseries, profile_id, lrn_db, days=days,
@@ -1214,8 +1249,10 @@ async def get_brain_evolution_timeseries(
1214
1249
 
1215
1250
  @router.get("/learning/stats",
1216
1251
  dependencies=[Depends(require_install_token)])
1217
- async def learning_stats_deprecated(profile_id: str | None = None) -> dict:
1218
- profile_id = profile_id or get_active_profile()
1252
+ async def learning_stats_deprecated(
1253
+ request: Request, profile_id: str | None = None,
1254
+ ) -> dict:
1255
+ profile_id = _authorized_profile(request, profile_id)
1219
1256
  lrn_db = LearningDatabase(_learning_db_path())
1220
1257
  return {
1221
1258
  "deprecated": True,
@@ -1226,8 +1263,10 @@ async def learning_stats_deprecated(profile_id: str | None = None) -> dict:
1226
1263
 
1227
1264
  @router.get("/patterns",
1228
1265
  dependencies=[Depends(require_install_token)])
1229
- async def patterns_deprecated(profile_id: str | None = None) -> dict:
1230
- profile_id = profile_id or get_active_profile()
1266
+ async def patterns_deprecated(
1267
+ request: Request, profile_id: str | None = None,
1268
+ ) -> dict:
1269
+ profile_id = _authorized_profile(request, profile_id)
1231
1270
  return {
1232
1271
  "deprecated": True,
1233
1272
  "use_instead": "/api/v3/brain",
@@ -1237,8 +1276,10 @@ async def patterns_deprecated(profile_id: str | None = None) -> dict:
1237
1276
 
1238
1277
  @router.get("/behavioral",
1239
1278
  dependencies=[Depends(require_install_token)])
1240
- async def behavioral_deprecated(profile_id: str | None = None) -> dict:
1241
- profile_id = profile_id or get_active_profile()
1279
+ async def behavioral_deprecated(
1280
+ request: Request, profile_id: str | None = None,
1281
+ ) -> dict:
1282
+ profile_id = _authorized_profile(request, profile_id)
1242
1283
  return {
1243
1284
  "deprecated": True,
1244
1285
  "use_instead": "/api/v3/brain",
@@ -19,21 +19,21 @@ MEMORY_DIR to a tmp_path.
19
19
  Restart-required semantics:
20
20
  - graph_backend / vector_backend: changes take effect only on daemon restart.
21
21
  - daemon_port / daemon_legacy_port: changes take effect only on daemon restart.
22
- - All other fields: hot-applied on next engine reconfigure cycle.
22
+ - mesh, trust, and forgetting are persisted safely but require restart
23
+ because no complete supported worker-rebind transaction exists for them.
23
24
  """
24
25
 
25
26
  from __future__ import annotations
26
27
 
27
- import json
28
28
  import logging
29
- import os
30
29
  from pathlib import Path
31
- from typing import Annotated, Literal, Optional
30
+ from typing import Annotated, Optional
32
31
 
33
32
  from fastapi import APIRouter, Request
34
33
  from fastapi.responses import JSONResponse
35
34
  from pydantic import BaseModel, ConfigDict, Field, StrictBool
36
35
 
36
+ from superlocalmemory.server.config_file import read_config, update_config
37
37
  from superlocalmemory.server.routes.helpers import MEMORY_DIR
38
38
 
39
39
  logger = logging.getLogger(__name__)
@@ -73,24 +73,18 @@ def _config_path() -> Path:
73
73
 
74
74
 
75
75
  def _read_config() -> dict:
76
- """Read config.json as a raw dict. Returns {} on missing/corrupt file."""
76
+ """Read one coherent config snapshot."""
77
77
  p = _config_path()
78
- if not p.exists():
79
- return {}
80
78
  try:
81
- return json.loads(p.read_text())
82
- except (json.JSONDecodeError, OSError) as exc:
79
+ return read_config(p)
80
+ except (ValueError, OSError) as exc:
83
81
  logger.warning("config_api: could not read config.json: %s", exc)
84
- return {}
82
+ raise
85
83
 
86
84
 
87
- def _atomic_write(data: dict) -> None:
88
- """Atomic config.json write: .tmp → os.replace."""
89
- p = _config_path()
90
- p.parent.mkdir(parents=True, exist_ok=True)
91
- tmp = p.with_suffix(".json.tmp")
92
- tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
93
- os.replace(tmp, p)
85
+ def _update_config(mutator) -> dict:
86
+ """Run one interprocess-locked read/modify/replace transaction."""
87
+ return update_config(_config_path(), mutator)
94
88
 
95
89
 
96
90
  # ---------------------------------------------------------------------------
@@ -175,7 +169,7 @@ _FORGETTING_DEFAULTS: dict = {
175
169
 
176
170
 
177
171
  @router.get("/storage/config")
178
- async def get_storage_config():
172
+ def get_storage_config():
179
173
  """Return current storage backend configuration.
180
174
 
181
175
  base_dir is read-only — it is derived from the process namespace and
@@ -188,7 +182,7 @@ async def get_storage_config():
188
182
  "vector_backend": data.get("vector_backend", "auto"),
189
183
  "base_dir": data.get("base_dir", str(MEMORY_DIR)),
190
184
  }
191
- except Exception as exc:
185
+ except Exception:
192
186
  logger.exception("get_storage_config failed")
193
187
  return JSONResponse({"error": "Internal server error"}, status_code=500)
194
188
 
@@ -199,7 +193,7 @@ async def get_storage_config():
199
193
 
200
194
 
201
195
  @router.put("/storage/config")
202
- async def put_storage_config(request: Request, body: StorageConfigUpdate):
196
+ def put_storage_config(request: Request, body: StorageConfigUpdate):
203
197
  """Update graph_backend and/or vector_backend.
204
198
 
205
199
  Both fields require a daemon restart to take effect.
@@ -207,19 +201,20 @@ async def put_storage_config(request: Request, body: StorageConfigUpdate):
207
201
  """
208
202
  _require_admin(request)
209
203
  try:
210
- data = _read_config()
211
- if body.graph_backend is not None:
212
- data["graph_backend"] = body.graph_backend
213
- if body.vector_backend is not None:
214
- data["vector_backend"] = body.vector_backend
215
- _atomic_write(data)
204
+ def mutate(data: dict) -> None:
205
+ if body.graph_backend is not None:
206
+ data["graph_backend"] = body.graph_backend
207
+ if body.vector_backend is not None:
208
+ data["vector_backend"] = body.vector_backend
209
+
210
+ data = _update_config(mutate)
216
211
  return {
217
212
  "graph_backend": data.get("graph_backend", "auto"),
218
213
  "vector_backend": data.get("vector_backend", "auto"),
219
214
  "base_dir": data.get("base_dir", str(MEMORY_DIR)),
220
215
  "restart_required": True,
221
216
  }
222
- except Exception as exc:
217
+ except Exception:
223
218
  logger.exception("put_storage_config failed")
224
219
  return JSONResponse({"error": "Internal server error"}, status_code=500)
225
220
 
@@ -230,7 +225,7 @@ async def put_storage_config(request: Request, body: StorageConfigUpdate):
230
225
 
231
226
 
232
227
  @router.get("/daemon/config")
233
- async def get_daemon_config():
228
+ def get_daemon_config():
234
229
  """Return current daemon configuration."""
235
230
  try:
236
231
  data = _read_config()
@@ -240,7 +235,7 @@ async def get_daemon_config():
240
235
  "legacy_port": data.get("daemon_legacy_port", 8767),
241
236
  "enable_legacy_port": data.get("daemon_enable_legacy_port", True),
242
237
  }
243
- except Exception as exc:
238
+ except Exception:
244
239
  logger.exception("get_daemon_config failed")
245
240
  return JSONResponse({"error": "Internal server error"}, status_code=500)
246
241
 
@@ -251,7 +246,7 @@ async def get_daemon_config():
251
246
 
252
247
 
253
248
  @router.put("/daemon/config")
254
- async def put_daemon_config(request: Request, body: DaemonConfigUpdate):
249
+ def put_daemon_config(request: Request, body: DaemonConfigUpdate):
255
250
  """Update daemon configuration.
256
251
 
257
252
  Port / legacy_port changes require a daemon restart.
@@ -259,19 +254,22 @@ async def put_daemon_config(request: Request, body: DaemonConfigUpdate):
259
254
  """
260
255
  _require_admin(request)
261
256
  try:
262
- data = _read_config()
263
257
  port_changed = False
264
- if body.idle_timeout is not None:
265
- data["daemon_idle_timeout"] = body.idle_timeout
266
- if body.port is not None:
267
- data["daemon_port"] = body.port
268
- port_changed = True
269
- if body.legacy_port is not None:
270
- data["daemon_legacy_port"] = body.legacy_port
271
- port_changed = True
272
- if body.enable_legacy_port is not None:
273
- data["daemon_enable_legacy_port"] = body.enable_legacy_port
274
- _atomic_write(data)
258
+
259
+ def mutate(data: dict) -> None:
260
+ nonlocal port_changed
261
+ if body.idle_timeout is not None:
262
+ data["daemon_idle_timeout"] = body.idle_timeout
263
+ if body.port is not None:
264
+ data["daemon_port"] = body.port
265
+ port_changed = True
266
+ if body.legacy_port is not None:
267
+ data["daemon_legacy_port"] = body.legacy_port
268
+ port_changed = True
269
+ if body.enable_legacy_port is not None:
270
+ data["daemon_enable_legacy_port"] = body.enable_legacy_port
271
+
272
+ data = _update_config(mutate)
275
273
  return {
276
274
  "idle_timeout": data.get("daemon_idle_timeout", 0),
277
275
  "port": data.get("daemon_port", 8765),
@@ -279,7 +277,7 @@ async def put_daemon_config(request: Request, body: DaemonConfigUpdate):
279
277
  "enable_legacy_port": data.get("daemon_enable_legacy_port", True),
280
278
  "restart_required": port_changed,
281
279
  }
282
- except Exception as exc:
280
+ except Exception:
283
281
  logger.exception("put_daemon_config failed")
284
282
  return JSONResponse({"error": "Internal server error"}, status_code=500)
285
283
 
@@ -290,12 +288,12 @@ async def put_daemon_config(request: Request, body: DaemonConfigUpdate):
290
288
 
291
289
 
292
290
  @router.get("/mesh/config")
293
- async def get_mesh_config():
291
+ def get_mesh_config():
294
292
  """Return current mesh configuration."""
295
293
  try:
296
294
  data = _read_config()
297
295
  return {"enabled": data.get("mesh_enabled", True)}
298
- except Exception as exc:
296
+ except Exception:
299
297
  logger.exception("get_mesh_config failed")
300
298
  return JSONResponse({"error": "Internal server error"}, status_code=500)
301
299
 
@@ -306,15 +304,15 @@ async def get_mesh_config():
306
304
 
307
305
 
308
306
  @router.put("/mesh/config")
309
- async def put_mesh_config(request: Request, body: MeshConfigUpdate):
310
- """Enable or disable the mesh sync layer."""
307
+ def put_mesh_config(request: Request, body: MeshConfigUpdate):
308
+ """Persist mesh state; restart is required to rebuild the mesh worker."""
311
309
  _require_admin(request)
312
310
  try:
313
- data = _read_config()
314
- data["mesh_enabled"] = body.enabled
315
- _atomic_write(data)
316
- return {"enabled": body.enabled}
317
- except Exception as exc:
311
+ _update_config(
312
+ lambda data: data.update({"mesh_enabled": body.enabled}),
313
+ )
314
+ return {"enabled": body.enabled, "restart_required": True}
315
+ except Exception:
318
316
  logger.exception("put_mesh_config failed")
319
317
  return JSONResponse({"error": "Internal server error"}, status_code=500)
320
318
 
@@ -325,7 +323,7 @@ async def put_mesh_config(request: Request, body: MeshConfigUpdate):
325
323
 
326
324
 
327
325
  @router.get("/trust/config")
328
- async def get_trust_config():
326
+ def get_trust_config():
329
327
  """Return current trust configuration.
330
328
 
331
329
  Fields are spread across three config sections:
@@ -343,7 +341,7 @@ async def get_trust_config():
343
341
  "trust_first_party": injection.get("trust_first_party", False),
344
342
  "promotion_min_trust": consolidation.get("promotion_min_trust", 0.5),
345
343
  }
346
- except Exception as exc:
344
+ except Exception:
347
345
  logger.exception("get_trust_config failed")
348
346
  return JSONResponse({"error": "Internal server error"}, status_code=500)
349
347
 
@@ -354,7 +352,7 @@ async def get_trust_config():
354
352
 
355
353
 
356
354
  @router.put("/trust/config")
357
- async def put_trust_config(request: Request, body: TrustConfigUpdate):
355
+ def put_trust_config(request: Request, body: TrustConfigUpdate):
358
356
  """Update trust configuration.
359
357
 
360
358
  Each field is stored in its canonical config.json sub-section.
@@ -362,17 +360,18 @@ async def put_trust_config(request: Request, body: TrustConfigUpdate):
362
360
  """
363
361
  _require_admin(request)
364
362
  try:
365
- data = _read_config()
366
- if body.use_trust_weighting is not None:
367
- retrieval = data.setdefault("retrieval", {})
368
- retrieval["use_trust_weighting"] = body.use_trust_weighting
369
- if body.trust_first_party is not None:
370
- injection = data.setdefault("injection", {})
371
- injection["trust_first_party"] = body.trust_first_party
372
- if body.promotion_min_trust is not None:
373
- consolidation = data.setdefault("consolidation", {})
374
- consolidation["promotion_min_trust"] = body.promotion_min_trust
375
- _atomic_write(data)
363
+ def mutate(data: dict) -> None:
364
+ if body.use_trust_weighting is not None:
365
+ retrieval = data.setdefault("retrieval", {})
366
+ retrieval["use_trust_weighting"] = body.use_trust_weighting
367
+ if body.trust_first_party is not None:
368
+ injection = data.setdefault("injection", {})
369
+ injection["trust_first_party"] = body.trust_first_party
370
+ if body.promotion_min_trust is not None:
371
+ consolidation = data.setdefault("consolidation", {})
372
+ consolidation["promotion_min_trust"] = body.promotion_min_trust
373
+
374
+ data = _update_config(mutate)
376
375
  retrieval = data.get("retrieval", {})
377
376
  injection = data.get("injection", {})
378
377
  consolidation = data.get("consolidation", {})
@@ -380,8 +379,9 @@ async def put_trust_config(request: Request, body: TrustConfigUpdate):
380
379
  "use_trust_weighting": retrieval.get("use_trust_weighting", True),
381
380
  "trust_first_party": injection.get("trust_first_party", False),
382
381
  "promotion_min_trust": consolidation.get("promotion_min_trust", 0.5),
382
+ "restart_required": True,
383
383
  }
384
- except Exception as exc:
384
+ except Exception:
385
385
  logger.exception("put_trust_config failed")
386
386
  return JSONResponse({"error": "Internal server error"}, status_code=500)
387
387
 
@@ -392,7 +392,7 @@ async def put_trust_config(request: Request, body: TrustConfigUpdate):
392
392
 
393
393
 
394
394
  @router.get("/forgetting/config")
395
- async def get_forgetting_config():
395
+ def get_forgetting_config():
396
396
  """Return all Ebbinghaus forgetting configuration fields."""
397
397
  try:
398
398
  data = _read_config()
@@ -401,7 +401,7 @@ async def get_forgetting_config():
401
401
  result = {**_FORGETTING_DEFAULTS, **stored}
402
402
  # Keep only known fields
403
403
  return {k: result[k] for k in _FORGETTING_DEFAULTS}
404
- except Exception as exc:
404
+ except Exception:
405
405
  logger.exception("get_forgetting_config failed")
406
406
  return JSONResponse({"error": "Internal server error"}, status_code=500)
407
407
 
@@ -412,25 +412,27 @@ async def get_forgetting_config():
412
412
 
413
413
 
414
414
  @router.put("/forgetting/config")
415
- async def put_forgetting_config(request: Request, body: ForgettingConfigUpdate):
415
+ def put_forgetting_config(request: Request, body: ForgettingConfigUpdate):
416
416
  """Update Ebbinghaus forgetting configuration.
417
417
 
418
418
  Only provided fields are changed; all other forgetting fields are
419
- preserved. Changes take effect on the next daemon startup (or after
420
- the scheduler fires its next cycle).
419
+ preserved. Changes take effect after the daemon restarts.
421
420
  """
422
421
  _require_admin(request)
423
422
  try:
424
- data = _read_config()
425
- stored = data.get("forgetting", {})
426
- # Apply defaults for missing fields, then overlay the stored values,
427
- # then overlay the requested updates.
428
- merged = {**_FORGETTING_DEFAULTS, **stored}
429
423
  updates = body.model_dump(exclude_none=True)
430
- merged.update(updates)
431
- data["forgetting"] = merged
432
- _atomic_write(data)
433
- return {k: merged[k] for k in _FORGETTING_DEFAULTS}
434
- except Exception as exc:
424
+
425
+ def mutate(data: dict) -> None:
426
+ stored = data.get("forgetting", {})
427
+ merged = {**_FORGETTING_DEFAULTS, **stored, **updates}
428
+ data["forgetting"] = merged
429
+
430
+ data = _update_config(mutate)
431
+ merged = data["forgetting"]
432
+ return {
433
+ **{k: merged[k] for k in _FORGETTING_DEFAULTS},
434
+ "restart_required": True,
435
+ }
436
+ except Exception:
435
437
  logger.exception("put_forgetting_config failed")
436
438
  return JSONResponse({"error": "Internal server error"}, status_code=500)
@@ -6,48 +6,117 @@
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- from fastapi import APIRouter, HTTPException, Request, Query
9
+ from fastapi import APIRouter, HTTPException, Query, Request
10
10
 
11
- from .helpers import require_engine, get_active_profile
11
+ from .helpers import get_active_profile, require_engine
12
12
 
13
13
  router = APIRouter(prefix="/api/entity", tags=["entity"])
14
14
 
15
15
 
16
+ def _list_entities_sql(where_sql: str) -> str:
17
+ """Build the page-first entity query after ``where_sql`` is parameterized."""
18
+ return f"""
19
+ WITH page_entities AS MATERIALIZED (
20
+ SELECT ce.entity_id, ce.profile_id, ce.canonical_name,
21
+ ce.entity_type, ce.fact_count, ce.first_seen, ce.last_seen
22
+ FROM canonical_entities ce
23
+ WHERE {where_sql}
24
+ ORDER BY ce.fact_count DESC, ce.entity_id ASC
25
+ LIMIT ? OFFSET ?
26
+ ),
27
+ ranked_profiles AS MATERIALIZED (
28
+ SELECT ep.*,
29
+ ROW_NUMBER() OVER (
30
+ PARTITION BY ep.entity_id, ep.profile_id
31
+ ORDER BY COALESCE(ep.last_compiled_at, '') DESC,
32
+ ep.project_name COLLATE NOCASE ASC,
33
+ ep.rowid ASC
34
+ ) AS summary_rank
35
+ FROM entity_profiles ep
36
+ JOIN page_entities page
37
+ ON page.entity_id = ep.entity_id
38
+ AND page.profile_id = ep.profile_id
39
+ WHERE ep.profile_id = ?
40
+ )
41
+ SELECT ce.entity_id, ce.canonical_name, ce.entity_type,
42
+ ce.fact_count, ce.first_seen, ce.last_seen,
43
+ ep.knowledge_summary, ep.compiled_truth,
44
+ ep.compilation_confidence, ep.last_compiled_at
45
+ FROM page_entities ce
46
+ LEFT JOIN ranked_profiles ep
47
+ ON ce.entity_id = ep.entity_id
48
+ AND ep.profile_id = ce.profile_id
49
+ AND ep.summary_rank = 1
50
+ ORDER BY ce.fact_count DESC, ce.entity_id ASC
51
+ """
52
+
53
+
54
+ def _require_read(request: Request, profile: str) -> None:
55
+ """Authorize entity metadata access for the explicitly requested profile."""
56
+ from superlocalmemory.access.rbac import Permission
57
+ from superlocalmemory.server.rbac_enforce import require_permission
58
+
59
+ require_permission(request, Permission.READ, profile=profile)
60
+
61
+
62
+ def _require_manage(request: Request, profile: str) -> None:
63
+ """Authorize entity recompilation for the explicitly requested profile."""
64
+ from superlocalmemory.server.rbac_enforce import require_manage
65
+
66
+ require_manage(request, profile=profile)
67
+
68
+
16
69
  @router.get("/list")
17
- async def list_entities(
70
+ def list_entities(
18
71
  request: Request,
19
72
  profile: str | None = Query(default=None),
73
+ entity_type: str | None = Query(default=None, alias="type", max_length=80),
74
+ search: str | None = Query(default=None, max_length=200),
20
75
  limit: int = Query(default=100, ge=1, le=1000),
21
76
  offset: int = Query(default=0, ge=0),
22
77
  ):
23
- """List all entities with basic info (canonical name, type, fact count)."""
78
+ """List a profile's entities, filtering before count and pagination."""
24
79
  engine = require_engine(request)
25
80
  # Default to the ACTIVE profile (request runtime truth), never the literal
26
81
  # "default" — otherwise every profile sees the default profile's entities.
27
82
  profile = profile or get_active_profile()
83
+ _require_read(request, profile)
28
84
 
29
85
  import sqlite3
30
- import json
31
86
  conn = sqlite3.connect(str(engine._config.db_path))
32
87
  conn.row_factory = sqlite3.Row
33
88
  try:
89
+ where = ["ce.profile_id = ?"]
90
+ params: list[object] = [profile]
91
+ if entity_type and entity_type.lower() != "all":
92
+ where.append("ce.entity_type = ? COLLATE NOCASE")
93
+ params.append(entity_type.strip().lower())
94
+ if search and search.strip():
95
+ escaped = (search.strip().lower().replace("\\", "\\\\")
96
+ .replace("%", "\\%").replace("_", "\\_"))
97
+ where.append(
98
+ "(LOWER(ce.canonical_name) LIKE ? ESCAPE '\\' "
99
+ "OR LOWER(COALESCE(ce.entity_type, 'unknown')) LIKE ? ESCAPE '\\' "
100
+ "OR EXISTS ("
101
+ "SELECT 1 FROM entity_profiles eps "
102
+ "WHERE eps.entity_id = ce.entity_id "
103
+ "AND eps.profile_id = ce.profile_id "
104
+ "AND LOWER(COALESCE(eps.knowledge_summary, '')) "
105
+ "LIKE ? ESCAPE '\\'))"
106
+ )
107
+ params.extend([f"%{escaped}%"] * 3)
108
+ where_sql = " AND ".join(where)
109
+
34
110
  total = conn.execute(
35
- "SELECT COUNT(*) FROM canonical_entities WHERE profile_id = ?",
36
- (profile,),
111
+ "SELECT COUNT(*) FROM canonical_entities ce "
112
+ f"WHERE {where_sql}",
113
+ params,
37
114
  ).fetchone()[0]
38
115
 
39
- rows = conn.execute("""
40
- SELECT ce.entity_id, ce.canonical_name, ce.entity_type,
41
- ce.fact_count, ce.first_seen, ce.last_seen,
42
- ep.knowledge_summary, ep.compiled_truth,
43
- ep.compilation_confidence, ep.last_compiled_at
44
- FROM canonical_entities ce
45
- LEFT JOIN entity_profiles ep
46
- ON ce.entity_id = ep.entity_id AND ep.profile_id = ce.profile_id
47
- WHERE ce.profile_id = ?
48
- ORDER BY ce.fact_count DESC
49
- LIMIT ? OFFSET ?
50
- """, (profile, limit, offset)).fetchall()
116
+ rows = conn.execute(
117
+ _list_entities_sql(where_sql),
118
+ [*params, limit, offset, profile],
119
+ ).fetchall()
51
120
 
52
121
  entities = []
53
122
  for r in rows:
@@ -65,13 +134,19 @@ async def list_entities(
65
134
  "last_compiled_at": r["last_compiled_at"],
66
135
  })
67
136
 
68
- return {"entities": entities, "total": total, "limit": limit, "offset": offset}
137
+ return {
138
+ "entities": entities,
139
+ "total": total,
140
+ "limit": limit,
141
+ "offset": offset,
142
+ "has_more": offset + limit < total,
143
+ }
69
144
  finally:
70
145
  conn.close()
71
146
 
72
147
 
73
148
  @router.get("/{entity_name}")
74
- async def get_entity(
149
+ def get_entity(
75
150
  entity_name: str,
76
151
  request: Request,
77
152
  profile: str | None = Query(default=None),
@@ -80,9 +155,10 @@ async def get_entity(
80
155
  """Get compiled truth + timeline for an entity."""
81
156
  engine = require_engine(request)
82
157
  profile = profile or get_active_profile()
158
+ _require_read(request, profile)
83
159
 
84
- import sqlite3
85
160
  import json
161
+ import sqlite3
86
162
  conn = sqlite3.connect(str(engine._config.db_path))
87
163
  conn.row_factory = sqlite3.Row
88
164
  try:
@@ -116,7 +192,7 @@ async def get_entity(
116
192
 
117
193
 
118
194
  @router.post("/{entity_name}/recompile")
119
- async def recompile_entity(
195
+ def recompile_entity(
120
196
  entity_name: str,
121
197
  request: Request,
122
198
  profile: str | None = Query(default=None),
@@ -125,6 +201,7 @@ async def recompile_entity(
125
201
  """Force immediate recompilation of an entity."""
126
202
  engine = require_engine(request)
127
203
  profile = profile or get_active_profile()
204
+ _require_manage(request, profile)
128
205
 
129
206
  import sqlite3
130
207
  conn = sqlite3.connect(str(engine._config.db_path))