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
|
@@ -0,0 +1,477 @@
|
|
|
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
|
+
"""Role-based access control for the memory tenancy model.
|
|
6
|
+
|
|
7
|
+
The tenancy boundary is the *profile* (individual / team / company). This
|
|
8
|
+
engine adds real, persisted user identity and role grants on top of that
|
|
9
|
+
boundary, following the Cognee reference model (Tenant -> User -> Role).
|
|
10
|
+
|
|
11
|
+
Design principles
|
|
12
|
+
-----------------
|
|
13
|
+
* **Additive / self-hosting-correct.** With zero users the daemon stays
|
|
14
|
+
single-operator: the machine operator (install token / daemon capability
|
|
15
|
+
over loopback) is the implicit *owner* with every permission, so individual
|
|
16
|
+
use has no login friction. Once an org creates users + memberships from the
|
|
17
|
+
dashboard, logged-in users are enforced against their role.
|
|
18
|
+
* **Enforced, not decorative.** ``has_permission`` is called from the write /
|
|
19
|
+
delete / share / manage routes. This module is the single source of truth
|
|
20
|
+
for the role -> permission matrix.
|
|
21
|
+
* **No plaintext secrets.** Passwords are hashed with ``hashlib.scrypt`` and a
|
|
22
|
+
per-user random salt; session tokens are stored only as SHA-256 hashes and
|
|
23
|
+
compared in constant time.
|
|
24
|
+
|
|
25
|
+
All state lives in memory.db (tables from migration M024). Connections are
|
|
26
|
+
short-lived (opened per call) with a bounded busy timeout, matching the mesh
|
|
27
|
+
broker's concurrency-safe pattern.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import hashlib
|
|
33
|
+
import hmac
|
|
34
|
+
import logging
|
|
35
|
+
import secrets
|
|
36
|
+
import sqlite3
|
|
37
|
+
import uuid
|
|
38
|
+
from datetime import datetime, timedelta, timezone
|
|
39
|
+
from enum import Enum
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger("superlocalmemory.access.rbac")
|
|
43
|
+
|
|
44
|
+
_BUSY_TIMEOUT_MS = 4000
|
|
45
|
+
_SESSION_TTL_HOURS = 12
|
|
46
|
+
|
|
47
|
+
# scrypt cost parameters (OWASP-recommended interactive-login range).
|
|
48
|
+
_SCRYPT_N = 2 ** 14
|
|
49
|
+
_SCRYPT_R = 8
|
|
50
|
+
_SCRYPT_P = 1
|
|
51
|
+
_SCRYPT_DKLEN = 32
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Role(str, Enum):
|
|
55
|
+
ADMIN = "admin"
|
|
56
|
+
MEMBER = "member"
|
|
57
|
+
VIEWER = "viewer"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Permission(str, Enum):
|
|
61
|
+
READ = "read"
|
|
62
|
+
WRITE = "write"
|
|
63
|
+
DELETE = "delete"
|
|
64
|
+
SHARE = "share"
|
|
65
|
+
MANAGE = "manage" # manage users, roles, profile settings
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# Role -> permission set. This is the single source of truth for authorization.
|
|
69
|
+
_ROLE_PERMISSIONS: dict[Role, frozenset[Permission]] = {
|
|
70
|
+
Role.ADMIN: frozenset(Permission),
|
|
71
|
+
Role.MEMBER: frozenset({Permission.READ, Permission.WRITE, Permission.SHARE}),
|
|
72
|
+
Role.VIEWER: frozenset({Permission.READ}),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def permissions_for_role(role: Role) -> frozenset[Permission]:
|
|
77
|
+
return _ROLE_PERMISSIONS.get(role, frozenset())
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class RbacError(ValueError):
|
|
81
|
+
"""Raised for invalid RBAC operations (bad role, duplicate user, ...)."""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _now() -> str:
|
|
85
|
+
return datetime.now(timezone.utc).isoformat()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _uid() -> str:
|
|
89
|
+
return uuid.uuid4().hex[:16]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _hash_password(password: str) -> str:
|
|
93
|
+
"""scrypt$N$r$p$salt_hex$dk_hex — self-describing so params can evolve."""
|
|
94
|
+
if not password or len(password) < 8:
|
|
95
|
+
raise RbacError("Password must be at least 8 characters.")
|
|
96
|
+
salt = secrets.token_bytes(16)
|
|
97
|
+
dk = hashlib.scrypt(
|
|
98
|
+
password.encode("utf-8"), salt=salt,
|
|
99
|
+
n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=_SCRYPT_DKLEN,
|
|
100
|
+
)
|
|
101
|
+
return f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}${salt.hex()}${dk.hex()}"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _verify_password(password: str, stored: str) -> bool:
|
|
105
|
+
try:
|
|
106
|
+
scheme, n, r, p, salt_hex, dk_hex = stored.split("$")
|
|
107
|
+
if scheme != "scrypt":
|
|
108
|
+
return False
|
|
109
|
+
dk = hashlib.scrypt(
|
|
110
|
+
password.encode("utf-8"), salt=bytes.fromhex(salt_hex),
|
|
111
|
+
n=int(n), r=int(r), p=int(p), dklen=len(bytes.fromhex(dk_hex)),
|
|
112
|
+
)
|
|
113
|
+
return hmac.compare_digest(dk.hex(), dk_hex)
|
|
114
|
+
except (ValueError, TypeError):
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _hash_token(raw_token: str) -> str:
|
|
119
|
+
return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# Constant dummy hash so verify_credentials spends ~equal time on unknown and
|
|
123
|
+
# known usernames (defeats the username-enumeration timing oracle). Computed
|
|
124
|
+
# once at import.
|
|
125
|
+
_DUMMY_PASSWORD_HASH = _hash_password("__slm_dummy_sentinel__never_a_real_pw__")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class RbacEngine:
|
|
129
|
+
"""Persisted user / membership / session store with role enforcement."""
|
|
130
|
+
|
|
131
|
+
def __init__(self, db_path: str | Path) -> None:
|
|
132
|
+
self._db_path = str(db_path)
|
|
133
|
+
|
|
134
|
+
# -- connection -------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
def _conn(self) -> sqlite3.Connection:
|
|
137
|
+
conn = sqlite3.connect(self._db_path, timeout=_BUSY_TIMEOUT_MS / 1000)
|
|
138
|
+
conn.execute(f"PRAGMA busy_timeout={_BUSY_TIMEOUT_MS}")
|
|
139
|
+
conn.row_factory = sqlite3.Row
|
|
140
|
+
return conn
|
|
141
|
+
|
|
142
|
+
# -- users ------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
def user_count(self) -> int:
|
|
145
|
+
conn = self._conn()
|
|
146
|
+
try:
|
|
147
|
+
return int(conn.execute("SELECT COUNT(*) FROM rbac_users").fetchone()[0])
|
|
148
|
+
finally:
|
|
149
|
+
conn.close()
|
|
150
|
+
|
|
151
|
+
def create_user(self, username: str, password: str, display_name: str = "",
|
|
152
|
+
created_by: str = "owner") -> dict:
|
|
153
|
+
username = (username or "").strip()
|
|
154
|
+
if not username:
|
|
155
|
+
raise RbacError("Username is required.")
|
|
156
|
+
pw_hash = _hash_password(password)
|
|
157
|
+
user_id = _uid()
|
|
158
|
+
conn = self._conn()
|
|
159
|
+
try:
|
|
160
|
+
# The UNIQUE(username) constraint is the source of truth; the SELECT
|
|
161
|
+
# is a fast path. Two concurrent creates can both pass the SELECT, so
|
|
162
|
+
# the loser's INSERT hits the constraint — convert that to RbacError
|
|
163
|
+
# (409) instead of leaking a raw IntegrityError as a 500.
|
|
164
|
+
exists = conn.execute(
|
|
165
|
+
"SELECT 1 FROM rbac_users WHERE username=?", (username,)
|
|
166
|
+
).fetchone()
|
|
167
|
+
if exists:
|
|
168
|
+
raise RbacError(f"User '{username}' already exists.")
|
|
169
|
+
try:
|
|
170
|
+
conn.execute(
|
|
171
|
+
"INSERT INTO rbac_users (user_id, username, display_name, "
|
|
172
|
+
"password_hash, status, created_at, created_by) "
|
|
173
|
+
"VALUES (?, ?, ?, ?, 'active', ?, ?)",
|
|
174
|
+
(user_id, username, display_name or username, pw_hash,
|
|
175
|
+
_now(), created_by),
|
|
176
|
+
)
|
|
177
|
+
conn.commit()
|
|
178
|
+
except sqlite3.IntegrityError:
|
|
179
|
+
raise RbacError(f"User '{username}' already exists.")
|
|
180
|
+
finally:
|
|
181
|
+
conn.close()
|
|
182
|
+
logger.info("RBAC: created user '%s' (%s)", username, user_id)
|
|
183
|
+
return {"user_id": user_id, "username": username,
|
|
184
|
+
"display_name": display_name or username, "status": "active"}
|
|
185
|
+
|
|
186
|
+
def set_password(self, user_id: str, password: str) -> None:
|
|
187
|
+
pw_hash = _hash_password(password)
|
|
188
|
+
conn = self._conn()
|
|
189
|
+
try:
|
|
190
|
+
cur = conn.execute(
|
|
191
|
+
"UPDATE rbac_users SET password_hash=? WHERE user_id=?",
|
|
192
|
+
(pw_hash, user_id),
|
|
193
|
+
)
|
|
194
|
+
if cur.rowcount == 0:
|
|
195
|
+
raise RbacError("User not found.")
|
|
196
|
+
# A password change invalidates every existing session.
|
|
197
|
+
conn.execute("DELETE FROM rbac_sessions WHERE user_id=?", (user_id,))
|
|
198
|
+
conn.commit()
|
|
199
|
+
finally:
|
|
200
|
+
conn.close()
|
|
201
|
+
|
|
202
|
+
def set_status(self, user_id: str, status: str) -> None:
|
|
203
|
+
if status not in ("active", "disabled"):
|
|
204
|
+
raise RbacError("status must be 'active' or 'disabled'.")
|
|
205
|
+
conn = self._conn()
|
|
206
|
+
try:
|
|
207
|
+
cur = conn.execute(
|
|
208
|
+
"UPDATE rbac_users SET status=? WHERE user_id=?", (status, user_id)
|
|
209
|
+
)
|
|
210
|
+
if cur.rowcount == 0:
|
|
211
|
+
raise RbacError("User not found.")
|
|
212
|
+
if status == "disabled":
|
|
213
|
+
conn.execute("DELETE FROM rbac_sessions WHERE user_id=?", (user_id,))
|
|
214
|
+
conn.commit()
|
|
215
|
+
finally:
|
|
216
|
+
conn.close()
|
|
217
|
+
|
|
218
|
+
def delete_user(self, user_id: str) -> None:
|
|
219
|
+
conn = self._conn()
|
|
220
|
+
try:
|
|
221
|
+
conn.execute("DELETE FROM rbac_sessions WHERE user_id=?", (user_id,))
|
|
222
|
+
conn.execute("DELETE FROM rbac_memberships WHERE user_id=?", (user_id,))
|
|
223
|
+
cur = conn.execute("DELETE FROM rbac_users WHERE user_id=?", (user_id,))
|
|
224
|
+
if cur.rowcount == 0:
|
|
225
|
+
raise RbacError("User not found.")
|
|
226
|
+
conn.commit()
|
|
227
|
+
finally:
|
|
228
|
+
conn.close()
|
|
229
|
+
|
|
230
|
+
def get_user(self, user_id: str) -> dict | None:
|
|
231
|
+
conn = self._conn()
|
|
232
|
+
try:
|
|
233
|
+
row = conn.execute(
|
|
234
|
+
"SELECT user_id, username, display_name, status, created_at, created_by "
|
|
235
|
+
"FROM rbac_users WHERE user_id=?", (user_id,)
|
|
236
|
+
).fetchone()
|
|
237
|
+
return dict(row) if row else None
|
|
238
|
+
finally:
|
|
239
|
+
conn.close()
|
|
240
|
+
|
|
241
|
+
def list_users(self) -> list[dict]:
|
|
242
|
+
conn = self._conn()
|
|
243
|
+
try:
|
|
244
|
+
rows = conn.execute(
|
|
245
|
+
"SELECT user_id, username, display_name, status, created_at, created_by "
|
|
246
|
+
"FROM rbac_users ORDER BY username"
|
|
247
|
+
).fetchall()
|
|
248
|
+
return [dict(r) for r in rows]
|
|
249
|
+
finally:
|
|
250
|
+
conn.close()
|
|
251
|
+
|
|
252
|
+
# -- credentials / sessions ------------------------------------------
|
|
253
|
+
|
|
254
|
+
def verify_credentials(self, username: str, password: str) -> dict | None:
|
|
255
|
+
conn = self._conn()
|
|
256
|
+
try:
|
|
257
|
+
row = conn.execute(
|
|
258
|
+
"SELECT user_id, username, display_name, password_hash, status "
|
|
259
|
+
"FROM rbac_users WHERE username=?", ((username or "").strip(),)
|
|
260
|
+
).fetchone()
|
|
261
|
+
finally:
|
|
262
|
+
conn.close()
|
|
263
|
+
if not row or row["status"] != "active":
|
|
264
|
+
# Run a verify against a constant dummy hash so an unknown/disabled
|
|
265
|
+
# username costs the same wall-time as a wrong password — no timing
|
|
266
|
+
# oracle for username enumeration.
|
|
267
|
+
_verify_password(password, _DUMMY_PASSWORD_HASH)
|
|
268
|
+
return None
|
|
269
|
+
if not _verify_password(password, row["password_hash"]):
|
|
270
|
+
return None
|
|
271
|
+
return {"user_id": row["user_id"], "username": row["username"],
|
|
272
|
+
"display_name": row["display_name"]}
|
|
273
|
+
|
|
274
|
+
def create_session(self, user_id: str, ttl_hours: int = _SESSION_TTL_HOURS) -> str:
|
|
275
|
+
"""Mint an opaque session token; only its hash is stored. Returns the
|
|
276
|
+
raw token (shown to the client once)."""
|
|
277
|
+
raw = secrets.token_urlsafe(32)
|
|
278
|
+
now = datetime.now(timezone.utc)
|
|
279
|
+
expires = (now + timedelta(hours=ttl_hours)).isoformat()
|
|
280
|
+
conn = self._conn()
|
|
281
|
+
try:
|
|
282
|
+
conn.execute(
|
|
283
|
+
"INSERT INTO rbac_sessions (token_hash, user_id, created_at, "
|
|
284
|
+
"expires_at, last_seen) VALUES (?, ?, ?, ?, ?)",
|
|
285
|
+
(_hash_token(raw), user_id, now.isoformat(), expires, now.isoformat()),
|
|
286
|
+
)
|
|
287
|
+
conn.commit()
|
|
288
|
+
finally:
|
|
289
|
+
conn.close()
|
|
290
|
+
return raw
|
|
291
|
+
|
|
292
|
+
def resolve_session(self, raw_token: str) -> dict | None:
|
|
293
|
+
"""Return the active user for a session token, or None if invalid /
|
|
294
|
+
expired / disabled. Updates last_seen. Constant-time by hash lookup."""
|
|
295
|
+
if not raw_token:
|
|
296
|
+
return None
|
|
297
|
+
token_hash = _hash_token(raw_token)
|
|
298
|
+
now = datetime.now(timezone.utc)
|
|
299
|
+
conn = self._conn()
|
|
300
|
+
try:
|
|
301
|
+
row = conn.execute(
|
|
302
|
+
"SELECT s.user_id, s.expires_at, s.last_seen, "
|
|
303
|
+
"u.username, u.display_name, u.status "
|
|
304
|
+
"FROM rbac_sessions s JOIN rbac_users u ON u.user_id = s.user_id "
|
|
305
|
+
"WHERE s.token_hash=?", (token_hash,)
|
|
306
|
+
).fetchone()
|
|
307
|
+
if not row:
|
|
308
|
+
return None
|
|
309
|
+
try:
|
|
310
|
+
expired = datetime.fromisoformat(row["expires_at"]) <= now
|
|
311
|
+
except ValueError:
|
|
312
|
+
expired = True
|
|
313
|
+
if expired or row["status"] != "active":
|
|
314
|
+
conn.execute("DELETE FROM rbac_sessions WHERE token_hash=?", (token_hash,))
|
|
315
|
+
conn.commit()
|
|
316
|
+
return None
|
|
317
|
+
# Debounce last_seen: only write when it is stale (>60s), so a burst
|
|
318
|
+
# of authenticated requests does not serialize through the single
|
|
319
|
+
# SQLite writer on every call.
|
|
320
|
+
stale = True
|
|
321
|
+
try:
|
|
322
|
+
stale = (now - datetime.fromisoformat(row["last_seen"])).total_seconds() > 60
|
|
323
|
+
except (ValueError, TypeError, KeyError):
|
|
324
|
+
stale = True
|
|
325
|
+
if stale:
|
|
326
|
+
conn.execute(
|
|
327
|
+
"UPDATE rbac_sessions SET last_seen=? WHERE token_hash=?",
|
|
328
|
+
(now.isoformat(), token_hash),
|
|
329
|
+
)
|
|
330
|
+
conn.commit()
|
|
331
|
+
return {"user_id": row["user_id"], "username": row["username"],
|
|
332
|
+
"display_name": row["display_name"]}
|
|
333
|
+
finally:
|
|
334
|
+
conn.close()
|
|
335
|
+
|
|
336
|
+
def revoke_session(self, raw_token: str) -> None:
|
|
337
|
+
conn = self._conn()
|
|
338
|
+
try:
|
|
339
|
+
conn.execute("DELETE FROM rbac_sessions WHERE token_hash=?",
|
|
340
|
+
(_hash_token(raw_token),))
|
|
341
|
+
conn.commit()
|
|
342
|
+
finally:
|
|
343
|
+
conn.close()
|
|
344
|
+
|
|
345
|
+
def purge_expired_sessions(self) -> int:
|
|
346
|
+
conn = self._conn()
|
|
347
|
+
try:
|
|
348
|
+
cur = conn.execute(
|
|
349
|
+
"DELETE FROM rbac_sessions WHERE expires_at <= ?", (_now(),)
|
|
350
|
+
)
|
|
351
|
+
conn.commit()
|
|
352
|
+
return cur.rowcount or 0
|
|
353
|
+
finally:
|
|
354
|
+
conn.close()
|
|
355
|
+
|
|
356
|
+
# -- memberships ------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
def set_membership(self, profile_id: str, user_id: str, role: str,
|
|
359
|
+
added_by: str = "owner") -> dict:
|
|
360
|
+
try:
|
|
361
|
+
role_enum = Role(role)
|
|
362
|
+
except ValueError:
|
|
363
|
+
raise RbacError(f"Invalid role '{role}'. Use admin/member/viewer.")
|
|
364
|
+
conn = self._conn()
|
|
365
|
+
try:
|
|
366
|
+
if not conn.execute(
|
|
367
|
+
"SELECT 1 FROM rbac_users WHERE user_id=?", (user_id,)
|
|
368
|
+
).fetchone():
|
|
369
|
+
raise RbacError("User not found.")
|
|
370
|
+
conn.execute(
|
|
371
|
+
"INSERT INTO rbac_memberships (profile_id, user_id, role, added_at, added_by) "
|
|
372
|
+
"VALUES (?, ?, ?, ?, ?) "
|
|
373
|
+
"ON CONFLICT(profile_id, user_id) DO UPDATE SET role=excluded.role, "
|
|
374
|
+
"added_at=excluded.added_at, added_by=excluded.added_by",
|
|
375
|
+
(profile_id, user_id, role_enum.value, _now(), added_by),
|
|
376
|
+
)
|
|
377
|
+
conn.commit()
|
|
378
|
+
finally:
|
|
379
|
+
conn.close()
|
|
380
|
+
return {"profile_id": profile_id, "user_id": user_id, "role": role_enum.value}
|
|
381
|
+
|
|
382
|
+
def remove_membership(self, profile_id: str, user_id: str) -> None:
|
|
383
|
+
conn = self._conn()
|
|
384
|
+
try:
|
|
385
|
+
conn.execute(
|
|
386
|
+
"DELETE FROM rbac_memberships WHERE profile_id=? AND user_id=?",
|
|
387
|
+
(profile_id, user_id),
|
|
388
|
+
)
|
|
389
|
+
conn.commit()
|
|
390
|
+
finally:
|
|
391
|
+
conn.close()
|
|
392
|
+
|
|
393
|
+
def get_role(self, user_id: str, profile_id: str) -> Role | None:
|
|
394
|
+
conn = self._conn()
|
|
395
|
+
try:
|
|
396
|
+
row = conn.execute(
|
|
397
|
+
"SELECT role FROM rbac_memberships WHERE user_id=? AND profile_id=?",
|
|
398
|
+
(user_id, profile_id),
|
|
399
|
+
).fetchone()
|
|
400
|
+
finally:
|
|
401
|
+
conn.close()
|
|
402
|
+
if not row:
|
|
403
|
+
return None
|
|
404
|
+
try:
|
|
405
|
+
return Role(row["role"])
|
|
406
|
+
except ValueError:
|
|
407
|
+
return None
|
|
408
|
+
|
|
409
|
+
def list_members(self, profile_id: str) -> list[dict]:
|
|
410
|
+
conn = self._conn()
|
|
411
|
+
try:
|
|
412
|
+
rows = conn.execute(
|
|
413
|
+
"SELECT m.user_id, u.username, u.display_name, m.role, m.added_at "
|
|
414
|
+
"FROM rbac_memberships m JOIN rbac_users u ON u.user_id = m.user_id "
|
|
415
|
+
"WHERE m.profile_id=? ORDER BY u.username", (profile_id,)
|
|
416
|
+
).fetchall()
|
|
417
|
+
return [dict(r) for r in rows]
|
|
418
|
+
finally:
|
|
419
|
+
conn.close()
|
|
420
|
+
|
|
421
|
+
def list_user_profiles(self, user_id: str) -> list[dict]:
|
|
422
|
+
conn = self._conn()
|
|
423
|
+
try:
|
|
424
|
+
rows = conn.execute(
|
|
425
|
+
"SELECT profile_id, role FROM rbac_memberships WHERE user_id=?",
|
|
426
|
+
(user_id,)
|
|
427
|
+
).fetchall()
|
|
428
|
+
return [dict(r) for r in rows]
|
|
429
|
+
finally:
|
|
430
|
+
conn.close()
|
|
431
|
+
|
|
432
|
+
# -- policy settings --------------------------------------------------
|
|
433
|
+
|
|
434
|
+
def get_policy(self, key: str, default: str = "") -> str:
|
|
435
|
+
conn = self._conn()
|
|
436
|
+
try:
|
|
437
|
+
row = conn.execute(
|
|
438
|
+
"SELECT value FROM rbac_settings WHERE key=?", (key,)
|
|
439
|
+
).fetchone()
|
|
440
|
+
return row["value"] if row else default
|
|
441
|
+
finally:
|
|
442
|
+
conn.close()
|
|
443
|
+
|
|
444
|
+
def set_policy(self, key: str, value: str) -> None:
|
|
445
|
+
conn = self._conn()
|
|
446
|
+
try:
|
|
447
|
+
conn.execute(
|
|
448
|
+
"INSERT INTO rbac_settings (key, value) VALUES (?, ?) "
|
|
449
|
+
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
|
450
|
+
(key, value),
|
|
451
|
+
)
|
|
452
|
+
conn.commit()
|
|
453
|
+
finally:
|
|
454
|
+
conn.close()
|
|
455
|
+
|
|
456
|
+
def require_login(self) -> bool:
|
|
457
|
+
"""Company mode: mutations require a valid user session (no owner
|
|
458
|
+
bypass). Default off (personal / single-operator use)."""
|
|
459
|
+
return self.get_policy("require_login", "0") == "1"
|
|
460
|
+
|
|
461
|
+
def set_require_login(self, enabled: bool) -> None:
|
|
462
|
+
self.set_policy("require_login", "1" if enabled else "0")
|
|
463
|
+
|
|
464
|
+
# -- authorization ----------------------------------------------------
|
|
465
|
+
|
|
466
|
+
def has_permission(self, user_id: str, profile_id: str,
|
|
467
|
+
permission: Permission | str) -> bool:
|
|
468
|
+
"""True iff the user's role on the profile grants ``permission``.
|
|
469
|
+
|
|
470
|
+
A user with no membership on the profile has NO access (deny-by-default).
|
|
471
|
+
The implicit machine owner is handled by the caller, not here.
|
|
472
|
+
"""
|
|
473
|
+
perm = permission if isinstance(permission, Permission) else Permission(permission)
|
|
474
|
+
role = self.get_role(user_id, profile_id)
|
|
475
|
+
if role is None:
|
|
476
|
+
return False
|
|
477
|
+
return perm in permissions_for_role(role)
|
|
@@ -40,6 +40,14 @@ def _cmd_db_dispatch(args: Namespace) -> None:
|
|
|
40
40
|
sys.exit(2)
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
def _cmd_mesh_dispatch(args: Namespace) -> None:
|
|
44
|
+
"""Route ``slm mesh ...`` inspection subcommands (M-03)."""
|
|
45
|
+
from superlocalmemory.cli.mesh_cmd import cmd_mesh
|
|
46
|
+
rc = cmd_mesh(args)
|
|
47
|
+
if rc:
|
|
48
|
+
sys.exit(rc)
|
|
49
|
+
|
|
50
|
+
|
|
43
51
|
def _cmd_escape_disable(args: Namespace) -> None:
|
|
44
52
|
from superlocalmemory.cli.escape_hatch import cmd_disable
|
|
45
53
|
cmd_disable(args)
|
|
@@ -98,6 +106,11 @@ def _cmd_help_optimize(args: Namespace) -> None:
|
|
|
98
106
|
cmd_help_optimize(args)
|
|
99
107
|
|
|
100
108
|
|
|
109
|
+
def _cmd_loop(args: Namespace) -> None:
|
|
110
|
+
from superlocalmemory.cli.loop_cmd import cmd_loop
|
|
111
|
+
cmd_loop(args)
|
|
112
|
+
|
|
113
|
+
|
|
101
114
|
# ---- end SLM v3.6 Optimize dispatch functions ----
|
|
102
115
|
|
|
103
116
|
|
|
@@ -201,6 +214,8 @@ def dispatch(args: Namespace) -> None:
|
|
|
201
214
|
"context": _cmd_context_dispatch,
|
|
202
215
|
# V3.4.22 LLD-06 additive schema migrations
|
|
203
216
|
"db": _cmd_db_dispatch,
|
|
217
|
+
# V3.7.9 M-03 — terminal mesh inspection
|
|
218
|
+
"mesh": _cmd_mesh_dispatch,
|
|
204
219
|
# V3.4.22 Stage 8 SB-5 — MASTER-PLAN §8 escape hatches.
|
|
205
220
|
"disable": _cmd_escape_disable,
|
|
206
221
|
"enable": _cmd_escape_enable,
|
|
@@ -218,6 +233,8 @@ def dispatch(args: Namespace) -> None:
|
|
|
218
233
|
"compress": _cmd_compress,
|
|
219
234
|
"proxy": _cmd_proxy,
|
|
220
235
|
"help-optimize": _cmd_help_optimize,
|
|
236
|
+
# V3.8.0 bounded loops (gate-verified agent loops, SLM-backed ledger)
|
|
237
|
+
"loop": _cmd_loop,
|
|
221
238
|
}
|
|
222
239
|
handler = handlers.get(args.command)
|
|
223
240
|
if handler:
|
|
@@ -345,6 +362,11 @@ def cmd_serve(args: Namespace) -> None:
|
|
|
345
362
|
else:
|
|
346
363
|
from superlocalmemory.infra.data_root import state_path
|
|
347
364
|
print(f"Failed to start daemon. Check {state_path('logs', 'daemon.log')}")
|
|
365
|
+
# INT-H-02: exit non-zero so callers can detect the failure. The plugin
|
|
366
|
+
# launcher (plugin/scripts/slm-launch) guards on this exit code and
|
|
367
|
+
# refuses to open a direct MCP writer against a broken daemon; without
|
|
368
|
+
# the non-zero exit that guard was a dead no-op.
|
|
369
|
+
sys.exit(1)
|
|
348
370
|
|
|
349
371
|
|
|
350
372
|
# -- Ingestion Adapters (V3.4.3) ------------------------------------------
|
|
@@ -610,6 +632,8 @@ def cmd_config(args: Namespace) -> None:
|
|
|
610
632
|
elif action == "set":
|
|
611
633
|
_ALLOWED_CONFIG_KEYS = {
|
|
612
634
|
"evolution.enabled", "evolution.backend", "evolution.max_evolutions_per_cycle",
|
|
635
|
+
"evolution.mutation_model", "evolution.verify_model",
|
|
636
|
+
"evolution.confirm_model",
|
|
613
637
|
"mesh_enabled", "daemon_idle_timeout", "entity_compilation_enabled",
|
|
614
638
|
"graph_backend", "vector_backend", "scale_engine_state",
|
|
615
639
|
"scope.default_scope", "scope.recall_include_global",
|
|
@@ -677,6 +701,31 @@ def cmd_config(args: Namespace) -> None:
|
|
|
677
701
|
print(f"Error: {message}")
|
|
678
702
|
sys.exit(1)
|
|
679
703
|
|
|
704
|
+
_MODEL_KEYS = {
|
|
705
|
+
"evolution.mutation_model", "evolution.verify_model",
|
|
706
|
+
"evolution.confirm_model",
|
|
707
|
+
}
|
|
708
|
+
if key in _MODEL_KEYS:
|
|
709
|
+
from superlocalmemory.evolution.model_selection import _MODEL_ALIASES
|
|
710
|
+
|
|
711
|
+
accepted = set(_MODEL_ALIASES) | {"", "auto"}
|
|
712
|
+
sval = str(parsed_value)
|
|
713
|
+
if sval not in accepted:
|
|
714
|
+
allowed = ", ".join(["auto", *sorted(_MODEL_ALIASES)])
|
|
715
|
+
message = f"{key} must be one of: {allowed}"
|
|
716
|
+
if use_json:
|
|
717
|
+
from superlocalmemory.cli.json_output import json_print
|
|
718
|
+
json_print("config", error={
|
|
719
|
+
"code": "INVALID_VALUE", "message": message,
|
|
720
|
+
})
|
|
721
|
+
else:
|
|
722
|
+
print(f"Error: {message}")
|
|
723
|
+
sys.exit(1)
|
|
724
|
+
# Normalise the "auto" sentinel to the empty string the
|
|
725
|
+
# resolver treats as "pick the cheapest for the backend".
|
|
726
|
+
if sval == "auto":
|
|
727
|
+
parsed_value = ""
|
|
728
|
+
|
|
680
729
|
if key.startswith("scope."):
|
|
681
730
|
from superlocalmemory.cli.daemon import daemon_request, is_daemon_running
|
|
682
731
|
|
|
@@ -727,6 +776,18 @@ def cmd_config(args: Namespace) -> None:
|
|
|
727
776
|
})
|
|
728
777
|
else:
|
|
729
778
|
print(f"{key}: {old_value} -> {parsed_value}")
|
|
779
|
+
if key == "evolution.enabled" and parsed_value is True:
|
|
780
|
+
print(
|
|
781
|
+
"\n⚠ Skill evolution is now ON. It makes background "
|
|
782
|
+
"LLM calls during consolidation\n"
|
|
783
|
+
" (capped at 10 calls/cycle, 3 cycles/day). It defaults "
|
|
784
|
+
"to the lowest-cost model\n"
|
|
785
|
+
" for your backend (Claude → Haiku, Ollama → "
|
|
786
|
+
"local/free).\n"
|
|
787
|
+
" Pick models: slm config set evolution.mutation_model "
|
|
788
|
+
"<auto|haiku|sonnet|ollama>\n"
|
|
789
|
+
" Turn off: slm config set evolution.enabled false"
|
|
790
|
+
)
|
|
730
791
|
|
|
731
792
|
else:
|
|
732
793
|
if use_json:
|
|
@@ -1271,10 +1332,12 @@ def cmd_recall(args: Namespace) -> None:
|
|
|
1271
1332
|
scope_qs += f"&include_global={str(include_global).lower()}"
|
|
1272
1333
|
if include_shared is not None:
|
|
1273
1334
|
scope_qs += f"&include_shared={str(include_shared).lower()}"
|
|
1335
|
+
_window = getattr(args, "window", "") or ""
|
|
1336
|
+
window_qs = f"&window={quote(_window)}" if _window else ""
|
|
1274
1337
|
result = daemon_request(
|
|
1275
1338
|
"GET",
|
|
1276
1339
|
f"/recall?q={quote(args.query)}&limit={args.limit}"
|
|
1277
|
-
f"&session_id={quote(session_id)}{fast_qs}{scope_qs}",
|
|
1340
|
+
f"&session_id={quote(session_id)}{fast_qs}{scope_qs}{window_qs}",
|
|
1278
1341
|
)
|
|
1279
1342
|
if result and "results" in result:
|
|
1280
1343
|
# Format daemon response same as engine response
|
|
@@ -1294,8 +1357,10 @@ def cmd_recall(args: Namespace) -> None:
|
|
|
1294
1357
|
for i, r in enumerate(result["results"], 1):
|
|
1295
1358
|
print(f" {i}. [{r['score']:.2f}] {r['content']}")
|
|
1296
1359
|
return
|
|
1297
|
-
except Exception:
|
|
1298
|
-
|
|
1360
|
+
except Exception as _exc: # noqa: BLE001
|
|
1361
|
+
logger.warning(
|
|
1362
|
+
"Daemon recall failed, falling back to direct engine: %s", _exc
|
|
1363
|
+
)
|
|
1299
1364
|
|
|
1300
1365
|
from superlocalmemory.core.config import SLMConfig
|
|
1301
1366
|
from superlocalmemory.core.engine import MemoryEngine
|
|
@@ -1310,6 +1375,7 @@ def cmd_recall(args: Namespace) -> None:
|
|
|
1310
1375
|
fast=getattr(args, "fast", False),
|
|
1311
1376
|
include_global=include_global,
|
|
1312
1377
|
include_shared=include_shared,
|
|
1378
|
+
window=getattr(args, "window", "") or None,
|
|
1313
1379
|
)
|
|
1314
1380
|
except Exception as exc:
|
|
1315
1381
|
if use_json:
|
|
@@ -1394,12 +1460,32 @@ def cmd_forget(args: Namespace) -> None:
|
|
|
1394
1460
|
from superlocalmemory.core.config import SLMConfig
|
|
1395
1461
|
|
|
1396
1462
|
use_json = getattr(args, 'json', False)
|
|
1463
|
+
dry_run = getattr(args, 'dry_run', False)
|
|
1464
|
+
raw_query = getattr(args, 'query', None)
|
|
1465
|
+
|
|
1466
|
+
# F3: `query` is optional so `slm forget --dry-run` can preview every memory.
|
|
1467
|
+
# Deletion ALWAYS requires an explicit query — a bare `slm forget` must never
|
|
1468
|
+
# mass-delete. In dry-run mode a missing query means "match all" (preview).
|
|
1469
|
+
if raw_query is None and not dry_run:
|
|
1470
|
+
msg = (
|
|
1471
|
+
"A query is required to delete. Preview everything with "
|
|
1472
|
+
"'slm forget --dry-run', or delete matches with 'slm forget <query>'."
|
|
1473
|
+
)
|
|
1474
|
+
if use_json:
|
|
1475
|
+
from superlocalmemory.cli.json_output import json_print
|
|
1476
|
+
json_print("forget", error={"code": "QUERY_REQUIRED", "message": msg})
|
|
1477
|
+
else:
|
|
1478
|
+
print(msg)
|
|
1479
|
+
sys.exit(2)
|
|
1480
|
+
|
|
1481
|
+
query_lower = "" if raw_query is None else raw_query.lower()
|
|
1482
|
+
query_label = raw_query if raw_query is not None else "*all*"
|
|
1483
|
+
|
|
1397
1484
|
try:
|
|
1398
1485
|
config = SLMConfig.load()
|
|
1399
1486
|
engine = MemoryEngine(config)
|
|
1400
1487
|
engine.initialize()
|
|
1401
1488
|
facts = engine._db.get_all_facts(engine.profile_id)
|
|
1402
|
-
query_lower = args.query.lower()
|
|
1403
1489
|
matches = [f for f in facts if query_lower in f.content.lower()]
|
|
1404
1490
|
except Exception as exc:
|
|
1405
1491
|
if use_json:
|
|
@@ -1408,8 +1494,6 @@ def cmd_forget(args: Namespace) -> None:
|
|
|
1408
1494
|
sys.exit(1)
|
|
1409
1495
|
raise
|
|
1410
1496
|
|
|
1411
|
-
dry_run = getattr(args, 'dry_run', False)
|
|
1412
|
-
|
|
1413
1497
|
def delete_fact_authorized_for_cli(fact_id: str) -> None:
|
|
1414
1498
|
from superlocalmemory.core.engine_ingestion import local_trusted_actor_id
|
|
1415
1499
|
from superlocalmemory.core.mutations import delete_fact_authorized
|
|
@@ -1450,12 +1534,12 @@ def cmd_forget(args: Namespace) -> None:
|
|
|
1450
1534
|
"matches": match_items,
|
|
1451
1535
|
"hint": "Add --yes to confirm deletion",
|
|
1452
1536
|
}, next_actions=[
|
|
1453
|
-
{"command": f"slm forget '{
|
|
1537
|
+
{"command": f"slm forget '{query_label}' --json --yes", "description": "Confirm deletion"},
|
|
1454
1538
|
])
|
|
1455
1539
|
return
|
|
1456
1540
|
|
|
1457
1541
|
if not matches:
|
|
1458
|
-
print(f"No memories matching '{
|
|
1542
|
+
print(f"No memories matching '{query_label}'")
|
|
1459
1543
|
return
|
|
1460
1544
|
print(f"Found {len(matches)} matching memories:")
|
|
1461
1545
|
for f in matches[:10]:
|
|
@@ -1835,8 +1919,8 @@ def cmd_health(args: Namespace) -> None:
|
|
|
1835
1919
|
|
|
1836
1920
|
print("Math Layer Health:")
|
|
1837
1921
|
print(f" Total facts: {len(facts)}")
|
|
1838
|
-
print(f"
|
|
1839
|
-
print(f"
|
|
1922
|
+
print(f" Math layer indexed: {fisher_count}/{len(facts)}")
|
|
1923
|
+
print(f" Lifecycle positioned: {langevin_count}/{len(facts)}")
|
|
1840
1924
|
print(f" Mode: {config.mode.value.upper()}")
|
|
1841
1925
|
|
|
1842
1926
|
|