superlocalmemory 3.7.8 → 3.8.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/.claude-plugin/marketplace.json +1 -1
- package/ATTRIBUTION.md +1 -3
- package/CHANGELOG.md +69 -0
- package/README.md +199 -29
- package/package.json +4 -2
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/CLAUDE.md +8 -8
- package/plugin/agents/slm-governance-advisor.md +80 -0
- package/plugin/agents/slm-loop-runner.md +71 -0
- package/plugin/agents/slm-memory-advisor.md +10 -5
- package/plugin/agents/slm-optimize-advisor.md +9 -3
- package/plugin/commands/slm-loop.md +31 -0
- package/plugin/hooks/hooks.json +79 -0
- package/plugin/requirements.txt +1 -1
- package/plugin/scripts/slm-launch +46 -7
- package/plugin/settings.json +9 -0
- package/plugin/skills/slm-cache/SKILL.md +9 -1
- package/plugin/skills/slm-compress/SKILL.md +8 -1
- package/plugin/skills/slm-governance/SKILL.md +248 -0
- package/plugin/skills/slm-graph/SKILL.md +17 -3
- package/plugin/skills/slm-loop/SKILL.md +99 -0
- package/plugin/skills/slm-mesh/SKILL.md +282 -0
- package/plugin/skills/slm-profile/SKILL.md +148 -0
- package/plugin/skills/slm-recall/SKILL.md +46 -10
- package/plugin/skills/slm-remember/SKILL.md +48 -1
- package/plugin/skills/slm-scope/SKILL.md +176 -0
- package/plugin/skills/slm-session/SKILL.md +24 -1
- package/plugin/skills/slm-status/SKILL.md +18 -1
- package/plugin-src/agents/slm-governance-advisor.md +80 -0
- package/plugin-src/agents/slm-loop-runner.md +71 -0
- package/plugin-src/agents/slm-memory-advisor.md +10 -5
- package/plugin-src/agents/slm-optimize-advisor.md +9 -3
- package/plugin-src/commands/slm-loop.md +31 -0
- package/plugin-src/hooks/hooks.json +79 -0
- package/plugin-src/manifest.json +7 -2
- package/plugin-src/requirements.txt +1 -1
- package/plugin-src/rules/AGENTS.md +57 -18
- package/plugin-src/rules/CLAUDE.md.fragment +8 -8
- package/plugin-src/scripts/slm-launch +46 -7
- package/plugin-src/settings.json +9 -0
- package/plugin-src/skills/slm-cache/SKILL.md +9 -1
- package/plugin-src/skills/slm-compress/SKILL.md +8 -1
- package/plugin-src/skills/slm-governance/SKILL.md +248 -0
- package/plugin-src/skills/slm-graph/SKILL.md +17 -3
- 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 +46 -10
- package/plugin-src/skills/slm-remember/SKILL.md +48 -1
- package/plugin-src/skills/slm-scope/SKILL.md +176 -0
- package/plugin-src/skills/slm-session/SKILL.md +24 -1
- package/plugin-src/skills/slm-status/SKILL.md +18 -1
- package/pyproject.toml +1 -1
- package/scripts/postinstall/validation.js +2 -0
- package/scripts/postinstall-interactive.js +74 -2
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/__init__.py +3 -0
- package/src/superlocalmemory/access/rbac.py +477 -0
- package/src/superlocalmemory/cli/commands.py +94 -10
- package/src/superlocalmemory/cli/compress_cmd.py +17 -7
- package/src/superlocalmemory/cli/loop_cmd.py +192 -0
- package/src/superlocalmemory/cli/main.py +39 -4
- package/src/superlocalmemory/cli/mesh_cmd.py +38 -0
- package/src/superlocalmemory/cli/optimize_cmd.py +3 -0
- package/src/superlocalmemory/cli/pending_store.py +49 -13
- package/src/superlocalmemory/cli/proxy_cmd.py +4 -0
- package/src/superlocalmemory/cli/scale_engine_cmd.py +6 -0
- package/src/superlocalmemory/cli/setup_wizard.py +22 -13
- package/src/superlocalmemory/compliance/audit.py +6 -0
- package/src/superlocalmemory/compliance/gdpr.py +128 -138
- package/src/superlocalmemory/compliance/retention.py +176 -45
- package/src/superlocalmemory/core/backend_orchestrator.py +5 -43
- package/src/superlocalmemory/core/community_summary.py +267 -0
- package/src/superlocalmemory/core/config.py +216 -3
- package/src/superlocalmemory/core/consolidation_engine.py +95 -22
- package/src/superlocalmemory/core/context_cache.py +61 -18
- package/src/superlocalmemory/core/embedding_worker.py +17 -2
- package/src/superlocalmemory/core/embeddings.py +12 -1
- package/src/superlocalmemory/core/engine.py +17 -1
- package/src/superlocalmemory/core/engine_ingestion.py +29 -0
- package/src/superlocalmemory/core/engine_wiring.py +13 -0
- package/src/superlocalmemory/core/entity_community.py +178 -0
- package/src/superlocalmemory/core/graph_analyzer.py +39 -2
- package/src/superlocalmemory/core/graph_pruner.py +13 -8
- package/src/superlocalmemory/core/key_expander.py +138 -0
- package/src/superlocalmemory/core/maintenance.py +23 -0
- package/src/superlocalmemory/core/modes.py +1 -1
- package/src/superlocalmemory/core/mutations.py +2 -2
- package/src/superlocalmemory/core/pii.py +105 -0
- package/src/superlocalmemory/core/progressive_abstraction.py +208 -0
- package/src/superlocalmemory/core/recall_pipeline.py +2 -0
- package/src/superlocalmemory/core/recall_worker.py +20 -6
- package/src/superlocalmemory/core/scale_engine.py +60 -1
- package/src/superlocalmemory/core/security_primitives.py +40 -2
- package/src/superlocalmemory/core/store_pipeline.py +35 -11
- package/src/superlocalmemory/core/worker_pool.py +21 -6
- package/src/superlocalmemory/encoding/entity_reflexion.py +200 -0
- package/src/superlocalmemory/encoding/entity_resolver.py +34 -24
- package/src/superlocalmemory/encoding/fact_extractor.py +26 -1
- package/src/superlocalmemory/encoding/temporal_validator.py +64 -1
- package/src/superlocalmemory/evolution/evolution_store.py +122 -45
- package/src/superlocalmemory/evolution/llm_dispatch.py +12 -1
- package/src/superlocalmemory/evolution/model_selection.py +160 -0
- package/src/superlocalmemory/evolution/mutation_generator.py +16 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +127 -42
- package/src/superlocalmemory/evolution/triggers.py +22 -13
- package/src/superlocalmemory/graph/cozo_backend.py +43 -20
- package/src/superlocalmemory/hooks/adapter_base.py +5 -1
- package/src/superlocalmemory/hooks/auto_recall.py +13 -1
- package/src/superlocalmemory/hooks/claude_code_hooks.py +11 -0
- package/src/superlocalmemory/hooks/codex_assets.py +64 -5
- package/src/superlocalmemory/hooks/hook_daemon.py +20 -3
- package/src/superlocalmemory/hooks/memory_protocol.py +54 -0
- package/src/superlocalmemory/hooks/portable_kit.py +114 -1
- package/src/superlocalmemory/infra/backup.py +12 -1
- package/src/superlocalmemory/infra/daemon_identity.py +40 -4
- package/src/superlocalmemory/infra/data_root.py +43 -4
- package/src/superlocalmemory/infra/event_bus.py +107 -24
- package/src/superlocalmemory/infra/rate_limiter.py +93 -0
- package/src/superlocalmemory/ingestion/adapter_manager.py +4 -1
- package/src/superlocalmemory/ingestion/credentials.py +1 -1
- package/src/superlocalmemory/learning/cross_project.py +28 -19
- package/src/superlocalmemory/learning/reward_proxy.py +42 -9
- package/src/superlocalmemory/loops/__init__.py +56 -0
- package/src/superlocalmemory/loops/budget.py +58 -0
- package/src/superlocalmemory/loops/engine.py +164 -0
- package/src/superlocalmemory/loops/ledger.py +243 -0
- package/src/superlocalmemory/loops/models.py +152 -0
- package/src/superlocalmemory/loops/rules.py +52 -0
- package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
- package/src/superlocalmemory/mcp/_pool_adapter.py +2 -0
- package/src/superlocalmemory/mcp/profiles.py +103 -0
- package/src/superlocalmemory/mcp/server.py +21 -49
- package/src/superlocalmemory/mcp/tools_active.py +4 -7
- package/src/superlocalmemory/mcp/tools_code_graph.py +51 -5
- package/src/superlocalmemory/mcp/tools_core.py +8 -1
- package/src/superlocalmemory/mcp/tools_evolution.py +6 -3
- package/src/superlocalmemory/mcp/tools_loops.py +300 -0
- package/src/superlocalmemory/mcp/tools_mesh.py +140 -4
- package/src/superlocalmemory/mcp/tools_optimize.py +15 -8
- package/src/superlocalmemory/mesh/broker.py +237 -129
- package/src/superlocalmemory/mesh/remote_sync.py +50 -8
- package/src/superlocalmemory/optimize/NOTICE +1 -6
- package/src/superlocalmemory/optimize/adapters/anthropic_adapter.py +1 -4
- package/src/superlocalmemory/optimize/adapters/openai_adapter.py +1 -4
- package/src/superlocalmemory/optimize/cache/semantic.py +27 -19
- package/src/superlocalmemory/optimize/compress/align.py +32 -26
- package/src/superlocalmemory/optimize/compress/ccr.py +14 -71
- package/src/superlocalmemory/optimize/compress/router.py +105 -22
- package/src/superlocalmemory/optimize/config/defaults.py +1 -1
- package/src/superlocalmemory/optimize/config/schema.py +87 -4
- package/src/superlocalmemory/optimize/metrics/counters.py +13 -4
- package/src/superlocalmemory/optimize/metrics/estimator.py +0 -3
- package/src/superlocalmemory/optimize/proxy/_helpers.py +31 -4
- package/src/superlocalmemory/optimize/storage/db.py +38 -9
- package/src/superlocalmemory/optimize/storage/schema.py +10 -0
- package/src/superlocalmemory/parameterization/pattern_extractor.py +6 -3
- package/src/superlocalmemory/retrieval/agentic.py +1 -1
- package/src/superlocalmemory/retrieval/bm25_channel.py +68 -10
- package/src/superlocalmemory/retrieval/engine.py +168 -26
- package/src/superlocalmemory/retrieval/entity_channel.py +7 -5
- package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -2
- package/src/superlocalmemory/retrieval/semantic_channel.py +114 -21
- package/src/superlocalmemory/retrieval/spreading_activation.py +11 -2
- package/src/superlocalmemory/retrieval/temporal_channel.py +48 -9
- package/src/superlocalmemory/retrieval/temporal_frame.py +102 -0
- package/src/superlocalmemory/retrieval/temporal_validity_filter.py +135 -0
- package/src/superlocalmemory/retrieval/time_window.py +181 -0
- package/src/superlocalmemory/server/api.py +4 -4
- package/src/superlocalmemory/server/profile_runtime.py +125 -8
- package/src/superlocalmemory/server/rbac_enforce.py +142 -0
- package/src/superlocalmemory/server/recall_health.py +24 -3
- package/src/superlocalmemory/server/recall_serializer.py +19 -1
- package/src/superlocalmemory/server/routes/abstraction.py +115 -0
- package/src/superlocalmemory/server/routes/agents.py +128 -38
- package/src/superlocalmemory/server/routes/backup.py +34 -10
- package/src/superlocalmemory/server/routes/behavioral.py +13 -12
- package/src/superlocalmemory/server/routes/brain.py +21 -5
- package/src/superlocalmemory/server/routes/chat.py +10 -5
- package/src/superlocalmemory/server/routes/compliance.py +171 -21
- package/src/superlocalmemory/server/routes/config_api.py +436 -0
- package/src/superlocalmemory/server/routes/data_io.py +30 -8
- package/src/superlocalmemory/server/routes/entity.py +9 -4
- package/src/superlocalmemory/server/routes/events.py +24 -8
- package/src/superlocalmemory/server/routes/evolution.py +135 -17
- package/src/superlocalmemory/server/routes/helpers.py +16 -1
- package/src/superlocalmemory/server/routes/ingest.py +7 -4
- package/src/superlocalmemory/server/routes/insights.py +3 -3
- package/src/superlocalmemory/server/routes/learning.py +14 -14
- package/src/superlocalmemory/server/routes/lifecycle.py +59 -8
- package/src/superlocalmemory/server/routes/memories.py +182 -57
- package/src/superlocalmemory/server/routes/mesh.py +95 -15
- package/src/superlocalmemory/server/routes/optimize.py +33 -1
- package/src/superlocalmemory/server/routes/prewarm.py +2 -0
- package/src/superlocalmemory/server/routes/profiles.py +63 -17
- package/src/superlocalmemory/server/routes/ratelimit.py +124 -0
- package/src/superlocalmemory/server/routes/rbac.py +367 -0
- package/src/superlocalmemory/server/routes/stats.py +13 -6
- package/src/superlocalmemory/server/routes/tiers.py +11 -9
- package/src/superlocalmemory/server/routes/v3_api.py +183 -69
- package/src/superlocalmemory/server/routes/ws.py +5 -2
- package/src/superlocalmemory/server/security_middleware.py +12 -5
- package/src/superlocalmemory/server/ui.py +20 -5
- package/src/superlocalmemory/server/unified_daemon.py +384 -56
- package/src/superlocalmemory/server/write_identity.py +38 -8
- package/src/superlocalmemory/storage/database.py +265 -53
- package/src/superlocalmemory/storage/migration_runner.py +53 -0
- package/src/superlocalmemory/storage/migrations/M021_ingestion_log_profile.py +108 -0
- package/src/superlocalmemory/storage/migrations/M022_entity_aliases_profile.py +86 -0
- package/src/superlocalmemory/storage/migrations/M023_mesh_profile_isolation.py +194 -0
- package/src/superlocalmemory/storage/migrations/M024_rbac_users_roles.py +87 -0
- package/src/superlocalmemory/storage/migrations/M025_perf_indexes.py +90 -0
- package/src/superlocalmemory/storage/migrations/M026_rbac_memberships_fk.py +136 -0
- package/src/superlocalmemory/storage/migrations/M027_transferable_patterns_profile.py +163 -0
- package/src/superlocalmemory/storage/models.py +4 -0
- package/src/superlocalmemory/storage/schema.py +87 -0
- package/src/superlocalmemory/storage/schema_v343.py +24 -12
- package/src/superlocalmemory/trust/gate.py +49 -8
- package/src/superlocalmemory/ui/assets/slm-icon-white.svg +64 -0
- package/src/superlocalmemory/ui/assets/slm-icon.svg +36 -0
- package/src/superlocalmemory/ui/css/design-system.css +621 -0
- package/src/superlocalmemory/ui/css/neural-glass.css +6 -0
- package/src/superlocalmemory/ui/css/od-bridge.css +158 -0
- package/src/superlocalmemory/ui/favicon.svg +35 -4
- package/src/superlocalmemory/ui/index.html +306 -173
- package/src/superlocalmemory/ui/js/brain.js +5 -20
- package/src/superlocalmemory/ui/js/core.js +47 -31
- package/src/superlocalmemory/ui/js/dashboard.js +314 -63
- package/src/superlocalmemory/ui/js/event-delegation.js +102 -0
- package/src/superlocalmemory/ui/js/knowledge-graph.js +11 -11
- package/src/superlocalmemory/ui/js/math-health.js +1 -1
- package/src/superlocalmemory/ui/js/memories.js +15 -4
- package/src/superlocalmemory/ui/js/memory-chat.js +7 -7
- package/src/superlocalmemory/ui/js/ng-entities.js +6 -8
- package/src/superlocalmemory/ui/js/ng-ingestion.js +4 -4
- package/src/superlocalmemory/ui/js/ng-mesh.js +4 -9
- package/src/superlocalmemory/ui/js/ng-shell.js +8 -8
- package/src/superlocalmemory/ui/js/ng-skills.js +54 -2
- package/src/superlocalmemory/ui/js/od-agents.js +544 -0
- package/src/superlocalmemory/ui/js/od-auth-gate.js +257 -0
- package/src/superlocalmemory/ui/js/od-backup.js +780 -0
- package/src/superlocalmemory/ui/js/od-brain.js +779 -0
- package/src/superlocalmemory/ui/js/od-entities.js +579 -0
- package/src/superlocalmemory/ui/js/od-graph.js +593 -0
- package/src/superlocalmemory/ui/js/od-health.js +539 -0
- package/src/superlocalmemory/ui/js/od-mcp.js +508 -0
- package/src/superlocalmemory/ui/js/od-memories.js +887 -0
- package/src/superlocalmemory/ui/js/od-mesh.js +539 -0
- package/src/superlocalmemory/ui/js/od-operations.js +1250 -0
- package/src/superlocalmemory/ui/js/od-optimize.js +787 -0
- package/src/superlocalmemory/ui/js/od-settings.js +1053 -0
- package/src/superlocalmemory/ui/js/od-shell.js +593 -0
- package/src/superlocalmemory/ui/js/od-skills.js +573 -0
- package/src/superlocalmemory/ui/js/od-team.js +258 -0
- package/src/superlocalmemory/ui/js/profiles.js +159 -46
- package/src/superlocalmemory/ui/js/settings.js +2 -2
- package/src/superlocalmemory/ui/js/timeline.js +34 -5
- package/src/superlocalmemory/ui/js/trust-dashboard.js +2 -2
- package/src/superlocalmemory/vector/lancedb_backend.py +8 -6
- package/src/superlocalmemory/learning/behavioral_listener.py +0 -94
|
@@ -6,6 +6,7 @@ import json
|
|
|
6
6
|
import os
|
|
7
7
|
import tempfile
|
|
8
8
|
import threading
|
|
9
|
+
import time as _time
|
|
9
10
|
from contextlib import contextmanager
|
|
10
11
|
from contextvars import ContextVar
|
|
11
12
|
from dataclasses import dataclass
|
|
@@ -20,6 +21,20 @@ _REQUEST_PROFILE: ContextVar[str | None] = ContextVar(
|
|
|
20
21
|
"slm_request_profile", default=None,
|
|
21
22
|
)
|
|
22
23
|
|
|
24
|
+
# Maximum seconds to wait for in-flight operations to drain before a
|
|
25
|
+
# profile switch or engine reconfigure is aborted with HTTP 503.
|
|
26
|
+
# Tests can monkeypatch this module-level value to keep runs fast.
|
|
27
|
+
_DRAIN_TIMEOUT_SECS: float = 5.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TransitionDrainTimeout(Exception):
|
|
31
|
+
"""Raised when in-flight operations do not drain within _DRAIN_TIMEOUT_SECS.
|
|
32
|
+
|
|
33
|
+
The caller (HTTP switch route) maps this to HTTP 503 so the client
|
|
34
|
+
receives a clear error instead of a silent hang. After the timeout,
|
|
35
|
+
`_transitioning` is always reset to False so the daemon stays responsive.
|
|
36
|
+
"""
|
|
37
|
+
|
|
23
38
|
|
|
24
39
|
def current_request_profile() -> str | None:
|
|
25
40
|
"""Return the immutable profile snapshot admitted for this request."""
|
|
@@ -61,6 +76,18 @@ class ProfileRuntime:
|
|
|
61
76
|
self._active_operations += 1
|
|
62
77
|
return self._snapshot
|
|
63
78
|
|
|
79
|
+
def _try_acquire_operation_nowait(self) -> ProfileSnapshot | None:
|
|
80
|
+
"""Non-blocking acquire: returns None immediately when a transition is pending.
|
|
81
|
+
|
|
82
|
+
Used by cooperative background maintenance tasks so they never block the
|
|
83
|
+
drain window of a pending profile switch.
|
|
84
|
+
"""
|
|
85
|
+
with self._condition:
|
|
86
|
+
if self._transitioning:
|
|
87
|
+
return None
|
|
88
|
+
self._active_operations += 1
|
|
89
|
+
return self._snapshot
|
|
90
|
+
|
|
64
91
|
def release_operation(self) -> None:
|
|
65
92
|
with self._condition:
|
|
66
93
|
if self._active_operations <= 0:
|
|
@@ -77,20 +104,64 @@ class ProfileRuntime:
|
|
|
77
104
|
finally:
|
|
78
105
|
self.release_operation()
|
|
79
106
|
|
|
107
|
+
@contextmanager
|
|
108
|
+
def operation_nowait(self) -> Iterator[ProfileSnapshot | None]:
|
|
109
|
+
"""Cooperatively skip when a profile transition is pending.
|
|
110
|
+
|
|
111
|
+
Background maintenance tasks that do NOT mutate profile-scoped engine
|
|
112
|
+
state (cache warmup, health probes) MUST use this instead of
|
|
113
|
+
``operation()`` so they never hold the drain window hostage.
|
|
114
|
+
|
|
115
|
+
Yields ``None`` (preempted — skip this work cycle) when a switch is
|
|
116
|
+
already in progress. Yields a :class:`ProfileSnapshot` and holds the
|
|
117
|
+
lease normally when no transition is pending.
|
|
118
|
+
|
|
119
|
+
The caller is responsible for checking ``if snap is None: return``.
|
|
120
|
+
|
|
121
|
+
Unlike ``operation()``, this context manager never blocks — it either
|
|
122
|
+
admits immediately or preempts immediately.
|
|
123
|
+
"""
|
|
124
|
+
snapshot = self._try_acquire_operation_nowait()
|
|
125
|
+
acquired = snapshot is not None
|
|
126
|
+
try:
|
|
127
|
+
yield snapshot
|
|
128
|
+
finally:
|
|
129
|
+
if acquired:
|
|
130
|
+
self.release_operation()
|
|
131
|
+
|
|
80
132
|
def transition(
|
|
81
133
|
self,
|
|
82
134
|
target_profile: str,
|
|
83
135
|
commit: Callable[[ProfileSnapshot, str], None],
|
|
84
136
|
) -> ProfileSnapshot:
|
|
85
|
-
"""Drain admitted operations, commit, then publish a new generation.
|
|
137
|
+
"""Drain admitted operations, commit, then publish a new generation.
|
|
138
|
+
|
|
139
|
+
Raises TransitionDrainTimeout if in-flight operations do not drain
|
|
140
|
+
within _DRAIN_TIMEOUT_SECS. The flag is always reset on timeout so
|
|
141
|
+
the daemon remains responsive (no permanent _transitioning=True wedge).
|
|
142
|
+
"""
|
|
143
|
+
deadline = _time.monotonic() + _DRAIN_TIMEOUT_SECS
|
|
86
144
|
with self._condition:
|
|
87
145
|
while self._transitioning:
|
|
88
146
|
self._condition.wait()
|
|
89
147
|
if target_profile == self._snapshot.profile_id:
|
|
90
148
|
return self._snapshot
|
|
91
149
|
self._transitioning = True
|
|
92
|
-
while self._active_operations:
|
|
93
|
-
|
|
150
|
+
while self._active_operations > 0:
|
|
151
|
+
remaining = deadline - _time.monotonic()
|
|
152
|
+
if remaining <= 0:
|
|
153
|
+
# Reset before raising — never leave the daemon wedged.
|
|
154
|
+
self._transitioning = False
|
|
155
|
+
self._condition.notify_all()
|
|
156
|
+
raise TransitionDrainTimeout(
|
|
157
|
+
f"Profile switch to '{target_profile}' timed out after "
|
|
158
|
+
f"{_DRAIN_TIMEOUT_SECS:.0f}s: {self._active_operations} "
|
|
159
|
+
"in-flight operation(s) did not drain. "
|
|
160
|
+
"Try again when no active requests are in progress."
|
|
161
|
+
)
|
|
162
|
+
# Use short sleep slices so we react quickly to notifications
|
|
163
|
+
# and to timeout expiry without busy-spinning.
|
|
164
|
+
self._condition.wait(timeout=min(remaining, 0.25))
|
|
94
165
|
previous = self._snapshot
|
|
95
166
|
|
|
96
167
|
try:
|
|
@@ -111,13 +182,26 @@ class ProfileRuntime:
|
|
|
111
182
|
return self._snapshot
|
|
112
183
|
|
|
113
184
|
def reconfigure(self, commit: Callable[[ProfileSnapshot], None]) -> ProfileSnapshot:
|
|
114
|
-
"""Run a same-profile engine transition behind the operation barrier.
|
|
185
|
+
"""Run a same-profile engine transition behind the operation barrier.
|
|
186
|
+
|
|
187
|
+
Raises TransitionDrainTimeout if in-flight operations do not drain
|
|
188
|
+
within _DRAIN_TIMEOUT_SECS (same semantics as transition()).
|
|
189
|
+
"""
|
|
190
|
+
deadline = _time.monotonic() + _DRAIN_TIMEOUT_SECS
|
|
115
191
|
with self._condition:
|
|
116
192
|
while self._transitioning:
|
|
117
193
|
self._condition.wait()
|
|
118
194
|
self._transitioning = True
|
|
119
|
-
while self._active_operations:
|
|
120
|
-
|
|
195
|
+
while self._active_operations > 0:
|
|
196
|
+
remaining = deadline - _time.monotonic()
|
|
197
|
+
if remaining <= 0:
|
|
198
|
+
self._transitioning = False
|
|
199
|
+
self._condition.notify_all()
|
|
200
|
+
raise TransitionDrainTimeout(
|
|
201
|
+
f"Engine reconfigure timed out after {_DRAIN_TIMEOUT_SECS:.0f}s: "
|
|
202
|
+
f"{self._active_operations} in-flight operation(s) did not drain."
|
|
203
|
+
)
|
|
204
|
+
self._condition.wait(timeout=min(remaining, 0.25))
|
|
121
205
|
snapshot = self._snapshot
|
|
122
206
|
|
|
123
207
|
try:
|
|
@@ -362,12 +446,44 @@ class ProfileRuntimeMiddleware:
|
|
|
362
446
|
raise
|
|
363
447
|
scope.setdefault("state", {})["profile_snapshot"] = snapshot
|
|
364
448
|
token = _REQUEST_PROFILE.set(snapshot.profile_id)
|
|
449
|
+
|
|
450
|
+
# The operation lease guards the SYNCHRONOUS route work that produces
|
|
451
|
+
# the response — not the streaming of its body. A long-lived SSE body
|
|
452
|
+
# would otherwise hold the lease for its whole lifetime and make every
|
|
453
|
+
# profile switch time out during drain (HTTP 503):
|
|
454
|
+
# * /events/stream runs `while True: await sleep(1)` FOREVER while a
|
|
455
|
+
# dashboard tab is open;
|
|
456
|
+
# * /api/v3/chat/stream streams LLM tokens for up to 120s.
|
|
457
|
+
# Release the lease the instant the response starts. By then the route
|
|
458
|
+
# handler has finished its engine work and returned its Response
|
|
459
|
+
# (FastAPI buffers normal responses before sending response.start).
|
|
460
|
+
# Streaming generators that still need the engine (chat recall) already
|
|
461
|
+
# re-acquire their own short-lived lease internally, and /events/stream
|
|
462
|
+
# only reads the EventBus DB — neither depends on this outer lease.
|
|
463
|
+
released = False
|
|
464
|
+
|
|
465
|
+
def _release_lease_once() -> None:
|
|
466
|
+
# Single-task coroutine chain — a plain flag is sufficient; the
|
|
467
|
+
# underlying release_operation() is lock-guarded and idempotent-safe
|
|
468
|
+
# only via this guard, so never call it twice.
|
|
469
|
+
nonlocal released
|
|
470
|
+
if not released:
|
|
471
|
+
released = True
|
|
472
|
+
runtime.release_operation()
|
|
473
|
+
|
|
474
|
+
async def _send(message) -> None:
|
|
475
|
+
if message.get("type") == "http.response.start":
|
|
476
|
+
_release_lease_once()
|
|
477
|
+
await send(message)
|
|
478
|
+
|
|
365
479
|
try:
|
|
366
|
-
await self._app(scope, receive,
|
|
480
|
+
await self._app(scope, receive, _send)
|
|
367
481
|
finally:
|
|
368
482
|
_REQUEST_PROFILE.reset(token)
|
|
483
|
+
# Safety net: a request that errors before emitting response.start
|
|
484
|
+
# (or an ASGI app that never sends one) must still release its lease.
|
|
369
485
|
# Release is lock-only and must not itself be cancellation-prone.
|
|
370
|
-
|
|
486
|
+
_release_lease_once()
|
|
371
487
|
|
|
372
488
|
|
|
373
489
|
__all__ = [
|
|
@@ -375,6 +491,7 @@ __all__ = [
|
|
|
375
491
|
"ProfileRuntime",
|
|
376
492
|
"ProfileRuntimeMiddleware",
|
|
377
493
|
"ProfileSnapshot",
|
|
494
|
+
"TransitionDrainTimeout",
|
|
378
495
|
"bind_profile_runtime",
|
|
379
496
|
"commit_daemon_profile_switch",
|
|
380
497
|
"current_request_profile",
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 — RBAC / teams (C3)
|
|
4
|
+
|
|
5
|
+
"""RBAC enforcement boundary for HTTP routes.
|
|
6
|
+
|
|
7
|
+
This is the single place that turns "who is calling" + "what are they trying to
|
|
8
|
+
do" into an allow/deny decision, on top of the existing machine-auth layer
|
|
9
|
+
(write_identity). It is deliberately small so every mutation route calls the
|
|
10
|
+
same code path — the research warning was explicit: an RBAC layer that is
|
|
11
|
+
defined but not consistently called is worse than none.
|
|
12
|
+
|
|
13
|
+
Principal model
|
|
14
|
+
---------------
|
|
15
|
+
* **user** — a logged-in dashboard user (valid session token). Always enforced
|
|
16
|
+
against their role on the active profile.
|
|
17
|
+
* **owner** — the machine operator (already proved machine auth via
|
|
18
|
+
write_identity; no user session). In personal mode the owner bypasses RBAC
|
|
19
|
+
(all permissions). When the org turns on *require_login* (company mode) the
|
|
20
|
+
owner bypass is disabled and a user session is mandatory.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from fastapi import HTTPException, Request
|
|
28
|
+
|
|
29
|
+
from superlocalmemory.access.rbac import Permission, Role, permissions_for_role
|
|
30
|
+
|
|
31
|
+
_SESSION_HEADER = "X-SLM-User-Session"
|
|
32
|
+
_SESSION_COOKIE = "slm_session"
|
|
33
|
+
|
|
34
|
+
OWNER_PRINCIPAL = {
|
|
35
|
+
"kind": "owner",
|
|
36
|
+
"user_id": "owner",
|
|
37
|
+
"username": "owner",
|
|
38
|
+
"display_name": "Machine Owner",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def get_rbac_engine(app_state: Any) -> Any | None:
|
|
43
|
+
return getattr(app_state, "rbac", None)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _session_token(request: Request) -> str:
|
|
47
|
+
tok = request.headers.get(_SESSION_HEADER, "")
|
|
48
|
+
if tok:
|
|
49
|
+
return tok
|
|
50
|
+
try:
|
|
51
|
+
return request.cookies.get(_SESSION_COOKIE, "") or ""
|
|
52
|
+
except Exception:
|
|
53
|
+
return ""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def resolve_principal(request: Request) -> dict:
|
|
57
|
+
"""Resolve the caller to a user (valid session) or the machine owner."""
|
|
58
|
+
rbac = get_rbac_engine(request.app.state)
|
|
59
|
+
token = _session_token(request)
|
|
60
|
+
if rbac is not None and token:
|
|
61
|
+
user = rbac.resolve_session(token)
|
|
62
|
+
if user:
|
|
63
|
+
return {"kind": "user", **user}
|
|
64
|
+
return dict(OWNER_PRINCIPAL)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _active_profile() -> str:
|
|
68
|
+
from superlocalmemory.server.routes.helpers import get_active_profile
|
|
69
|
+
|
|
70
|
+
return get_active_profile()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def require_permission(
|
|
74
|
+
request: Request,
|
|
75
|
+
permission: Permission,
|
|
76
|
+
*,
|
|
77
|
+
profile: str | None = None,
|
|
78
|
+
) -> dict:
|
|
79
|
+
"""Authorize ``permission`` on ``profile`` (default: active profile).
|
|
80
|
+
|
|
81
|
+
Returns the principal on success. Raises 401 when a login is required but
|
|
82
|
+
absent, or 403 when the user's role does not grant the permission.
|
|
83
|
+
"""
|
|
84
|
+
rbac = get_rbac_engine(request.app.state)
|
|
85
|
+
principal = resolve_principal(request)
|
|
86
|
+
require_login = bool(rbac is not None and rbac.require_login())
|
|
87
|
+
prof = profile or _active_profile()
|
|
88
|
+
|
|
89
|
+
if principal["kind"] == "owner":
|
|
90
|
+
# The machine operator is root — they always retain MANAGE (they have
|
|
91
|
+
# shell access to the box regardless), so company mode can never lock
|
|
92
|
+
# administration out of the dashboard. require_login only gates the
|
|
93
|
+
# owner's DATA operations, forcing per-user login for read/write/etc.
|
|
94
|
+
if require_login and permission != Permission.MANAGE:
|
|
95
|
+
raise HTTPException(
|
|
96
|
+
401,
|
|
97
|
+
detail="Login required: this workspace enforces per-user access.",
|
|
98
|
+
)
|
|
99
|
+
return principal # personal mode — operator is owner
|
|
100
|
+
|
|
101
|
+
# Logged-in user: always enforced against their role.
|
|
102
|
+
if rbac is not None and rbac.has_permission(principal["user_id"], prof, permission):
|
|
103
|
+
return principal
|
|
104
|
+
raise HTTPException(
|
|
105
|
+
403,
|
|
106
|
+
detail=(
|
|
107
|
+
f"Your role does not allow '{permission.value}' on this workspace."
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def require_manage(request: Request, *, profile: str | None = None) -> dict:
|
|
113
|
+
"""Guard for user/role administration (MANAGE permission)."""
|
|
114
|
+
return require_permission(request, Permission.MANAGE, profile=profile)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def principal_info(request: Request) -> dict:
|
|
118
|
+
"""Rich identity for /whoami: principal + role + effective permissions on
|
|
119
|
+
the active profile. Never raises — used by the dashboard to render UI."""
|
|
120
|
+
rbac = get_rbac_engine(request.app.state)
|
|
121
|
+
principal = resolve_principal(request)
|
|
122
|
+
prof = _active_profile()
|
|
123
|
+
info = {
|
|
124
|
+
"kind": principal["kind"],
|
|
125
|
+
"user_id": principal["user_id"],
|
|
126
|
+
"username": principal["username"],
|
|
127
|
+
"display_name": principal.get("display_name", principal["username"]),
|
|
128
|
+
"profile": prof,
|
|
129
|
+
"rbac_active": bool(rbac is not None and rbac.user_count() > 0),
|
|
130
|
+
"require_login": bool(rbac is not None and rbac.require_login()),
|
|
131
|
+
}
|
|
132
|
+
if principal["kind"] == "owner":
|
|
133
|
+
# Owner has every permission (personal mode) unless login is required.
|
|
134
|
+
info["role"] = "owner"
|
|
135
|
+
info["permissions"] = [p.value for p in Permission]
|
|
136
|
+
return info
|
|
137
|
+
role = rbac.get_role(principal["user_id"], prof) if rbac is not None else None
|
|
138
|
+
info["role"] = role.value if role else None
|
|
139
|
+
info["permissions"] = (
|
|
140
|
+
[p.value for p in permissions_for_role(role)] if role else []
|
|
141
|
+
)
|
|
142
|
+
return info
|
|
@@ -124,15 +124,36 @@ def run_health_tick(engine, state: RecallHealth, *, probe: str = DEFAULT_PROBE,
|
|
|
124
124
|
|
|
125
125
|
Mutates and returns ``state``. Never raises — a timed-out / failing recall
|
|
126
126
|
marks the path unhealthy instead of propagating.
|
|
127
|
+
|
|
128
|
+
Uses ``operation_nowait()`` when a runtime is supplied so that a pending
|
|
129
|
+
profile switch is not blocked by the health-probe recall. If a transition
|
|
130
|
+
is in progress the tick is skipped entirely — the next scheduled tick will
|
|
131
|
+
re-warm once the switch has committed.
|
|
127
132
|
"""
|
|
128
133
|
state.checks += 1
|
|
129
134
|
|
|
130
135
|
# Tier 1: re-warm. A real full-fusion recall keeps the graph page cache hot
|
|
131
136
|
# and the embedder resident.
|
|
137
|
+
# Cooperative preemption: use operation_nowait() so a pending profile switch
|
|
138
|
+
# is not held hostage by a slow full-fusion recall (2–10s with fast=False).
|
|
132
139
|
try:
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
140
|
+
if runtime is not None:
|
|
141
|
+
lease = runtime.operation_nowait()
|
|
142
|
+
else:
|
|
143
|
+
lease = nullcontext()
|
|
144
|
+
with lease as _snap:
|
|
145
|
+
if runtime is not None and _snap is None:
|
|
146
|
+
# A profile transition is in progress — skip this tick so we
|
|
147
|
+
# do not hold the drain window. Health state is unchanged;
|
|
148
|
+
# the next tick fires after the switch commits.
|
|
149
|
+
log.debug(
|
|
150
|
+
"recall-health: tick skipped — profile transition in progress"
|
|
151
|
+
)
|
|
152
|
+
return state
|
|
153
|
+
# fast=True: a health probe must release its operation lease well
|
|
154
|
+
# within the 5s profile-switch drain window (fast=False is 2-10s and
|
|
155
|
+
# would make every profile switch time out while a tick is in flight).
|
|
156
|
+
resp = engine.recall(probe, limit=3, fast=True)
|
|
136
157
|
except Exception as exc:
|
|
137
158
|
state.healthy = False
|
|
138
159
|
state.consecutive_failures += 1
|
|
@@ -24,6 +24,7 @@ import re
|
|
|
24
24
|
from typing import Any
|
|
25
25
|
|
|
26
26
|
from superlocalmemory.core.config import CANONICAL_RECALL_LIMIT
|
|
27
|
+
from superlocalmemory.retrieval.temporal_frame import relative_age, temporal_frame
|
|
27
28
|
|
|
28
29
|
|
|
29
30
|
# ---------------------------------------------------------------------------
|
|
@@ -190,9 +191,13 @@ def serialize_recall_response(
|
|
|
190
191
|
is the evidence-floor signal lifted from the response (additive).
|
|
191
192
|
"""
|
|
192
193
|
memory_map = memory_map or {}
|
|
194
|
+
# T-inject: one shared "now" so every result's age label is consistent.
|
|
195
|
+
from datetime import datetime as _dt, timezone as _tz
|
|
196
|
+
_now = _dt.now(_tz.utc)
|
|
193
197
|
raw: list[dict] = []
|
|
194
198
|
for r in (response.results or [])[:limit]:
|
|
195
199
|
fact = r.fact
|
|
200
|
+
_created = getattr(fact, "created_at", "") or ""
|
|
196
201
|
fact_type = getattr(fact, "fact_type", None)
|
|
197
202
|
lifecycle = getattr(fact, "lifecycle", None)
|
|
198
203
|
raw.append({
|
|
@@ -225,7 +230,10 @@ def serialize_recall_response(
|
|
|
225
230
|
if lifecycle is not None and hasattr(lifecycle, "value")
|
|
226
231
|
else (lifecycle or ""),
|
|
227
232
|
"access_count": getattr(fact, "access_count", 0),
|
|
228
|
-
"created_at":
|
|
233
|
+
"created_at": _created,
|
|
234
|
+
# T-inject: human-relative age so consumers (and the LLM) can
|
|
235
|
+
# weigh recency without doing date math. "" when undated.
|
|
236
|
+
"age_label": relative_age(_created, _now),
|
|
229
237
|
"evidence_chain": list(getattr(r, "evidence_chain", []) or []),
|
|
230
238
|
})
|
|
231
239
|
|
|
@@ -241,6 +249,12 @@ def serialize_recall_response(
|
|
|
241
249
|
|
|
242
250
|
def recall_response_metadata(response: Any) -> dict:
|
|
243
251
|
"""Return Score Contract v2 response metadata for transport envelopes."""
|
|
252
|
+
# T-inject: a one-line temporal frame anchoring the result set to "now"
|
|
253
|
+
# and its age span, so time-blind LLMs get an explicit recency signal.
|
|
254
|
+
_timestamps = [
|
|
255
|
+
getattr(getattr(r, "fact", None), "created_at", "") or ""
|
|
256
|
+
for r in (getattr(response, "results", None) or [])
|
|
257
|
+
]
|
|
244
258
|
return {
|
|
245
259
|
"score_contract_version": getattr(response, "score_contract_version", "2"),
|
|
246
260
|
"calibration_status": getattr(response, "calibration_status", "uncalibrated"),
|
|
@@ -248,4 +262,8 @@ def recall_response_metadata(response: Any) -> dict:
|
|
|
248
262
|
"answer_confidence": getattr(response, "answer_confidence", None),
|
|
249
263
|
"abstained": bool(getattr(response, "abstained", False)),
|
|
250
264
|
"abstention_reason": getattr(response, "abstention_reason", None),
|
|
265
|
+
"temporal_frame": temporal_frame(_timestamps),
|
|
266
|
+
# Q2b: thematic community summary (pure pass-through; computed upstream
|
|
267
|
+
# in the engine where DB access is available). None on most recalls.
|
|
268
|
+
"thematic_context": getattr(response, "community_context", None),
|
|
251
269
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later — see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com
|
|
4
|
+
|
|
5
|
+
"""Progressive-abstraction read API (Wave Q3).
|
|
6
|
+
|
|
7
|
+
Exposes the abstraction hierarchy so the dashboard can browse it and drill
|
|
8
|
+
down to source atoms:
|
|
9
|
+
|
|
10
|
+
GET /api/v3/abstraction/persona — the per-profile persona roll-up
|
|
11
|
+
GET /api/v3/abstraction/communities — community summaries (Q2)
|
|
12
|
+
GET /api/v3/abstraction/sources — drill-down (node -> source atoms)
|
|
13
|
+
|
|
14
|
+
Read-only, profile-scoped (Rule 01), direct sqlite3 (Rule 06). All handlers
|
|
15
|
+
fail-soft: a missing DB or table returns an empty payload, never a 500.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
import sqlite3
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from fastapi import APIRouter, Query
|
|
25
|
+
from fastapi.responses import JSONResponse
|
|
26
|
+
|
|
27
|
+
from superlocalmemory.server.routes.helpers import DB_PATH, get_active_profile
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
router = APIRouter(prefix="/api/v3/abstraction", tags=["abstraction"])
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _ReadDB:
|
|
35
|
+
"""Adapt a raw sqlite3 connection to the .execute(...) -> list contract
|
|
36
|
+
the read-only builder methods expect (matches DatabaseManager.execute)."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, conn: sqlite3.Connection) -> None:
|
|
39
|
+
self._conn = conn
|
|
40
|
+
|
|
41
|
+
def execute(self, sql: str, params: tuple = ()) -> list:
|
|
42
|
+
return self._conn.execute(sql, params).fetchall()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _conn() -> sqlite3.Connection | None:
|
|
46
|
+
if not DB_PATH.exists():
|
|
47
|
+
return None
|
|
48
|
+
conn = sqlite3.connect(str(DB_PATH))
|
|
49
|
+
conn.row_factory = sqlite3.Row
|
|
50
|
+
return conn
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@router.get("/persona")
|
|
54
|
+
def get_persona(profile: str = Query("")) -> JSONResponse:
|
|
55
|
+
pid = profile or get_active_profile()
|
|
56
|
+
conn = _conn()
|
|
57
|
+
if conn is None:
|
|
58
|
+
return JSONResponse({"profile": pid, "persona": None})
|
|
59
|
+
try:
|
|
60
|
+
from superlocalmemory.core.progressive_abstraction import ProgressiveAbstraction
|
|
61
|
+
|
|
62
|
+
persona = ProgressiveAbstraction(_ReadDB(conn)).get_persona(pid)
|
|
63
|
+
return JSONResponse({"profile": pid, "persona": persona})
|
|
64
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
65
|
+
logger.debug("persona read failed: %s", exc)
|
|
66
|
+
return JSONResponse({"profile": pid, "persona": None})
|
|
67
|
+
finally:
|
|
68
|
+
conn.close()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@router.get("/communities")
|
|
72
|
+
def get_communities(profile: str = Query("")) -> JSONResponse:
|
|
73
|
+
pid = profile or get_active_profile()
|
|
74
|
+
conn = _conn()
|
|
75
|
+
if conn is None:
|
|
76
|
+
return JSONResponse({"profile": pid, "communities": []})
|
|
77
|
+
try:
|
|
78
|
+
from superlocalmemory.core.community_summary import CommunitySummaryBuilder
|
|
79
|
+
|
|
80
|
+
summaries = CommunitySummaryBuilder(_ReadDB(conn)).get_summaries(pid)
|
|
81
|
+
return JSONResponse({"profile": pid, "communities": summaries})
|
|
82
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
83
|
+
logger.debug("communities read failed: %s", exc)
|
|
84
|
+
return JSONResponse({"profile": pid, "communities": []})
|
|
85
|
+
finally:
|
|
86
|
+
conn.close()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@router.get("/sources")
|
|
90
|
+
def get_sources(
|
|
91
|
+
profile: str = Query(""), node: str = Query("persona"),
|
|
92
|
+
) -> JSONResponse:
|
|
93
|
+
pid = profile or get_active_profile()
|
|
94
|
+
conn = _conn()
|
|
95
|
+
empty: dict[str, Any] = {
|
|
96
|
+
"node_id": node, "node_type": "unknown", "communities": [], "fact_ids": [],
|
|
97
|
+
}
|
|
98
|
+
if conn is None:
|
|
99
|
+
return JSONResponse({"profile": pid, "sources": empty})
|
|
100
|
+
try:
|
|
101
|
+
from superlocalmemory.core.progressive_abstraction import ProgressiveAbstraction
|
|
102
|
+
|
|
103
|
+
node_val: Any = node
|
|
104
|
+
if node != "persona":
|
|
105
|
+
try:
|
|
106
|
+
node_val = int(node)
|
|
107
|
+
except (ValueError, TypeError):
|
|
108
|
+
node_val = node
|
|
109
|
+
sources = ProgressiveAbstraction(_ReadDB(conn)).get_sources(pid, node_val)
|
|
110
|
+
return JSONResponse({"profile": pid, "sources": sources})
|
|
111
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
112
|
+
logger.debug("sources read failed: %s", exc)
|
|
113
|
+
return JSONResponse({"profile": pid, "sources": empty})
|
|
114
|
+
finally:
|
|
115
|
+
conn.close()
|