superlocalmemory 3.8.13 → 4.0.0
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/ATTRIBUTION.md +4 -4
- package/CHANGELOG.md +113 -121
- package/README.md +65 -63
- package/docs/pi-dev-integration.md +1 -1
- package/package.json +6 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +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 +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -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-governance/SKILL.md +248 -0
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-loop/SKILL.md +99 -0
- package/plugin-src/skills/slm-mesh/SKILL.md +282 -0
- package/plugin-src/skills/slm-profile/SKILL.md +148 -0
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-scope/SKILL.md +176 -0
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +11 -4
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/cli/commands.py +125 -11
- package/src/superlocalmemory/cli/daemon.py +5 -1
- package/src/superlocalmemory/cli/main.py +35 -2
- package/src/superlocalmemory/cli/ops_cmd.py +281 -0
- package/src/superlocalmemory/cli/setup_wizard.py +1 -1
- package/src/superlocalmemory/compliance/audit.py +65 -0
- package/src/superlocalmemory/compliance/eu_ai_act.py +27 -57
- package/src/superlocalmemory/compliance/gdpr.py +416 -20
- package/src/superlocalmemory/compliance/retention.py +74 -22
- package/src/superlocalmemory/compliance/scheduler.py +78 -9
- package/src/superlocalmemory/core/actor_context.py +166 -0
- package/src/superlocalmemory/core/admission.py +549 -0
- package/src/superlocalmemory/core/backend_orchestrator.py +23 -10
- package/src/superlocalmemory/core/config.py +202 -24
- package/src/superlocalmemory/core/consolidation_engine.py +13 -13
- package/src/superlocalmemory/core/context_cache.py +28 -0
- package/src/superlocalmemory/core/embeddings.py +64 -2
- package/src/superlocalmemory/core/engine.py +7 -2
- package/src/superlocalmemory/core/engine_ingestion.py +65 -3
- package/src/superlocalmemory/core/engine_wiring.py +36 -9
- package/src/superlocalmemory/core/ingest_policy.py +38 -0
- package/src/superlocalmemory/core/maintenance.py +255 -0
- package/src/superlocalmemory/core/modes.py +40 -13
- package/src/superlocalmemory/core/mutations.py +437 -44
- package/src/superlocalmemory/core/operation_policy.py +92 -0
- package/src/superlocalmemory/core/operation_policy_registry.py +542 -0
- package/src/superlocalmemory/core/operation_request.py +127 -0
- package/src/superlocalmemory/core/ops_remediation.py +542 -0
- package/src/superlocalmemory/core/recall_pipeline.py +7 -0
- package/src/superlocalmemory/core/remember_runtime.py +202 -4
- package/src/superlocalmemory/core/remote_mode.py +20 -5
- package/src/superlocalmemory/core/store_pipeline.py +150 -0
- package/src/superlocalmemory/core/topic_signature.py +19 -4
- package/src/superlocalmemory/core/transactions/__init__.py +78 -0
- package/src/superlocalmemory/core/transactions/concrete_owners.py +597 -0
- package/src/superlocalmemory/core/transactions/erasure.py +825 -0
- package/src/superlocalmemory/core/transactions/manifest.py +255 -0
- package/src/superlocalmemory/core/transactions/manifest_key.py +155 -0
- package/src/superlocalmemory/core/transactions/obligations.py +272 -0
- package/src/superlocalmemory/core/transactions/owners.py +114 -0
- package/src/superlocalmemory/core/transactions/reconciler.py +285 -0
- package/src/superlocalmemory/core/transactions/service.py +330 -0
- package/src/superlocalmemory/core/worker_pool.py +33 -5
- package/src/superlocalmemory/encoding/cognitive_consolidator.py +70 -28
- package/src/superlocalmemory/encoding/emotional.py +75 -14
- package/src/superlocalmemory/encoding/scene_builder.py +115 -13
- package/src/superlocalmemory/encoding/temporal_parser.py +4 -0
- package/src/superlocalmemory/evolution/blind_verifier.py +11 -4
- package/src/superlocalmemory/evolution/evolution_store.py +244 -4
- package/src/superlocalmemory/evolution/llm_dispatch.py +40 -0
- package/src/superlocalmemory/evolution/model_selection.py +18 -3
- package/src/superlocalmemory/evolution/mutation_generator.py +3 -0
- package/src/superlocalmemory/evolution/skill_activator.py +270 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +281 -59
- package/src/superlocalmemory/evolution/types.py +30 -8
- package/src/superlocalmemory/graph/cozo_backend.py +17 -9
- package/src/superlocalmemory/hooks/auto_invoker.py +2 -1
- package/src/superlocalmemory/hooks/auto_recall.py +64 -30
- package/src/superlocalmemory/hooks/codex_assets.py +14 -1
- package/src/superlocalmemory/infra/backup.py +434 -7
- package/src/superlocalmemory/infra/process_reaper.py +18 -0
- package/src/superlocalmemory/infra/self_heal.py +401 -0
- package/src/superlocalmemory/learning/feedback.py +52 -9
- package/src/superlocalmemory/loops/engine.py +10 -0
- package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
- package/src/superlocalmemory/mcp/http_transport.py +30 -331
- package/src/superlocalmemory/mcp/profiles.py +5 -0
- package/src/superlocalmemory/mcp/resources.py +8 -0
- package/src/superlocalmemory/mcp/server.py +51 -4
- package/src/superlocalmemory/mcp/shared.py +19 -0
- package/src/superlocalmemory/mcp/tools_active.py +25 -4
- package/src/superlocalmemory/mcp/tools_code_graph.py +26 -18
- package/src/superlocalmemory/mcp/tools_context.py +50 -8
- package/src/superlocalmemory/mcp/tools_core.py +69 -21
- package/src/superlocalmemory/mcp/tools_evolution.py +9 -2
- package/src/superlocalmemory/mcp/tools_learning.py +21 -10
- package/src/superlocalmemory/mcp/tools_loops.py +29 -18
- package/src/superlocalmemory/mcp/tools_mesh.py +8 -0
- package/src/superlocalmemory/mcp/tools_ops.py +115 -0
- package/src/superlocalmemory/mcp/tools_optimize.py +4 -0
- package/src/superlocalmemory/mcp/tools_v28.py +10 -3
- package/src/superlocalmemory/mcp/tools_v3.py +34 -14
- package/src/superlocalmemory/mcp/tools_v33.py +18 -33
- package/src/superlocalmemory/mesh/broker.py +124 -46
- package/src/superlocalmemory/mesh/broker_security.py +470 -0
- package/src/superlocalmemory/mesh/discovery.py +365 -0
- package/src/superlocalmemory/mesh/lock_protocol.py +313 -0
- package/src/superlocalmemory/mesh/node_identity.py +97 -0
- package/src/superlocalmemory/mesh/outbox_remote.py +429 -0
- package/src/superlocalmemory/mesh/remote_sync.py +511 -28
- package/src/superlocalmemory/mesh/state_sync.py +286 -0
- package/src/superlocalmemory/optimize/config/store.py +45 -0
- package/src/superlocalmemory/parameterization/cross_project.py +12 -0
- package/src/superlocalmemory/parameterization/prompt_injector.py +13 -11
- package/src/superlocalmemory/parameterization/prompt_lifecycle.py +8 -2
- package/src/superlocalmemory/parameterization/workflow_miner.py +17 -0
- package/src/superlocalmemory/retrieval/ann_index.py +5 -0
- package/src/superlocalmemory/retrieval/bm25_channel.py +49 -2
- package/src/superlocalmemory/retrieval/engine.py +19 -4
- package/src/superlocalmemory/retrieval/fusion.py +4 -1
- package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -3
- package/src/superlocalmemory/retrieval/remote_reranker.py +47 -22
- package/src/superlocalmemory/retrieval/reranker.py +32 -1
- package/src/superlocalmemory/retrieval/temporal_channel.py +16 -3
- package/src/superlocalmemory/retrieval/temporal_utils.py +107 -0
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +155 -42
- package/src/superlocalmemory/retrieval/vector_store.py +214 -8
- package/src/superlocalmemory/server/api.py +5 -5
- package/src/superlocalmemory/server/egress_policy.py +258 -0
- package/src/superlocalmemory/server/rbac_enforce.py +32 -0
- package/src/superlocalmemory/server/route_mutations.py +20 -0
- package/src/superlocalmemory/server/routes/compliance.py +153 -7
- package/src/superlocalmemory/server/routes/data_io.py +43 -2
- package/src/superlocalmemory/server/routes/events.py +15 -0
- package/src/superlocalmemory/server/routes/memories.py +56 -3
- package/src/superlocalmemory/server/routes/mesh.py +82 -1
- package/src/superlocalmemory/server/routes/mesh_lock.py +54 -0
- package/src/superlocalmemory/server/routes/mesh_state.py +63 -0
- package/src/superlocalmemory/server/routes/v3_api.py +50 -27
- package/src/superlocalmemory/server/routes/ws.py +86 -0
- package/src/superlocalmemory/server/ui.py +6 -6
- package/src/superlocalmemory/server/unified_daemon.py +942 -119
- package/src/superlocalmemory/storage/_migration_internals.py +568 -0
- package/src/superlocalmemory/storage/_schema_version.py +110 -0
- package/src/superlocalmemory/storage/database.py +329 -24
- package/src/superlocalmemory/storage/embedding_migrator.py +246 -51
- package/src/superlocalmemory/storage/erasure_fence.py +45 -0
- package/src/superlocalmemory/storage/generation_fence.py +63 -0
- package/src/superlocalmemory/storage/migration_runner.py +140 -417
- package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +40 -0
- package/src/superlocalmemory/storage/migrations/M033_projection_transactions.py +148 -0
- package/src/superlocalmemory/storage/migrations/M034_obligation_integrity.py +58 -0
- package/src/superlocalmemory/storage/migrations/M035_erasure_receipts.py +113 -0
- package/src/superlocalmemory/storage/migrations/M036_vector_row_map.py +107 -0
- package/src/superlocalmemory/storage/migrations/M037_manifest_hmac_version.py +162 -0
- package/src/superlocalmemory/storage/migrations/{M033_learning_feedback_channel.py → M038_learning_feedback_channel.py} +3 -3
- package/src/superlocalmemory/storage/migrations/M039_scene_fact_members.py +137 -0
- package/src/superlocalmemory/storage/migrations/__init__.py +4 -2
- package/src/superlocalmemory/storage/schema.py +67 -0
- package/src/superlocalmemory/storage/write_coordinator.py +125 -0
- package/src/superlocalmemory/trust/scorer.py +28 -4
- package/src/superlocalmemory/ui/index.html +14 -3
- package/src/superlocalmemory/ui/js/auto-settings.js +12 -1
- package/src/superlocalmemory/ui/js/brain.js +6 -4
- package/src/superlocalmemory/ui/js/compliance.js +66 -12
- package/src/superlocalmemory/ui/js/dashboard.js +13 -3
- package/src/superlocalmemory/ui/js/feedback.js +8 -2
- package/src/superlocalmemory/ui/js/lifecycle.js +7 -1
- package/src/superlocalmemory/ui/js/modal.js +272 -5
- package/src/superlocalmemory/ui/js/od-backup.js +9 -2
- package/src/superlocalmemory/ui/js/od-compliance-ext.js +301 -0
- package/src/superlocalmemory/ui/js/od-operations.js +154 -23
- package/src/superlocalmemory/ui/js/od-ops-health.js +417 -0
- package/src/superlocalmemory/ui/js/od-optimize.js +35 -21
- package/src/superlocalmemory/ui/js/od-team.js +9 -2
- package/src/superlocalmemory/ui/js/optimize.js +13 -16
- package/src/superlocalmemory/ui/js/profiles.js +7 -3
- package/src/superlocalmemory/ui/js/settings.js +7 -1
- package/src/superlocalmemory/vector/lancedb_backend.py +19 -9
- package/src/superlocalmemory/attribution/mathematical_dna.py +0 -235
- package/src/superlocalmemory/cli/post_install.py +0 -114
- package/src/superlocalmemory/core/clock_monitor.py +0 -45
- package/src/superlocalmemory/core/db_pool.py +0 -80
- package/src/superlocalmemory/core/error_catalog.py +0 -113
- package/src/superlocalmemory/core/loop_watchdog.py +0 -56
- package/src/superlocalmemory/core/priority_queue.py +0 -61
- package/src/superlocalmemory/core/pruning_engine.py +0 -216
- package/src/superlocalmemory/core/queue_dispatcher.py +0 -73
- package/src/superlocalmemory/core/slmignore.py +0 -125
- package/src/superlocalmemory/infra/heartbeat_monitor.py +0 -140
- package/src/superlocalmemory/infra/webhook_dispatcher.py +0 -247
- package/src/superlocalmemory/learning/quantization_scheduler.py +0 -320
- package/src/superlocalmemory/storage/access_control.py +0 -182
|
@@ -114,6 +114,48 @@ def _mutation_runtime_or_missing_fact(
|
|
|
114
114
|
raise HTTPException(503, detail="canonical mutation writer is not ready; retry shortly")
|
|
115
115
|
|
|
116
116
|
|
|
117
|
+
def _admit_http_mutation(request: Request, operation: str) -> None:
|
|
118
|
+
"""Route a memory HTTP mutation through OperationPolicyRegistry.evaluate().
|
|
119
|
+
|
|
120
|
+
Called from _authorize_memory_mutation after RBAC passes. Raises HTTP 403
|
|
121
|
+
if the policy registry denies the actor. Maps "delete" → FORGET,
|
|
122
|
+
"update" → CORRECT. Uses the server-derived principal and roles.
|
|
123
|
+
"""
|
|
124
|
+
from fastapi import HTTPException as _HTTPException
|
|
125
|
+
|
|
126
|
+
from superlocalmemory.core.actor_context import Transport
|
|
127
|
+
from superlocalmemory.core.admission import AdmissionDenied, admit, resolve_actor
|
|
128
|
+
from superlocalmemory.core.operation_request import OperationKind
|
|
129
|
+
from superlocalmemory.server.rbac_enforce import (
|
|
130
|
+
resolve_actor_roles,
|
|
131
|
+
resolve_principal,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
deployment = getattr(request.app.state, "deployment", None)
|
|
135
|
+
is_enterprise = bool(deployment and deployment.is_enterprise)
|
|
136
|
+
tier = "enterprise" if is_enterprise else "personal"
|
|
137
|
+
mode = "company" if is_enterprise else "local"
|
|
138
|
+
|
|
139
|
+
principal_info = resolve_principal(request)
|
|
140
|
+
principal = str(principal_info.get("user_id") or "")
|
|
141
|
+
actor_roles = resolve_actor_roles(request)
|
|
142
|
+
actor = resolve_actor(
|
|
143
|
+
Transport.HTTP,
|
|
144
|
+
tier=tier,
|
|
145
|
+
mode=mode,
|
|
146
|
+
principal=principal,
|
|
147
|
+
roles=actor_roles,
|
|
148
|
+
)
|
|
149
|
+
kind = OperationKind.FORGET if operation == "delete" else OperationKind.CORRECT
|
|
150
|
+
try:
|
|
151
|
+
admit(kind, actor, mode=mode)
|
|
152
|
+
except AdmissionDenied as exc:
|
|
153
|
+
raise _HTTPException(
|
|
154
|
+
status_code=403,
|
|
155
|
+
detail=f"Operation denied: {exc.decision.reason}",
|
|
156
|
+
) from exc
|
|
157
|
+
|
|
158
|
+
|
|
117
159
|
def _authorize_memory_mutation(
|
|
118
160
|
request: Request,
|
|
119
161
|
operation: str,
|
|
@@ -131,14 +173,15 @@ def _authorize_memory_mutation(
|
|
|
131
173
|
actor_kind="dashboard",
|
|
132
174
|
)
|
|
133
175
|
# RBAC (C3): on top of machine auth, enforce the caller's role on the active
|
|
134
|
-
# profile. delete → DELETE; every other mutation → WRITE.
|
|
135
|
-
# owner in require_login mode) is rejected here; owner/admin/member pass.
|
|
176
|
+
# profile. delete → DELETE; every other mutation → WRITE.
|
|
136
177
|
from superlocalmemory.access.rbac import Permission as _Perm
|
|
137
178
|
from superlocalmemory.server.rbac_enforce import require_permission as _rbac_require
|
|
138
179
|
_rbac_require(
|
|
139
180
|
request,
|
|
140
181
|
_Perm.DELETE if operation == "delete" else _Perm.WRITE,
|
|
141
182
|
)
|
|
183
|
+
# Phase 1: admission gateway — policy registry decision for this route.
|
|
184
|
+
_admit_http_mutation(request, operation)
|
|
142
185
|
engine = _get_engine(request)
|
|
143
186
|
if engine is None:
|
|
144
187
|
raise HTTPException(503, detail="Engine not initialized")
|
|
@@ -1045,8 +1088,18 @@ async def delete_memory(request: Request, fact_id: str):
|
|
|
1045
1088
|
idempotency_key=_mutation_idempotency_key(request),
|
|
1046
1089
|
)
|
|
1047
1090
|
if not result.get("ok"):
|
|
1091
|
+
if result.get("retryable"):
|
|
1092
|
+
raise HTTPException(
|
|
1093
|
+
status_code=503,
|
|
1094
|
+
detail="Erasure incomplete (projection residue); retry shortly",
|
|
1095
|
+
)
|
|
1048
1096
|
raise HTTPException(status_code=404, detail="Memory not found")
|
|
1049
|
-
return {
|
|
1097
|
+
return {
|
|
1098
|
+
"success": True,
|
|
1099
|
+
"deleted": fact_id,
|
|
1100
|
+
"erasure_verified": bool(result.get("erasure_verified", False)),
|
|
1101
|
+
"erasure_state": result.get("erasure_state", "FAILED"),
|
|
1102
|
+
}
|
|
1050
1103
|
except HTTPException:
|
|
1051
1104
|
raise
|
|
1052
1105
|
except Exception as exc:
|
|
@@ -112,7 +112,11 @@ def _get_broker(request: Request):
|
|
|
112
112
|
)
|
|
113
113
|
if not presented or not hmac.compare_digest(presented, secret):
|
|
114
114
|
raise HTTPException(401, detail="invalid or missing credential")
|
|
115
|
-
|
|
115
|
+
# TEST-1/SEC: When a fleet secret is configured, it is the primary auth
|
|
116
|
+
# mechanism. Loopback callers are trusted by transport and need no further
|
|
117
|
+
# proof. Only fall through to require_write_actor when NO secret is configured
|
|
118
|
+
# (install-token-only, single-machine mode).
|
|
119
|
+
return broker
|
|
116
120
|
|
|
117
121
|
# Loopback is a transport property, not an identity. Every mesh read and
|
|
118
122
|
# write must prove the install/API/process capability because peer inboxes,
|
|
@@ -346,6 +350,83 @@ def send(req: SendRequest, request: Request):
|
|
|
346
350
|
if not to_target:
|
|
347
351
|
raise HTTPException(400, detail="'to' or 'to_peer' required")
|
|
348
352
|
profile = _active_profile()
|
|
353
|
+
|
|
354
|
+
# 3a-1 + 3a-2: Apply signature verification and admission gate for non-loopback.
|
|
355
|
+
client_host = request.client.host if request.client else "127.0.0.1"
|
|
356
|
+
from superlocalmemory.server.loopback import is_loopback as _is_loopback_host
|
|
357
|
+
_is_lb = _is_loopback_host(client_host)
|
|
358
|
+
|
|
359
|
+
if not _is_lb:
|
|
360
|
+
from superlocalmemory.mesh.broker_security import (
|
|
361
|
+
check_mesh_message_signature,
|
|
362
|
+
is_strict_identity,
|
|
363
|
+
)
|
|
364
|
+
config = getattr(request.app.state, "config", None)
|
|
365
|
+
strict = is_strict_identity(config)
|
|
366
|
+
fleet_secret = getattr(broker, "_shared_secret", None)
|
|
367
|
+
|
|
368
|
+
# SEC-4 (hardened): identity resolution per mode.
|
|
369
|
+
# - COMPAT (default): fleet secret verifies (or unsigned legacy accepted).
|
|
370
|
+
# - STRICT: the sender MUST be a registered peer with its own per-peer key.
|
|
371
|
+
# There is NO fleet-secret fallback in strict mode — unregistered
|
|
372
|
+
# from_peer, a NULL peer_key, or a DB error all FAIL CLOSED. (The old
|
|
373
|
+
# fleet fallback was an impersonation escape hatch: any fleet-secret
|
|
374
|
+
# holder could claim an unregistered/legacy from_peer.)
|
|
375
|
+
verify_secret = fleet_secret
|
|
376
|
+
if strict:
|
|
377
|
+
if not req.from_peer:
|
|
378
|
+
raise HTTPException(401, detail="strict identity: from_peer required")
|
|
379
|
+
import sqlite3 as _sqlite3
|
|
380
|
+
try:
|
|
381
|
+
_conn = _sqlite3.connect(broker._db_path, timeout=3)
|
|
382
|
+
_conn.row_factory = _sqlite3.Row
|
|
383
|
+
_row = _conn.execute(
|
|
384
|
+
"SELECT peer_key FROM mesh_peers WHERE peer_id=? LIMIT 1",
|
|
385
|
+
(req.from_peer,),
|
|
386
|
+
).fetchone()
|
|
387
|
+
_conn.close()
|
|
388
|
+
except _sqlite3.Error:
|
|
389
|
+
raise HTTPException(503, detail="strict identity: peer key lookup failed")
|
|
390
|
+
if not _row or not _row["peer_key"]:
|
|
391
|
+
raise HTTPException(
|
|
392
|
+
401,
|
|
393
|
+
detail="strict identity: from_peer is not a registered peer with a per-peer key",
|
|
394
|
+
)
|
|
395
|
+
verify_secret = str(_row["peer_key"])
|
|
396
|
+
|
|
397
|
+
sig_err = check_mesh_message_signature(
|
|
398
|
+
verify_secret,
|
|
399
|
+
req.from_peer,
|
|
400
|
+
to_target,
|
|
401
|
+
req.content,
|
|
402
|
+
request.headers.get("x-mesh-sig"),
|
|
403
|
+
request.headers.get("x-mesh-nonce"),
|
|
404
|
+
request.headers.get("x-mesh-ts"),
|
|
405
|
+
is_loopback=False,
|
|
406
|
+
strict=strict,
|
|
407
|
+
)
|
|
408
|
+
if sig_err is not None:
|
|
409
|
+
raise HTTPException(401, detail=sig_err.get("error", "signature error"))
|
|
410
|
+
|
|
411
|
+
# Admission gate parity (closes Wave-1 P1 bypass for inbound remote send).
|
|
412
|
+
try:
|
|
413
|
+
from superlocalmemory.core.admission import (
|
|
414
|
+
AdmissionDenied,
|
|
415
|
+
admit,
|
|
416
|
+
resolve_actor,
|
|
417
|
+
)
|
|
418
|
+
from superlocalmemory.core.actor_context import Transport
|
|
419
|
+
from superlocalmemory.core.operation_request import OperationKind
|
|
420
|
+
except ImportError:
|
|
421
|
+
AdmissionDenied = None # type: ignore[assignment,misc]
|
|
422
|
+
|
|
423
|
+
if AdmissionDenied is not None:
|
|
424
|
+
try:
|
|
425
|
+
actor = resolve_actor(Transport.HTTP, client_host=client_host)
|
|
426
|
+
admit(OperationKind.MESH_SEND, actor)
|
|
427
|
+
except AdmissionDenied as exc:
|
|
428
|
+
raise HTTPException(403, detail=str(exc))
|
|
429
|
+
|
|
349
430
|
# This sync FastAPI route already runs in the worker thread pool, so the
|
|
350
431
|
# broker's SQLite retries and optional remote HTTP delivery cannot block
|
|
351
432
|
# the daemon event loop.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V4 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""SLM Mesh — lock-delta HTTP route (3c-2).
|
|
6
|
+
|
|
7
|
+
Exposes ``GET /mesh/lock/delta`` so remote peers can fetch this node's
|
|
8
|
+
live advisory locks for convergence via ``LockCoordinator.resolve()``.
|
|
9
|
+
|
|
10
|
+
Auth and broker resolution mirror ``routes/mesh.py``.
|
|
11
|
+
Do NOT mount this router directly — the delivery lead wires it via
|
|
12
|
+
``app.include_router(mesh_lock_routes.router)``.
|
|
13
|
+
|
|
14
|
+
Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from fastapi import APIRouter, Request
|
|
20
|
+
|
|
21
|
+
from superlocalmemory.mesh.lock_protocol import LockCoordinator
|
|
22
|
+
from superlocalmemory.mesh.node_identity import get_node_id
|
|
23
|
+
from superlocalmemory.server.routes.mesh import _get_broker
|
|
24
|
+
from superlocalmemory.server.routes.helpers import get_active_profile
|
|
25
|
+
|
|
26
|
+
router = APIRouter(prefix="/mesh/lock", tags=["mesh-lock"])
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@router.get("/delta")
|
|
30
|
+
def lock_delta(
|
|
31
|
+
profile: str = "",
|
|
32
|
+
request: Request = None, # type: ignore[assignment]
|
|
33
|
+
) -> dict:
|
|
34
|
+
"""Return this node's live advisory locks for a given tenant profile.
|
|
35
|
+
|
|
36
|
+
The response is designed for consumption by a remote peer's
|
|
37
|
+
``LockCoordinator.resolve()`` call: it includes the raw lock records
|
|
38
|
+
plus the local ``node_id`` so the remote can apply the total-order
|
|
39
|
+
``(fencing_token, node_id)`` comparison without a separate handshake.
|
|
40
|
+
|
|
41
|
+
Query params:
|
|
42
|
+
profile: Tenant profile id. Falls back to the active profile
|
|
43
|
+
from the request context var when omitted or blank.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
``{"locks": [...], "node_id": "<hex>"}``
|
|
47
|
+
``locks`` is the output of ``LockCoordinator.local_lock_delta()``.
|
|
48
|
+
"""
|
|
49
|
+
broker = _get_broker(request)
|
|
50
|
+
profile_id: str = profile.strip() or get_active_profile()
|
|
51
|
+
coordinator = LockCoordinator(broker)
|
|
52
|
+
locks = coordinator.local_lock_delta(profile_id)
|
|
53
|
+
node_id = get_node_id(broker._db_path)
|
|
54
|
+
return {"locks": locks, "node_id": node_id}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V4 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""SLM Mesh — state-sync delta endpoint (3c-1).
|
|
6
|
+
|
|
7
|
+
Exposes a single GET route that returns the local ``mesh_state`` rows whose
|
|
8
|
+
revision exceeds ``since``, suitable for pull-based LWW convergence.
|
|
9
|
+
|
|
10
|
+
Mounted by the delivery lead (unified_daemon.py) via ``include_router``.
|
|
11
|
+
Auth, broker access, and profile resolution all mirror ``routes/mesh.py``
|
|
12
|
+
exactly — no new patterns are introduced.
|
|
13
|
+
|
|
14
|
+
Author: Varun Pratap Bhardwaj
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from fastapi import APIRouter, Request
|
|
20
|
+
|
|
21
|
+
from superlocalmemory.mesh.state_sync import StateSyncer
|
|
22
|
+
|
|
23
|
+
# Re-use auth + broker helpers verbatim from the main mesh module so that
|
|
24
|
+
# security properties (shared-secret, loopback trust, write-identity) apply
|
|
25
|
+
# identically to this new endpoint.
|
|
26
|
+
from superlocalmemory.server.routes.mesh import _active_profile, _get_broker
|
|
27
|
+
|
|
28
|
+
router = APIRouter(prefix="/mesh", tags=["mesh-state-sync"])
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@router.get("/state/delta")
|
|
32
|
+
def state_delta(
|
|
33
|
+
request: Request,
|
|
34
|
+
profile: str = "",
|
|
35
|
+
since: int = 0,
|
|
36
|
+
) -> dict:
|
|
37
|
+
"""Return local mesh_state rows with revision > ``since`` for LWW sync.
|
|
38
|
+
|
|
39
|
+
Query parameters
|
|
40
|
+
----------------
|
|
41
|
+
profile : str, optional
|
|
42
|
+
Tenant profile id. Defaults to the currently active profile when
|
|
43
|
+
omitted or empty.
|
|
44
|
+
since : int, optional
|
|
45
|
+
Only rows whose ``revision`` strictly exceeds this value are returned.
|
|
46
|
+
Pass ``0`` (default) to retrieve all rows.
|
|
47
|
+
|
|
48
|
+
Response
|
|
49
|
+
--------
|
|
50
|
+
``{"entries": [...], "node_id": "<local_node_id>"}``
|
|
51
|
+
|
|
52
|
+
Each entry carries ``{key, value, set_by, updated_at, revision, node_id}``
|
|
53
|
+
where ``node_id`` is the effective origin node (resolved from
|
|
54
|
+
``origin_node`` column; BC rows with ``origin_node=''`` resolve to the
|
|
55
|
+
local node's id).
|
|
56
|
+
"""
|
|
57
|
+
broker = _get_broker(request)
|
|
58
|
+
resolved_profile = profile if profile else _active_profile()
|
|
59
|
+
syncer = StateSyncer(broker)
|
|
60
|
+
return {
|
|
61
|
+
"entries": syncer.local_delta(resolved_profile, since),
|
|
62
|
+
"node_id": syncer._node_id,
|
|
63
|
+
}
|
|
@@ -134,7 +134,10 @@ async def dashboard(request: Request):
|
|
|
134
134
|
except Exception:
|
|
135
135
|
pass
|
|
136
136
|
|
|
137
|
-
|
|
137
|
+
from superlocalmemory.core.modes import dashboard_mode_fields
|
|
138
|
+
|
|
139
|
+
# Mode record is the single source of truth for locality claims (F-03).
|
|
140
|
+
payload = {
|
|
138
141
|
"mode": config.mode.value,
|
|
139
142
|
"mode_name": {"a": "Local Guardian", "b": "Smart Local", "c": "Full Power"}.get(config.mode.value, "Unknown"),
|
|
140
143
|
"provider": config.llm.provider or "none",
|
|
@@ -145,6 +148,8 @@ async def dashboard(request: Request):
|
|
|
145
148
|
"base_dir": str(config.base_dir),
|
|
146
149
|
"version": SLM_VERSION,
|
|
147
150
|
}
|
|
151
|
+
payload.update(dashboard_mode_fields(config.mode))
|
|
152
|
+
return payload
|
|
148
153
|
except Exception as e:
|
|
149
154
|
return _internal_error()
|
|
150
155
|
|
|
@@ -589,41 +594,45 @@ def _validate_provider_url(url: str, client_host: str) -> str | None:
|
|
|
589
594
|
are allowed for it. A NON-loopback caller may not make the server fetch
|
|
590
595
|
private/loopback/link-local/reserved targets — that is the SSRF abuse.
|
|
591
596
|
"""
|
|
592
|
-
from
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
return "Only http/https endpoints are supported"
|
|
598
|
-
host = p.hostname or ""
|
|
599
|
-
if host.lower() in ("169.254.169.254", "metadata.google.internal", "metadata"):
|
|
600
|
-
return "Cloud metadata endpoints are not allowed"
|
|
597
|
+
from superlocalmemory.server.egress_policy import (
|
|
598
|
+
EgressActor,
|
|
599
|
+
EgressVerdict,
|
|
600
|
+
validate_egress_url,
|
|
601
|
+
)
|
|
601
602
|
from superlocalmemory.server.loopback import is_loopback as _is_loopback_host
|
|
602
603
|
|
|
603
|
-
|
|
604
|
-
return None # local dashboard may target its own local/LAN endpoints
|
|
604
|
+
is_local = _is_loopback_host(client_host)
|
|
605
605
|
# SLM_REMOTE residue (#40): an allowlisted LAN dashboard is trusted exactly
|
|
606
606
|
# like the loopback one and may probe its own LAN LLM endpoint. This does
|
|
607
607
|
# NOT relax the SSRF guard for arbitrary remote callers —
|
|
608
608
|
# is_lan_client_allowed is False unless remote mode is ON *and* the client
|
|
609
609
|
# IP is in SLM_MCP_ALLOWED_HOSTS.
|
|
610
|
+
is_lan = False
|
|
610
611
|
try:
|
|
611
612
|
from superlocalmemory.core.remote_mode import is_lan_client_allowed
|
|
612
|
-
|
|
613
|
-
return None
|
|
613
|
+
is_lan = bool(is_lan_client_allowed(client_host))
|
|
614
614
|
except Exception: # pragma: no cover — defensive, never weaken on import error
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
if
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
615
|
+
is_lan = False
|
|
616
|
+
|
|
617
|
+
result = validate_egress_url(
|
|
618
|
+
url, EgressActor(is_local=is_local, is_lan=is_lan)
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
if result.verdict is EgressVerdict.ALLOW:
|
|
622
|
+
return None
|
|
623
|
+
if result.verdict is EgressVerdict.DENY_SCHEME:
|
|
624
|
+
return "Only http/https endpoints are supported"
|
|
625
|
+
if result.verdict is EgressVerdict.DENY_METADATA:
|
|
626
|
+
return "Cloud metadata endpoints are not allowed"
|
|
627
|
+
if result.verdict in (
|
|
628
|
+
EgressVerdict.DENY_CREDENTIALS,
|
|
629
|
+
EgressVerdict.DENY_FRAGMENT,
|
|
630
|
+
):
|
|
631
|
+
return "Endpoint URL must not embed credentials or fragments"
|
|
632
|
+
if result.verdict is EgressVerdict.DENY_DNS_FAILURE:
|
|
633
|
+
return "Endpoint host could not be resolved"
|
|
634
|
+
# DENY_PRIVATE / DENY_MIXED_DNS / DENY_HOST
|
|
635
|
+
return "Internal/private endpoints are not allowed from a remote client"
|
|
627
636
|
|
|
628
637
|
|
|
629
638
|
@router.post("/provider/test")
|
|
@@ -820,6 +829,17 @@ async def recall_trace(request: Request):
|
|
|
820
829
|
query = body.get("query", "")
|
|
821
830
|
limit = body.get("limit", 10)
|
|
822
831
|
window = body.get("window", "") or ""
|
|
832
|
+
as_of_raw = (body.get("as_of", "") or "").strip()
|
|
833
|
+
|
|
834
|
+
# Normalize as_of at HTTP boundary. Invalid → 400.
|
|
835
|
+
_as_of: str | None = None
|
|
836
|
+
if as_of_raw:
|
|
837
|
+
from superlocalmemory.retrieval.temporal_utils import normalize_as_of
|
|
838
|
+
_as_of = normalize_as_of(as_of_raw)
|
|
839
|
+
if _as_of is None:
|
|
840
|
+
return JSONResponse(
|
|
841
|
+
{"error": "invalid_as_of", "raw": as_of_raw}, status_code=400
|
|
842
|
+
)
|
|
823
843
|
|
|
824
844
|
# Use daemon engine — already loaded, shares warm page cache.
|
|
825
845
|
# run_in_executor keeps event loop alive so browser doesn't abort.
|
|
@@ -832,7 +852,10 @@ async def recall_trace(request: Request):
|
|
|
832
852
|
t0 = _time.monotonic()
|
|
833
853
|
response = await loop.run_in_executor(
|
|
834
854
|
None,
|
|
835
|
-
lambda: engine.recall(
|
|
855
|
+
lambda: engine.recall(
|
|
856
|
+
query, limit=limit, fast=False,
|
|
857
|
+
window=window or None, as_of=_as_of,
|
|
858
|
+
),
|
|
836
859
|
)
|
|
837
860
|
elapsed_ms = round((_time.monotonic() - t0) * 1000, 1)
|
|
838
861
|
|
|
@@ -42,9 +42,95 @@ class ConnectionManager:
|
|
|
42
42
|
manager = ConnectionManager()
|
|
43
43
|
|
|
44
44
|
|
|
45
|
+
def _ws_origin_allowed(websocket: WebSocket) -> bool:
|
|
46
|
+
"""Return True when the WebSocket upgrade Origin is permitted.
|
|
47
|
+
|
|
48
|
+
Non-browser clients (CLI, MCP tools) carry no Origin header and are
|
|
49
|
+
always allowed. Browser-originated connections must come from a
|
|
50
|
+
loopback address or a host in the configured remote allowlist.
|
|
51
|
+
"""
|
|
52
|
+
origin = websocket.headers.get("origin", "")
|
|
53
|
+
if not origin:
|
|
54
|
+
return True # non-browser caller (CLI/MCP) — no origin to validate
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
from superlocalmemory.server.origin import origin_is_loopback
|
|
58
|
+
if origin_is_loopback(origin):
|
|
59
|
+
return True
|
|
60
|
+
except Exception:
|
|
61
|
+
# If the helper is unavailable, fall through to the remote check.
|
|
62
|
+
if "127.0.0.1" in origin or "::1" in origin or "localhost" in origin:
|
|
63
|
+
return True
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
from superlocalmemory.core.remote_mode import is_remote_origin_allowed
|
|
67
|
+
if is_remote_origin_allowed(origin):
|
|
68
|
+
return True
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
return False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _ws_rbac_allowed(websocket: WebSocket, app_state) -> bool:
|
|
76
|
+
"""Return True when the caller is authorised to open a WS connection.
|
|
77
|
+
|
|
78
|
+
Mirrors _rbac_read_gate in unified_daemon.py: no-op in single-operator
|
|
79
|
+
installations (zero RBAC users), requires a valid session in company /
|
|
80
|
+
require_login mode. Fails closed on RBAC errors.
|
|
81
|
+
"""
|
|
82
|
+
rbac = getattr(app_state, "rbac", None)
|
|
83
|
+
if rbac is None:
|
|
84
|
+
return True # single-operator mode — no auth layer active
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
active = rbac.user_count() > 0
|
|
88
|
+
except Exception:
|
|
89
|
+
logger.warning("ws: RBAC state unavailable — rejecting handshake (fail closed)")
|
|
90
|
+
return False # fail closed
|
|
91
|
+
|
|
92
|
+
if not active:
|
|
93
|
+
return True # no users registered — single-operator mode
|
|
94
|
+
|
|
95
|
+
token = (
|
|
96
|
+
websocket.headers.get("x-slm-user-session", "")
|
|
97
|
+
or websocket.cookies.get("slm_session", "")
|
|
98
|
+
)
|
|
99
|
+
user = rbac.resolve_session(token) if token else None
|
|
100
|
+
|
|
101
|
+
if user is None:
|
|
102
|
+
if rbac.require_login():
|
|
103
|
+
return False # login required and no valid session
|
|
104
|
+
return True # personal / owner mode — no session needed
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
from superlocalmemory.access.rbac import Permission
|
|
108
|
+
from superlocalmemory.server.routes.helpers import get_active_profile
|
|
109
|
+
return rbac.has_permission(user["user_id"], get_active_profile(), Permission.READ)
|
|
110
|
+
except Exception:
|
|
111
|
+
logger.warning("ws: permission check failed — rejecting handshake (fail closed)")
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
|
|
45
115
|
@router.websocket("/ws/updates")
|
|
46
116
|
async def websocket_updates(websocket: WebSocket):
|
|
47
117
|
"""WebSocket endpoint for real-time memory updates."""
|
|
118
|
+
app_state = getattr(websocket, "app", None)
|
|
119
|
+
app_state = getattr(app_state, "state", None)
|
|
120
|
+
|
|
121
|
+
# --- Reject disallowed origins before accepting the handshake. ---
|
|
122
|
+
if not _ws_origin_allowed(websocket):
|
|
123
|
+
logger.info("ws: rejected handshake from disallowed origin %s",
|
|
124
|
+
websocket.headers.get("origin", ""))
|
|
125
|
+
await websocket.close(code=1008) # 1008 = Policy Violation
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
# --- Reject unauthenticated callers when login is required. ---
|
|
129
|
+
if not _ws_rbac_allowed(websocket, app_state):
|
|
130
|
+
logger.info("ws: rejected unauthenticated or unauthorised handshake")
|
|
131
|
+
await websocket.close(code=4001) # 4001 = custom: Unauthorized
|
|
132
|
+
return
|
|
133
|
+
|
|
48
134
|
await manager.connect(websocket)
|
|
49
135
|
|
|
50
136
|
try:
|
|
@@ -72,8 +72,8 @@ UI_DIR = Path(__file__).resolve().parent.parent / "ui"
|
|
|
72
72
|
def create_app() -> FastAPI:
|
|
73
73
|
"""Create and configure the FastAPI application."""
|
|
74
74
|
application = FastAPI(
|
|
75
|
-
title="SuperLocalMemory
|
|
76
|
-
description="Memory Dashboard with
|
|
75
|
+
title="SuperLocalMemory V4 UI Server",
|
|
76
|
+
description="Memory Dashboard with V4 Engine, Trust, Learning, and Compliance",
|
|
77
77
|
version=SLM_VERSION,
|
|
78
78
|
docs_url="/api/docs",
|
|
79
79
|
redoc_url="/api/redoc",
|
|
@@ -224,9 +224,9 @@ def create_app() -> FastAPI:
|
|
|
224
224
|
if not index_path.exists():
|
|
225
225
|
return (
|
|
226
226
|
"<!DOCTYPE html><html><head>"
|
|
227
|
-
"<title>SuperLocalMemory
|
|
227
|
+
"<title>SuperLocalMemory V4</title></head>"
|
|
228
228
|
"<body style='font-family:Arial;padding:40px'>"
|
|
229
|
-
"<h1>SuperLocalMemory
|
|
229
|
+
"<h1>SuperLocalMemory V4 UI Server Running</h1>"
|
|
230
230
|
"<p>UI not found. Check ui/index.html</p>"
|
|
231
231
|
"<p><a href='/api/docs'>API Documentation</a></p>"
|
|
232
232
|
"</body></html>"
|
|
@@ -290,7 +290,7 @@ if __name__ == "__main__":
|
|
|
290
290
|
import argparse
|
|
291
291
|
import socket
|
|
292
292
|
|
|
293
|
-
parser = argparse.ArgumentParser(description="SuperLocalMemory
|
|
293
|
+
parser = argparse.ArgumentParser(description="SuperLocalMemory V4 - Web Dashboard")
|
|
294
294
|
parser.add_argument("--port", type=int, default=8765, help="Port (default 8765)")
|
|
295
295
|
parser.add_argument("--profile", type=str, default=None, help="Memory profile")
|
|
296
296
|
args = parser.parse_args()
|
|
@@ -310,7 +310,7 @@ if __name__ == "__main__":
|
|
|
310
310
|
print(f"\n Port {args.port} in use -- using {ui_port} instead\n")
|
|
311
311
|
|
|
312
312
|
print("=" * 70)
|
|
313
|
-
print(" SuperLocalMemory
|
|
313
|
+
print(" SuperLocalMemory V4 - Web Dashboard")
|
|
314
314
|
print(" Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar")
|
|
315
315
|
print("=" * 70)
|
|
316
316
|
print(f" Database: {DB_PATH}")
|