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.
- package/CHANGELOG.md +112 -0
- package/README.md +32 -120
- package/package.json +9 -2
- package/plugin/.claude-plugin/plugin.json +1 -2
- 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 +2 -2
- 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 +1 -1
- 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 +1 -1
- package/plugin/skills/slm-recall/SKILL.md +3 -5
- 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 +2 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +3 -5
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +2 -1
- package/scripts/postinstall.js +7 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +494 -9
- package/src/superlocalmemory/cli/daemon.py +7 -0
- package/src/superlocalmemory/cli/loop_cmd.py +2 -7
- package/src/superlocalmemory/cli/main.py +72 -7
- package/src/superlocalmemory/cli/setup_wizard.py +142 -16
- package/src/superlocalmemory/cli/version_banner.py +17 -3
- package/src/superlocalmemory/core/backend_orchestrator.py +18 -16
- package/src/superlocalmemory/core/component_healer.py +144 -0
- package/src/superlocalmemory/core/component_registry.py +487 -0
- package/src/superlocalmemory/core/config.py +21 -0
- package/src/superlocalmemory/core/embedding_worker.py +4 -5
- package/src/superlocalmemory/core/embeddings.py +132 -45
- package/src/superlocalmemory/core/engine.py +29 -22
- package/src/superlocalmemory/core/engine_ingestion.py +332 -45
- package/src/superlocalmemory/core/ingestion_command.py +154 -25
- package/src/superlocalmemory/core/injection.py +12 -7
- package/src/superlocalmemory/core/maintenance.py +43 -0
- package/src/superlocalmemory/core/maintenance_scheduler.py +44 -6
- package/src/superlocalmemory/core/recall_pipeline.py +42 -4
- package/src/superlocalmemory/core/store_pipeline.py +195 -20
- package/src/superlocalmemory/hooks/hook_handlers.py +6 -1
- package/src/superlocalmemory/hooks/portable_kit.py +34 -2
- package/src/superlocalmemory/learning/model_rollback.py +3 -0
- package/src/superlocalmemory/learning/ranker_retrain_online.py +2 -0
- package/src/superlocalmemory/learning/reward.py +50 -0
- package/src/superlocalmemory/learning/source_quality.py +523 -1
- package/src/superlocalmemory/loops/ledger.py +25 -5
- package/src/superlocalmemory/mcp/_daemon_proxy.py +6 -2
- package/src/superlocalmemory/mcp/_pool_adapter.py +4 -1
- package/src/superlocalmemory/mcp/server.py +11 -30
- package/src/superlocalmemory/mcp/tools_active.py +1 -1
- package/src/superlocalmemory/mcp/tools_core.py +21 -5
- package/src/superlocalmemory/mcp/tools_learning.py +2 -2
- package/src/superlocalmemory/retrieval/bridge_discovery.py +14 -0
- package/src/superlocalmemory/retrieval/engine.py +53 -21
- package/src/superlocalmemory/retrieval/reranker.py +3 -4
- package/src/superlocalmemory/retrieval/spreading_activation.py +68 -38
- package/src/superlocalmemory/server/config_file.py +90 -0
- package/src/superlocalmemory/server/origin.py +50 -0
- package/src/superlocalmemory/server/routes/backup.py +293 -70
- package/src/superlocalmemory/server/routes/behavioral.py +342 -61
- package/src/superlocalmemory/server/routes/brain.py +57 -16
- package/src/superlocalmemory/server/routes/config_api.py +84 -82
- package/src/superlocalmemory/server/routes/entity.py +100 -23
- package/src/superlocalmemory/server/routes/evolution.py +103 -100
- package/src/superlocalmemory/server/routes/learning.py +286 -105
- package/src/superlocalmemory/server/routes/learning_telemetry.py +153 -0
- package/src/superlocalmemory/server/routes/memories.py +8 -3
- package/src/superlocalmemory/server/routes/mesh.py +121 -32
- package/src/superlocalmemory/server/routes/ratelimit.py +33 -25
- package/src/superlocalmemory/server/routes/stats.py +93 -155
- package/src/superlocalmemory/server/routes/token.py +3 -13
- package/src/superlocalmemory/server/routes/v3_api.py +184 -20
- package/src/superlocalmemory/server/unified_daemon.py +732 -41
- package/src/superlocalmemory/storage/embedding_migrator.py +235 -0
- package/src/superlocalmemory/storage/migration_runner.py +79 -1
- package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +5 -0
- package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +270 -0
- package/src/superlocalmemory/storage/migrations/M029_behavioral_history_indexes.py +137 -0
- package/src/superlocalmemory/storage/migrations/M030_entity_explorer_indexes.py +93 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +4 -0
- package/src/superlocalmemory/storage/schema.py +49 -1
- package/src/superlocalmemory/storage/schema_v32.py +2 -0
- package/src/superlocalmemory/storage/schema_v347.py +4 -0
- package/src/superlocalmemory/ui/index.html +6 -8
- package/src/superlocalmemory/ui/js/core.js +52 -9
- package/src/superlocalmemory/ui/js/dashboard.js +169 -82
- package/src/superlocalmemory/ui/js/od-backup.js +156 -65
- package/src/superlocalmemory/ui/js/od-brain.js +88 -51
- package/src/superlocalmemory/ui/js/od-components.js +147 -0
- package/src/superlocalmemory/ui/js/od-entities.js +65 -22
- package/src/superlocalmemory/ui/js/od-graph.js +46 -4
- package/src/superlocalmemory/ui/js/od-health.js +18 -0
- package/src/superlocalmemory/ui/js/od-memories.js +84 -5
- package/src/superlocalmemory/ui/js/od-mesh.js +23 -9
- package/src/superlocalmemory/ui/js/od-operations.js +36 -0
- package/src/superlocalmemory/ui/js/od-settings.js +186 -63
- package/src/superlocalmemory/ui/js/od-shell.js +249 -33
- package/src/superlocalmemory/ui/js/od-skills.js +44 -17
- package/src/superlocalmemory/ui/js/settings.js +15 -1
- package/plugin-src/.mcp.json +0 -12
- package/plugin-src/agents/slm-governance-advisor.md +0 -80
- package/plugin-src/agents/slm-loop-runner.md +0 -71
- package/plugin-src/agents/slm-memory-advisor.md +0 -49
- package/plugin-src/agents/slm-optimize-advisor.md +0 -44
- package/plugin-src/commands/slm-loop.md +0 -31
- package/plugin-src/hooks/.gitkeep +0 -0
- package/plugin-src/hooks/hooks.json +0 -102
- package/plugin-src/manifest.json +0 -30
- package/plugin-src/requirements.txt +0 -1
- package/plugin-src/rules/CLAUDE.md.fragment +0 -44
- package/plugin-src/scripts/ensure-venv.bat +0 -122
- package/plugin-src/scripts/ensure-venv.sh +0 -105
- package/plugin-src/scripts/slm-launch +0 -62
- package/plugin-src/scripts/slm-launch.bat +0 -23
- package/plugin-src/settings.json +0 -25
- package/plugin-src/skills/slm-governance/SKILL.md +0 -248
- package/plugin-src/skills/slm-loop/SKILL.md +0 -99
- package/plugin-src/skills/slm-mesh/SKILL.md +0 -282
- package/plugin-src/skills/slm-profile/SKILL.md +0 -148
- 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
|
|
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,
|
|
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(
|
|
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
|
-
|
|
983
|
-
"
|
|
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
|
|
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(
|
|
1218
|
-
|
|
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(
|
|
1230
|
-
|
|
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(
|
|
1241
|
-
|
|
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
|
-
-
|
|
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,
|
|
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
|
|
76
|
+
"""Read one coherent config snapshot."""
|
|
77
77
|
p = _config_path()
|
|
78
|
-
if not p.exists():
|
|
79
|
-
return {}
|
|
80
78
|
try:
|
|
81
|
-
return
|
|
82
|
-
except (
|
|
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
|
-
|
|
82
|
+
raise
|
|
85
83
|
|
|
86
84
|
|
|
87
|
-
def
|
|
88
|
-
"""
|
|
89
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
310
|
-
"""
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
return {"enabled": body.enabled}
|
|
317
|
-
except Exception
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
431
|
-
data
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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,
|
|
9
|
+
from fastapi import APIRouter, HTTPException, Query, Request
|
|
10
10
|
|
|
11
|
-
from .helpers import
|
|
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
|
-
|
|
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
|
|
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
|
|
36
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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))
|