superlocalmemory 3.7.8 → 3.8.1
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 +129 -0
- package/README.md +205 -123
- package/package.json +12 -3
- package/plugin/.claude-plugin/plugin.json +2 -3
- 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/rules/AGENTS.md +57 -18
- 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-graph/SKILL.md +17 -3
- 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-session/SKILL.md +24 -1
- package/plugin-src/skills/slm-status/SKILL.md +18 -1
- package/pyproject.toml +2 -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 +228 -17
- package/src/superlocalmemory/cli/compress_cmd.py +17 -7
- package/src/superlocalmemory/cli/daemon.py +7 -0
- package/src/superlocalmemory/cli/loop_cmd.py +187 -0
- package/src/superlocalmemory/cli/main.py +49 -8
- 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/cli/version_banner.py +17 -3
- 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 +23 -59
- 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 +21 -7
- package/src/superlocalmemory/core/embeddings.py +131 -46
- package/src/superlocalmemory/core/engine.py +41 -22
- package/src/superlocalmemory/core/engine_ingestion.py +359 -43
- 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/ingestion_command.py +134 -25
- package/src/superlocalmemory/core/injection.py +12 -7
- package/src/superlocalmemory/core/key_expander.py +138 -0
- package/src/superlocalmemory/core/maintenance.py +23 -0
- package/src/superlocalmemory/core/maintenance_scheduler.py +17 -7
- 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 +7 -3
- 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 +186 -29
- 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/hook_handlers.py +6 -1
- package/src/superlocalmemory/hooks/memory_protocol.py +54 -0
- package/src/superlocalmemory/hooks/portable_kit.py +148 -3
- 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/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/reward_proxy.py +42 -9
- package/src/superlocalmemory/learning/source_quality.py +523 -1
- 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 +263 -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 +32 -79
- 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 +12 -4
- package/src/superlocalmemory/mcp/tools_evolution.py +6 -3
- package/src/superlocalmemory/mcp/tools_learning.py +2 -2
- 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 +221 -47
- package/src/superlocalmemory/retrieval/entity_channel.py +7 -5
- package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -2
- package/src/superlocalmemory/retrieval/reranker.py +3 -4
- 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/config_file.py +90 -0
- package/src/superlocalmemory/server/origin.py +50 -0
- 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 +317 -70
- package/src/superlocalmemory/server/routes/behavioral.py +349 -71
- package/src/superlocalmemory/server/routes/brain.py +69 -12
- 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 +438 -0
- package/src/superlocalmemory/server/routes/data_io.py +30 -8
- package/src/superlocalmemory/server/routes/entity.py +108 -26
- package/src/superlocalmemory/server/routes/events.py +24 -8
- package/src/superlocalmemory/server/routes/evolution.py +189 -68
- 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 +289 -118
- package/src/superlocalmemory/server/routes/learning_telemetry.py +153 -0
- 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 +200 -31
- 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 +132 -0
- package/src/superlocalmemory/server/routes/rbac.py +367 -0
- package/src/superlocalmemory/server/routes/stats.py +103 -158
- package/src/superlocalmemory/server/routes/tiers.py +11 -9
- package/src/superlocalmemory/server/routes/token.py +3 -13
- package/src/superlocalmemory/server/routes/v3_api.py +247 -89
- 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 +827 -72
- package/src/superlocalmemory/server/write_identity.py +38 -8
- package/src/superlocalmemory/storage/database.py +265 -53
- package/src/superlocalmemory/storage/migration_runner.py +132 -1
- package/src/superlocalmemory/storage/migrations/M010_evolution_config.py +5 -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/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/models.py +4 -0
- package/src/superlocalmemory/storage/schema.py +136 -1
- package/src/superlocalmemory/storage/schema_v32.py +2 -0
- package/src/superlocalmemory/storage/schema_v343.py +24 -12
- package/src/superlocalmemory/storage/schema_v347.py +4 -0
- 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 +303 -173
- package/src/superlocalmemory/ui/js/brain.js +5 -20
- package/src/superlocalmemory/ui/js/core.js +100 -41
- package/src/superlocalmemory/ui/js/dashboard.js +403 -65
- 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 +871 -0
- package/src/superlocalmemory/ui/js/od-brain.js +816 -0
- package/src/superlocalmemory/ui/js/od-entities.js +579 -0
- package/src/superlocalmemory/ui/js/od-graph.js +600 -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 +929 -0
- package/src/superlocalmemory/ui/js/od-mesh.js +553 -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 +1107 -0
- package/src/superlocalmemory/ui/js/od-shell.js +809 -0
- package/src/superlocalmemory/ui/js/od-skills.js +600 -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 +17 -3
- 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/plugin-src/.mcp.json +0 -12
- package/plugin-src/agents/slm-memory-advisor.md +0 -44
- package/plugin-src/agents/slm-optimize-advisor.md +0 -38
- package/plugin-src/hooks/.gitkeep +0 -0
- package/plugin-src/hooks/hooks.json +0 -23
- package/plugin-src/manifest.json +0 -25
- 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 -23
- package/plugin-src/scripts/slm-launch.bat +0 -23
- package/plugin-src/settings.json +0 -16
- package/src/superlocalmemory/learning/behavioral_listener.py +0 -94
|
@@ -0,0 +1,181 @@
|
|
|
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 | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""Time-window parsing + membership for recall (Phase 4, T-window).
|
|
6
|
+
|
|
7
|
+
Pure, dependency-free helpers so recall() can prune candidates to an
|
|
8
|
+
event-time range. Two window forms are accepted:
|
|
9
|
+
|
|
10
|
+
* relative string — ``"1h" | "24h" | "7d" | "30d" | "90d" | "1y"`` etc.
|
|
11
|
+
(``<int><unit>`` where unit is h/d/w/m/y; m≈30d, y≈365d) → ``[now-Δ, now]``.
|
|
12
|
+
* explicit range — ``(start_iso, end_iso)`` two-tuple of timestamps.
|
|
13
|
+
|
|
14
|
+
Timestamps are parsed tolerantly: SQLite ``datetime('now')`` form
|
|
15
|
+
(``"YYYY-MM-DD HH:MM:SS"``), ISO-8601 with ``T`` and/or trailing ``Z``, and
|
|
16
|
+
date-only ``"YYYY-MM-DD"`` are all accepted. Comparing the *strings* would be
|
|
17
|
+
wrong (a space sorts before ``T``), so everything is parsed to tz-aware UTC
|
|
18
|
+
datetimes before comparison — never lexicographic.
|
|
19
|
+
|
|
20
|
+
Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
21
|
+
License: AGPL-3.0-or-later
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import re
|
|
27
|
+
from datetime import datetime, timedelta, timezone
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"parse_timestamp",
|
|
31
|
+
"parse_window",
|
|
32
|
+
"in_window",
|
|
33
|
+
"infer_window_from_query",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
_REL = re.compile(r"^\s*(\d+)\s*([hdwmy])\s*$", re.IGNORECASE)
|
|
37
|
+
|
|
38
|
+
# Hours per unit. Month and year are documented approximations.
|
|
39
|
+
_UNIT_HOURS: dict[str, int] = {
|
|
40
|
+
"h": 1,
|
|
41
|
+
"d": 24,
|
|
42
|
+
"w": 24 * 7,
|
|
43
|
+
"m": 24 * 30,
|
|
44
|
+
"y": 24 * 365,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def parse_timestamp(value: str | None) -> datetime | None:
|
|
49
|
+
"""Parse a stored timestamp into a tz-aware UTC datetime, or None.
|
|
50
|
+
|
|
51
|
+
Accepts SQLite ``datetime('now')`` (space-separated), ISO-8601 (``T`` and
|
|
52
|
+
optional ``Z``), and date-only strings. Naive values are assumed UTC.
|
|
53
|
+
"""
|
|
54
|
+
if not value or not isinstance(value, str):
|
|
55
|
+
return None
|
|
56
|
+
text = value.strip()
|
|
57
|
+
if not text:
|
|
58
|
+
return None
|
|
59
|
+
if text.endswith(("Z", "z")):
|
|
60
|
+
text = text[:-1] + "+00:00"
|
|
61
|
+
dt: datetime | None = None
|
|
62
|
+
try:
|
|
63
|
+
dt = datetime.fromisoformat(text)
|
|
64
|
+
except ValueError:
|
|
65
|
+
# Fall back to a date-only prefix (e.g. "2026-03-15 ...").
|
|
66
|
+
try:
|
|
67
|
+
dt = datetime.fromisoformat(text[:10])
|
|
68
|
+
except ValueError:
|
|
69
|
+
return None
|
|
70
|
+
if dt.tzinfo is None:
|
|
71
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
72
|
+
return dt.astimezone(timezone.utc)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_window(
|
|
76
|
+
window: str | tuple[str, str] | list | None,
|
|
77
|
+
now: datetime | None = None,
|
|
78
|
+
) -> tuple[datetime, datetime] | None:
|
|
79
|
+
"""Resolve a window spec to a ``(start, end)`` UTC datetime pair, or None.
|
|
80
|
+
|
|
81
|
+
Returns None when the spec is None or unparseable (caller then applies no
|
|
82
|
+
time filter — additive, fail-open).
|
|
83
|
+
"""
|
|
84
|
+
if window is None:
|
|
85
|
+
return None
|
|
86
|
+
_now = now or datetime.now(timezone.utc)
|
|
87
|
+
if _now.tzinfo is None:
|
|
88
|
+
_now = _now.replace(tzinfo=timezone.utc)
|
|
89
|
+
|
|
90
|
+
# Explicit (start, end) range.
|
|
91
|
+
if isinstance(window, (tuple, list)):
|
|
92
|
+
if len(window) != 2:
|
|
93
|
+
return None
|
|
94
|
+
start = parse_timestamp(window[0])
|
|
95
|
+
end = parse_timestamp(window[1])
|
|
96
|
+
if start is None or end is None:
|
|
97
|
+
return None
|
|
98
|
+
return (start, end) if start <= end else (end, start)
|
|
99
|
+
|
|
100
|
+
# String forms: relative "<int><unit>" or an explicit range written as
|
|
101
|
+
# "start..end" / "start,end" (so it survives URL params, JSON, and CLI args
|
|
102
|
+
# as a single value — no tuple serialization needed on the wire).
|
|
103
|
+
if isinstance(window, str):
|
|
104
|
+
m = _REL.match(window)
|
|
105
|
+
if m:
|
|
106
|
+
n = int(m.group(1))
|
|
107
|
+
unit = m.group(2).lower()
|
|
108
|
+
hours = n * _UNIT_HOURS[unit]
|
|
109
|
+
return (_now - timedelta(hours=hours), _now)
|
|
110
|
+
for sep in ("..", ","):
|
|
111
|
+
if sep in window:
|
|
112
|
+
left, _, right = window.partition(sep)
|
|
113
|
+
start = parse_timestamp(left)
|
|
114
|
+
end = parse_timestamp(right)
|
|
115
|
+
if start is None or end is None:
|
|
116
|
+
return None
|
|
117
|
+
return (start, end) if start <= end else (end, start)
|
|
118
|
+
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# Natural-language temporal-scope patterns → relative window spec. Ordered:
|
|
123
|
+
# more specific ("last 3 weeks") is matched before generic ("last week").
|
|
124
|
+
_UNIT_TO_SPEC = {"day": "d", "week": "w", "month": "m", "year": "y"}
|
|
125
|
+
_QUERY_N_UNIT = re.compile(
|
|
126
|
+
r"\b(?:last|past|previous|prior)\s+(\d{1,3})\s+(day|week|month|year)s?\b",
|
|
127
|
+
re.IGNORECASE,
|
|
128
|
+
)
|
|
129
|
+
_QUERY_PHRASES: tuple[tuple[re.Pattern[str], str], ...] = (
|
|
130
|
+
(re.compile(r"\btoday\b", re.IGNORECASE), "1d"),
|
|
131
|
+
(re.compile(r"\byesterday\b", re.IGNORECASE), "2d"),
|
|
132
|
+
(re.compile(r"\b(?:this|last|past|previous)\s+week\b", re.IGNORECASE), "7d"),
|
|
133
|
+
(re.compile(r"\b(?:this|last|past|previous)\s+month\b", re.IGNORECASE), "30d"),
|
|
134
|
+
(re.compile(r"\b(?:this|last|past|previous)\s+year\b", re.IGNORECASE), "1y"),
|
|
135
|
+
(re.compile(r"\b(?:recent|recently|lately)\b", re.IGNORECASE), "30d"),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def infer_window_from_query(query: str | None) -> str | None:
|
|
140
|
+
"""Infer a relative time window from natural-language scope in a query.
|
|
141
|
+
|
|
142
|
+
Recognises a small, unambiguous set of temporal-scope phrases ("yesterday",
|
|
143
|
+
"last week", "past 3 months", "recently") and maps them to a relative
|
|
144
|
+
window spec ("2d", "7d", "3m", …) that ``parse_window`` understands. Returns
|
|
145
|
+
None when no clear temporal scope is present, so recall applies no window.
|
|
146
|
+
|
|
147
|
+
Deliberately conservative — only fires on explicit scope words, never on
|
|
148
|
+
bare content — so it augments, never surprises. Callers use it only when the
|
|
149
|
+
user did not pass an explicit window.
|
|
150
|
+
"""
|
|
151
|
+
if not query or not isinstance(query, str):
|
|
152
|
+
return None
|
|
153
|
+
m = _QUERY_N_UNIT.search(query)
|
|
154
|
+
if m:
|
|
155
|
+
n = int(m.group(1))
|
|
156
|
+
unit = _UNIT_TO_SPEC.get(m.group(2).lower())
|
|
157
|
+
if unit and n > 0:
|
|
158
|
+
return f"{n}{unit}"
|
|
159
|
+
for pattern, spec in _QUERY_PHRASES:
|
|
160
|
+
if pattern.search(query):
|
|
161
|
+
return spec
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def in_window(
|
|
166
|
+
event_time: str | None,
|
|
167
|
+
bounds: tuple[datetime, datetime] | None,
|
|
168
|
+
) -> bool:
|
|
169
|
+
"""True if ``event_time`` falls within ``bounds`` (inclusive).
|
|
170
|
+
|
|
171
|
+
No bounds → always True (no filtering). An unparseable/missing event time
|
|
172
|
+
is excluded (conservative: a windowed query returns only datable facts in
|
|
173
|
+
range).
|
|
174
|
+
"""
|
|
175
|
+
if bounds is None:
|
|
176
|
+
return True
|
|
177
|
+
dt = parse_timestamp(event_time)
|
|
178
|
+
if dt is None:
|
|
179
|
+
return False
|
|
180
|
+
start, end = bounds
|
|
181
|
+
return start <= dt <= end
|
|
@@ -48,10 +48,10 @@ logger = logging.getLogger("superlocalmemory.api_server")
|
|
|
48
48
|
# V3 paths
|
|
49
49
|
MEMORY_DIR = DynamicStatePath()
|
|
50
50
|
DB_PATH = DynamicStatePath("memory.db")
|
|
51
|
-
# V3.3.21
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
UI_DIR =
|
|
51
|
+
# V3.3.21+: the dashboard ships inside the package (superlocalmemory/ui) for
|
|
52
|
+
# every install path. The legacy repo-root ui/ dev fallback was retired in
|
|
53
|
+
# v3.8.0 when that copy was deleted; the packaged directory is authoritative.
|
|
54
|
+
UI_DIR = Path(__file__).resolve().parent.parent / "ui"
|
|
55
55
|
|
|
56
56
|
|
|
57
57
|
# ============================================================================
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Process-safe, durable read/modify/write access to ``config.json``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
import threading
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Callable, Iterator
|
|
12
|
+
|
|
13
|
+
_PROCESS_LOCK = threading.RLock()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@contextmanager
|
|
17
|
+
def _file_lock(path: Path) -> Iterator[None]:
|
|
18
|
+
"""Serialize config access across daemon, CLI, and worker processes."""
|
|
19
|
+
lock_path = path.with_name(f".{path.name}.lock")
|
|
20
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
with _PROCESS_LOCK, lock_path.open("a+b") as handle:
|
|
22
|
+
if os.name == "nt":
|
|
23
|
+
import msvcrt
|
|
24
|
+
|
|
25
|
+
handle.seek(0)
|
|
26
|
+
handle.write(b"\0")
|
|
27
|
+
handle.flush()
|
|
28
|
+
handle.seek(0)
|
|
29
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
|
|
30
|
+
else:
|
|
31
|
+
import fcntl
|
|
32
|
+
|
|
33
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
|
34
|
+
try:
|
|
35
|
+
yield
|
|
36
|
+
finally:
|
|
37
|
+
if os.name == "nt":
|
|
38
|
+
handle.seek(0)
|
|
39
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
40
|
+
else:
|
|
41
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _read_unlocked(path: Path) -> dict:
|
|
45
|
+
if not path.exists():
|
|
46
|
+
return {}
|
|
47
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
48
|
+
if not isinstance(value, dict):
|
|
49
|
+
raise ValueError("config root must be a JSON object")
|
|
50
|
+
return value
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def read_config(path: Path) -> dict:
|
|
54
|
+
"""Return one coherent config snapshot under the interprocess lock."""
|
|
55
|
+
with _file_lock(path):
|
|
56
|
+
return _read_unlocked(path)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def update_config(path: Path, update: Callable[[dict], None]) -> dict:
|
|
60
|
+
"""Atomically update and durably replace a config JSON object."""
|
|
61
|
+
with _file_lock(path):
|
|
62
|
+
data = _read_unlocked(path)
|
|
63
|
+
update(data)
|
|
64
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
descriptor, temp_name = tempfile.mkstemp(
|
|
66
|
+
prefix=f".{path.name}.",
|
|
67
|
+
suffix=".tmp",
|
|
68
|
+
dir=path.parent,
|
|
69
|
+
)
|
|
70
|
+
temp_path = Path(temp_name)
|
|
71
|
+
try:
|
|
72
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
|
73
|
+
json.dump(data, stream, indent=2)
|
|
74
|
+
stream.write("\n")
|
|
75
|
+
stream.flush()
|
|
76
|
+
os.fsync(stream.fileno())
|
|
77
|
+
os.chmod(temp_path, 0o600)
|
|
78
|
+
os.replace(temp_path, path)
|
|
79
|
+
if hasattr(os, "O_DIRECTORY"):
|
|
80
|
+
directory_fd = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
|
|
81
|
+
try:
|
|
82
|
+
os.fsync(directory_fd)
|
|
83
|
+
finally:
|
|
84
|
+
os.close(directory_fd)
|
|
85
|
+
finally:
|
|
86
|
+
temp_path.unlink(missing_ok=True)
|
|
87
|
+
return data
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
__all__ = ("read_config", "update_config")
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Exact browser-origin validation for the local dashboard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from urllib.parse import urlsplit
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def origin_is_loopback(origin: str) -> bool:
|
|
12
|
+
"""Return whether an Origin is absent or an exact HTTP(S) loopback URL."""
|
|
13
|
+
if not origin:
|
|
14
|
+
return True
|
|
15
|
+
try:
|
|
16
|
+
parsed = urlsplit(origin)
|
|
17
|
+
# Accessing port validates malformed/non-numeric port values.
|
|
18
|
+
_ = parsed.port
|
|
19
|
+
except (TypeError, ValueError):
|
|
20
|
+
return False
|
|
21
|
+
return (
|
|
22
|
+
parsed.scheme in {"http", "https"}
|
|
23
|
+
and parsed.hostname is not None
|
|
24
|
+
and parsed.hostname.lower() in _LOOPBACK_HOSTS
|
|
25
|
+
and parsed.username is None
|
|
26
|
+
and parsed.password is None
|
|
27
|
+
and parsed.path in {"", "/"}
|
|
28
|
+
and not parsed.query
|
|
29
|
+
and not parsed.fragment
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def origin_is_daemon(origin: str, *, port: int) -> bool:
|
|
34
|
+
"""Return whether ``origin`` is one of this daemon's loopback aliases.
|
|
35
|
+
|
|
36
|
+
A loopback host alone is not a browser trust boundary: another local web
|
|
37
|
+
server can run on a different port. Credentialless dashboard writes are
|
|
38
|
+
therefore limited to the port owned by this daemon. Authenticated local
|
|
39
|
+
integrations are handled separately by the write-identity boundary.
|
|
40
|
+
"""
|
|
41
|
+
if not origin_is_loopback(origin):
|
|
42
|
+
return False
|
|
43
|
+
try:
|
|
44
|
+
parsed = urlsplit(origin)
|
|
45
|
+
return parsed.port == port
|
|
46
|
+
except (TypeError, ValueError):
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
__all__ = ("origin_is_daemon", "origin_is_loopback")
|
|
@@ -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
|