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
|
@@ -11,6 +11,7 @@ explicit process contract and always wins over persisted configuration.
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
13
|
import json
|
|
14
|
+
import logging
|
|
14
15
|
import os
|
|
15
16
|
from pathlib import Path
|
|
16
17
|
|
|
@@ -24,6 +25,8 @@ _DURABLE_IDENTITY_NAMES = frozenset(
|
|
|
24
25
|
},
|
|
25
26
|
)
|
|
26
27
|
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
27
30
|
|
|
28
31
|
class DataRootConflictError(RuntimeError):
|
|
29
32
|
"""Raised when two state-bearing roots make startup ambiguous."""
|
|
@@ -122,17 +125,53 @@ def assert_no_durable_root_conflict(
|
|
|
122
125
|
*,
|
|
123
126
|
home: str | Path | None = None,
|
|
124
127
|
) -> None:
|
|
125
|
-
"""Refuse ambiguous startup when
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
128
|
+
"""Refuse *ambiguous* startup when two state roots make the live namespace unclear.
|
|
129
|
+
|
|
130
|
+
The root actually selected for this process is always inspected: an
|
|
131
|
+
unreadable selected root fails closed. Beyond that, a conflict is only raised
|
|
132
|
+
when the selection was *implicit* — resolved from the legacy
|
|
133
|
+
``config.json:base_dir`` relocation hint — and a separately-populated default
|
|
134
|
+
root leaves it genuinely ambiguous which namespace is live.
|
|
135
|
+
|
|
136
|
+
An explicit environment selection (``SLM_DATA_DIR`` / ``SL_MEMORY_PATH`` /
|
|
137
|
+
``SLM_HOME``) is an unambiguous operator contract: a separately-populated
|
|
138
|
+
default root is then a deliberate multi-root / per-team / second-instance
|
|
139
|
+
layout, not an ambiguity, so startup proceeds. If the explicitly chosen root
|
|
140
|
+
is empty while the old default still holds data, that likely-mistyped path is
|
|
141
|
+
surfaced as a warning rather than a hard block.
|
|
142
|
+
|
|
143
|
+
This check never writes, copies, or deletes data.
|
|
129
144
|
"""
|
|
130
145
|
home_path = _canonical_path(home if home is not None else Path.home())
|
|
131
146
|
default_root = _canonical_path(home_path / ".superlocalmemory")
|
|
132
147
|
selected_root = canonical_data_root(home=home_path)
|
|
133
148
|
if selected_root == default_root:
|
|
134
149
|
return
|
|
150
|
+
|
|
151
|
+
# The root about to be used must be inspectable regardless of how it was
|
|
152
|
+
# chosen; an unreadable selected root fails closed inside _durable_markers.
|
|
135
153
|
selected_markers = _durable_markers(selected_root)
|
|
154
|
+
|
|
155
|
+
if environment_data_root() is not None:
|
|
156
|
+
# Explicit selection wins; a populated default root is a deliberate
|
|
157
|
+
# multi-root layout, not an ambiguity. Only warn on the "empty new root
|
|
158
|
+
# while the old default still holds data" case so a wrong SLM_DATA_DIR
|
|
159
|
+
# stays visible. Inspection of the unused default never blocks startup.
|
|
160
|
+
if not selected_markers:
|
|
161
|
+
try:
|
|
162
|
+
default_has_data = bool(_durable_markers(default_root))
|
|
163
|
+
except DataRootConflictError:
|
|
164
|
+
default_has_data = False
|
|
165
|
+
if default_has_data:
|
|
166
|
+
logger.warning(
|
|
167
|
+
"SLM_DATA_DIR selects an empty state root (%s) while the "
|
|
168
|
+
"default root (%s) still holds data; starting with the empty "
|
|
169
|
+
"root as explicitly requested.",
|
|
170
|
+
selected_root,
|
|
171
|
+
default_root,
|
|
172
|
+
)
|
|
173
|
+
return
|
|
174
|
+
|
|
136
175
|
default_markers = _durable_markers(default_root)
|
|
137
176
|
if not selected_markers or not default_markers:
|
|
138
177
|
return
|
|
@@ -102,13 +102,20 @@ class EventBus:
|
|
|
102
102
|
logger.info("EventBus initialized: db=%s", self.db_path)
|
|
103
103
|
|
|
104
104
|
def _init_schema(self) -> None:
|
|
105
|
-
"""Create the memory_events table if it does not exist.
|
|
105
|
+
"""Create the memory_events table if it does not exist.
|
|
106
|
+
|
|
107
|
+
Self-migrates a pre-isolation DB (memory_events without profile_id) so a
|
|
108
|
+
dashboard viewing profile A never sees profile B's events. This table is
|
|
109
|
+
store-owned (created here, not by the migration runner), so the store
|
|
110
|
+
owns its upgrade. Existing rows backfill to the 'default' profile.
|
|
111
|
+
"""
|
|
106
112
|
conn = sqlite3.connect(str(self.db_path))
|
|
107
113
|
try:
|
|
108
114
|
cur = conn.cursor()
|
|
109
115
|
cur.execute("""
|
|
110
116
|
CREATE TABLE IF NOT EXISTS memory_events (
|
|
111
117
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
118
|
+
profile_id TEXT NOT NULL DEFAULT 'default',
|
|
112
119
|
event_type TEXT NOT NULL,
|
|
113
120
|
memory_id INTEGER,
|
|
114
121
|
source_agent TEXT DEFAULT 'user',
|
|
@@ -119,13 +126,42 @@ class EventBus:
|
|
|
119
126
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
120
127
|
)
|
|
121
128
|
""")
|
|
129
|
+
existing = {r[1] for r in cur.execute(
|
|
130
|
+
"PRAGMA table_info(memory_events)").fetchall()}
|
|
131
|
+
if "profile_id" not in existing:
|
|
132
|
+
cur.execute(
|
|
133
|
+
"ALTER TABLE memory_events "
|
|
134
|
+
"ADD COLUMN profile_id TEXT NOT NULL DEFAULT 'default'"
|
|
135
|
+
)
|
|
122
136
|
cur.execute("CREATE INDEX IF NOT EXISTS idx_events_type ON memory_events(event_type)")
|
|
123
137
|
cur.execute("CREATE INDEX IF NOT EXISTS idx_events_created ON memory_events(created_at)")
|
|
124
138
|
cur.execute("CREATE INDEX IF NOT EXISTS idx_events_tier ON memory_events(tier)")
|
|
139
|
+
cur.execute("CREATE INDEX IF NOT EXISTS idx_events_profile ON memory_events(profile_id, id)")
|
|
125
140
|
conn.commit()
|
|
126
141
|
finally:
|
|
127
142
|
conn.close()
|
|
128
143
|
|
|
144
|
+
@staticmethod
|
|
145
|
+
def _resolve_profile(profile_id: Optional[str]) -> str:
|
|
146
|
+
"""Resolve the active profile for an event when not passed explicitly.
|
|
147
|
+
|
|
148
|
+
Uses the request-runtime helper (ContextVar for HTTP, else the
|
|
149
|
+
profiles.json active_profile cache that every switch keeps in sync) so
|
|
150
|
+
MCP-in-daemon and CLI emits are attributed correctly too. Lazy import
|
|
151
|
+
keeps the infra layer free of a hard server dependency; any failure
|
|
152
|
+
falls back to 'default'.
|
|
153
|
+
"""
|
|
154
|
+
if profile_id:
|
|
155
|
+
return profile_id
|
|
156
|
+
try:
|
|
157
|
+
from superlocalmemory.server.routes.helpers import get_active_profile
|
|
158
|
+
resolved = get_active_profile()
|
|
159
|
+
if resolved:
|
|
160
|
+
return resolved
|
|
161
|
+
except Exception:
|
|
162
|
+
pass
|
|
163
|
+
return "default"
|
|
164
|
+
|
|
129
165
|
def emit(
|
|
130
166
|
self,
|
|
131
167
|
event_type: str,
|
|
@@ -134,8 +170,14 @@ class EventBus:
|
|
|
134
170
|
source_agent: str = "user",
|
|
135
171
|
source_protocol: str = "internal",
|
|
136
172
|
importance: int = 5,
|
|
173
|
+
profile_id: Optional[str] = None,
|
|
137
174
|
) -> Optional[int]:
|
|
138
|
-
"""Emit an event to all subscribers and persist to database.
|
|
175
|
+
"""Emit an event to all subscribers and persist to database.
|
|
176
|
+
|
|
177
|
+
``profile_id`` scopes the event to a memory profile. When omitted it is
|
|
178
|
+
resolved from the active profile so a dashboard viewing one profile
|
|
179
|
+
never sees another profile's real-time or historical events.
|
|
180
|
+
"""
|
|
139
181
|
if event_type not in VALID_EVENT_TYPES:
|
|
140
182
|
raise ValueError(
|
|
141
183
|
f"Invalid event type: {event_type}. "
|
|
@@ -143,6 +185,7 @@ class EventBus:
|
|
|
143
185
|
)
|
|
144
186
|
|
|
145
187
|
importance = max(1, min(10, importance))
|
|
188
|
+
profile_id = self._resolve_profile(profile_id)
|
|
146
189
|
|
|
147
190
|
now = datetime.now(timezone.utc).isoformat()
|
|
148
191
|
with self._counter_lock:
|
|
@@ -151,6 +194,7 @@ class EventBus:
|
|
|
151
194
|
|
|
152
195
|
event: Dict[str, Any] = {
|
|
153
196
|
"seq": seq,
|
|
197
|
+
"profile_id": profile_id,
|
|
154
198
|
"event_type": event_type,
|
|
155
199
|
"memory_id": memory_id,
|
|
156
200
|
"source_agent": source_agent,
|
|
@@ -177,18 +221,24 @@ class EventBus:
|
|
|
177
221
|
event_type, event_id, memory_id,
|
|
178
222
|
)
|
|
179
223
|
|
|
180
|
-
# Auto-prune heuristic
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
224
|
+
# Auto-prune heuristic. Decide + reset the counter atomically under the
|
|
225
|
+
# lock so concurrent emit() calls cannot both cross the threshold and
|
|
226
|
+
# double-run the prune; run the prune itself OUTSIDE the lock.
|
|
227
|
+
should_prune = False
|
|
228
|
+
with self._counter_lock:
|
|
229
|
+
self._write_count += 1
|
|
230
|
+
if (
|
|
231
|
+
self._write_count >= 100
|
|
232
|
+
or (datetime.now() - self._last_prune).total_seconds() > 86400
|
|
233
|
+
):
|
|
234
|
+
should_prune = True
|
|
235
|
+
self._write_count = 0
|
|
236
|
+
self._last_prune = datetime.now()
|
|
237
|
+
if should_prune:
|
|
186
238
|
try:
|
|
187
239
|
self.prune_events()
|
|
188
240
|
except Exception:
|
|
189
241
|
pass
|
|
190
|
-
self._write_count = 0
|
|
191
|
-
self._last_prune = datetime.now()
|
|
192
242
|
|
|
193
243
|
return event_id
|
|
194
244
|
|
|
@@ -202,10 +252,12 @@ class EventBus:
|
|
|
202
252
|
try:
|
|
203
253
|
cur = conn.cursor()
|
|
204
254
|
cur.execute(
|
|
205
|
-
"INSERT INTO memory_events (event_type, memory_id,
|
|
206
|
-
" source_protocol, payload, importance, tier,
|
|
207
|
-
"
|
|
208
|
-
(
|
|
255
|
+
"INSERT INTO memory_events (profile_id, event_type, memory_id,"
|
|
256
|
+
" source_agent, source_protocol, payload, importance, tier,"
|
|
257
|
+
" created_at)"
|
|
258
|
+
" VALUES (?, ?, ?, ?, ?, ?, ?, 'hot', ?)",
|
|
259
|
+
(event.get("profile_id", "default"), event["event_type"],
|
|
260
|
+
event.get("memory_id"),
|
|
209
261
|
event["source_agent"], event["source_protocol"],
|
|
210
262
|
json.dumps(event["payload"]), event["importance"],
|
|
211
263
|
event["timestamp"]),
|
|
@@ -253,9 +305,16 @@ class EventBus:
|
|
|
253
305
|
since_id: Optional[int] = None,
|
|
254
306
|
limit: int = 50,
|
|
255
307
|
event_type: Optional[str] = None,
|
|
308
|
+
profile_id: Optional[str] = None,
|
|
256
309
|
) -> List[dict]:
|
|
257
|
-
"""Get recent events from the database.
|
|
310
|
+
"""Get recent events from the database, scoped to a profile.
|
|
311
|
+
|
|
312
|
+
``profile_id`` defaults to the active profile so callers never leak
|
|
313
|
+
another profile's events. Pass ``profile_id="*"`` to bypass scoping
|
|
314
|
+
(internal maintenance/pruning only — never a client-facing path).
|
|
315
|
+
"""
|
|
258
316
|
limit = min(limit, 200)
|
|
317
|
+
scope = profile_id if profile_id == "*" else self._resolve_profile(profile_id)
|
|
259
318
|
|
|
260
319
|
try:
|
|
261
320
|
conn = sqlite3.connect(str(self.db_path))
|
|
@@ -263,10 +322,15 @@ class EventBus:
|
|
|
263
322
|
cur = conn.cursor()
|
|
264
323
|
|
|
265
324
|
query = ("SELECT id, event_type, memory_id, source_agent,"
|
|
266
|
-
" source_protocol, payload, importance, tier, created_at"
|
|
325
|
+
" source_protocol, payload, importance, tier, created_at,"
|
|
326
|
+
" profile_id"
|
|
267
327
|
" FROM memory_events WHERE 1=1")
|
|
268
328
|
params: List[Any] = []
|
|
269
329
|
|
|
330
|
+
if scope != "*":
|
|
331
|
+
query += " AND profile_id = ?"
|
|
332
|
+
params.append(scope)
|
|
333
|
+
|
|
270
334
|
if since_id is not None:
|
|
271
335
|
query += " AND id > ?"
|
|
272
336
|
params.append(since_id)
|
|
@@ -293,7 +357,7 @@ class EventBus:
|
|
|
293
357
|
"id": row[0], "event_type": row[1], "memory_id": row[2],
|
|
294
358
|
"source_agent": row[3], "source_protocol": row[4],
|
|
295
359
|
"payload": parsed, "importance": row[6],
|
|
296
|
-
"tier": row[7], "timestamp": row[8],
|
|
360
|
+
"tier": row[7], "timestamp": row[8], "profile_id": row[9],
|
|
297
361
|
})
|
|
298
362
|
return events
|
|
299
363
|
|
|
@@ -306,19 +370,32 @@ class EventBus:
|
|
|
306
370
|
with self._buffer_lock:
|
|
307
371
|
return [e for e in self._buffer if e.get("seq", 0) > since_seq]
|
|
308
372
|
|
|
309
|
-
def get_event_stats(self) -> dict:
|
|
310
|
-
"""Get event system statistics.
|
|
373
|
+
def get_event_stats(self, profile_id: Optional[str] = None) -> dict:
|
|
374
|
+
"""Get event system statistics, scoped to a profile.
|
|
375
|
+
|
|
376
|
+
``profile_id`` defaults to the active profile so dashboard event
|
|
377
|
+
counts never blend across profiles.
|
|
378
|
+
"""
|
|
379
|
+
scope = self._resolve_profile(profile_id)
|
|
311
380
|
try:
|
|
312
381
|
conn = sqlite3.connect(str(self.db_path))
|
|
313
382
|
try:
|
|
314
383
|
cur = conn.cursor()
|
|
315
384
|
|
|
316
|
-
total = cur.execute(
|
|
317
|
-
|
|
385
|
+
total = cur.execute(
|
|
386
|
+
"SELECT COUNT(*) FROM memory_events WHERE profile_id = ?",
|
|
387
|
+
(scope,)).fetchone()[0]
|
|
388
|
+
cur.execute(
|
|
389
|
+
"SELECT event_type, COUNT(*) FROM memory_events "
|
|
390
|
+
"WHERE profile_id = ? GROUP BY event_type", (scope,))
|
|
318
391
|
by_type = dict(cur.fetchall())
|
|
319
|
-
cur.execute(
|
|
392
|
+
cur.execute(
|
|
393
|
+
"SELECT tier, COUNT(*) FROM memory_events "
|
|
394
|
+
"WHERE profile_id = ? GROUP BY tier", (scope,))
|
|
320
395
|
by_tier = dict(cur.fetchall())
|
|
321
|
-
cur.execute(
|
|
396
|
+
cur.execute(
|
|
397
|
+
"SELECT COUNT(*) FROM memory_events WHERE profile_id = ? "
|
|
398
|
+
"AND created_at >= datetime('now', '-24 hours')", (scope,))
|
|
322
399
|
last_24h = cur.fetchone()[0]
|
|
323
400
|
finally:
|
|
324
401
|
conn.close()
|
|
@@ -343,7 +420,13 @@ class EventBus:
|
|
|
343
420
|
warm_hours: int = DEFAULT_WARM_HOURS,
|
|
344
421
|
cold_hours: int = DEFAULT_COLD_HOURS,
|
|
345
422
|
) -> dict:
|
|
346
|
-
"""Apply tiered retention policy to persisted events.
|
|
423
|
+
"""Apply tiered retention policy to persisted events.
|
|
424
|
+
|
|
425
|
+
INTENTIONALLY GLOBAL (all profiles): this is age/tier-based housekeeping
|
|
426
|
+
of the shared event log, not a per-tenant read or retention-policy
|
|
427
|
+
surface. Tenant isolation of event CONTENT is enforced on read via the
|
|
428
|
+
profile_id filter; this sweep only demotes/expires old rows by age.
|
|
429
|
+
"""
|
|
347
430
|
try:
|
|
348
431
|
conn = sqlite3.connect(str(self.db_path))
|
|
349
432
|
try:
|
|
@@ -118,6 +118,22 @@ class RateLimiter:
|
|
|
118
118
|
del self._requests[k]
|
|
119
119
|
return len(stale)
|
|
120
120
|
|
|
121
|
+
def configure(
|
|
122
|
+
self,
|
|
123
|
+
max_requests: int | None = None,
|
|
124
|
+
window_seconds: int | None = None,
|
|
125
|
+
) -> None:
|
|
126
|
+
"""Reconfigure limits at runtime (thread-safe).
|
|
127
|
+
|
|
128
|
+
Recorded request timestamps are preserved; only the ceilings change,
|
|
129
|
+
so a raised limit takes effect immediately for the current window.
|
|
130
|
+
"""
|
|
131
|
+
with self._lock:
|
|
132
|
+
if max_requests is not None:
|
|
133
|
+
self.max_requests = max(1, int(max_requests))
|
|
134
|
+
if window_seconds is not None:
|
|
135
|
+
self.window = max(1, int(window_seconds))
|
|
136
|
+
|
|
121
137
|
def get_stats(self) -> dict:
|
|
122
138
|
"""Return a snapshot of limiter state."""
|
|
123
139
|
with self._lock:
|
|
@@ -133,3 +149,80 @@ class RateLimiter:
|
|
|
133
149
|
# ---------------------------------------------------------------------------
|
|
134
150
|
write_limiter = RateLimiter(max_requests=WRITE_LIMIT, window_seconds=WINDOW_SECONDS)
|
|
135
151
|
read_limiter = RateLimiter(max_requests=READ_LIMIT, window_seconds=WINDOW_SECONDS)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# Runtime-configurable limits (task #47) — dashboard-editable thresholds.
|
|
156
|
+
#
|
|
157
|
+
# The enforcement middleware builds its own limiter instances; it registers
|
|
158
|
+
# them here by role so a single set_limits() call reconfigures every live
|
|
159
|
+
# limiter at once (no restart). Loopback limiters derive from write/read,
|
|
160
|
+
# matching the startup derivation in unified_daemon.
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
_MANAGED: List[Tuple[str, "RateLimiter"]] = []
|
|
164
|
+
_MANAGED_LOCK = threading.Lock()
|
|
165
|
+
_CURRENT: Dict[str, int] = {
|
|
166
|
+
"write": WRITE_LIMIT, "read": READ_LIMIT, "window": WINDOW_SECONDS,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _loopback_write(write: int) -> int:
|
|
171
|
+
return max(300, int(write) * 10)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _loopback_read(read: int) -> int:
|
|
175
|
+
return max(2000, int(read) * 20)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def register_managed(role: str, limiter: "RateLimiter") -> None:
|
|
179
|
+
"""Register an enforcement limiter so set_limits() can reconfigure it.
|
|
180
|
+
|
|
181
|
+
role is one of: 'write', 'read', 'lb_write', 'lb_read'.
|
|
182
|
+
"""
|
|
183
|
+
with _MANAGED_LOCK:
|
|
184
|
+
_MANAGED.append((role, limiter))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def reset_managed() -> None:
|
|
188
|
+
"""Drop all registered limiters (app teardown / test isolation)."""
|
|
189
|
+
with _MANAGED_LOCK:
|
|
190
|
+
_MANAGED.clear()
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def get_limits() -> Dict[str, int]:
|
|
194
|
+
"""Return the current effective write/read/window limits."""
|
|
195
|
+
with _MANAGED_LOCK:
|
|
196
|
+
return dict(_CURRENT)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def set_limits(
|
|
200
|
+
write: int | None = None,
|
|
201
|
+
read: int | None = None,
|
|
202
|
+
window: int | None = None,
|
|
203
|
+
) -> Dict[str, int]:
|
|
204
|
+
"""Update limits and reconfigure every registered limiter live.
|
|
205
|
+
|
|
206
|
+
Partial updates are supported (pass only what changes). Loopback
|
|
207
|
+
limiters re-derive from the new write/read. Returns the new effective
|
|
208
|
+
limits. Values are floored at 1.
|
|
209
|
+
"""
|
|
210
|
+
with _MANAGED_LOCK:
|
|
211
|
+
if write is not None:
|
|
212
|
+
_CURRENT["write"] = max(1, int(write))
|
|
213
|
+
if read is not None:
|
|
214
|
+
_CURRENT["read"] = max(1, int(read))
|
|
215
|
+
if window is not None:
|
|
216
|
+
_CURRENT["window"] = max(1, int(window))
|
|
217
|
+
w, r, win = _CURRENT["write"], _CURRENT["read"], _CURRENT["window"]
|
|
218
|
+
lb_w, lb_r = _loopback_write(w), _loopback_read(r)
|
|
219
|
+
for role, limiter in _MANAGED:
|
|
220
|
+
if role == "write":
|
|
221
|
+
limiter.configure(max_requests=w, window_seconds=win)
|
|
222
|
+
elif role == "read":
|
|
223
|
+
limiter.configure(max_requests=r, window_seconds=win)
|
|
224
|
+
elif role == "lb_write":
|
|
225
|
+
limiter.configure(max_requests=lb_w, window_seconds=win)
|
|
226
|
+
elif role == "lb_read":
|
|
227
|
+
limiter.configure(max_requests=lb_r, window_seconds=win)
|
|
228
|
+
return dict(_CURRENT)
|
|
@@ -175,7 +175,10 @@ def stop_adapter(name: str) -> dict:
|
|
|
175
175
|
proc.terminate()
|
|
176
176
|
proc.wait(timeout=10)
|
|
177
177
|
except ImportError:
|
|
178
|
-
os.kill(pid, 15)
|
|
178
|
+
# No psutil: best-effort termination. On Windows os.kill(pid, 15) maps to
|
|
179
|
+
# TerminateProcess (a hard kill, not a graceful SIGTERM) — psutil's
|
|
180
|
+
# terminate() does the same there, so behavior is equivalent either way.
|
|
181
|
+
os.kill(pid, 15)
|
|
179
182
|
except Exception:
|
|
180
183
|
pass
|
|
181
184
|
|
|
@@ -42,7 +42,7 @@ def store_credential(service: str, key: str, value: str) -> bool:
|
|
|
42
42
|
except Exception:
|
|
43
43
|
pass
|
|
44
44
|
|
|
45
|
-
# Fallback:
|
|
45
|
+
# Fallback: restricted-permission file (0600) when no OS keychain is present
|
|
46
46
|
try:
|
|
47
47
|
cred_dir = _credential_dir()
|
|
48
48
|
cred_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -65,6 +65,7 @@ TECH_CATEGORIES: dict[str, list[str]] = {
|
|
|
65
65
|
_SCHEMA = """
|
|
66
66
|
CREATE TABLE IF NOT EXISTS transferable_patterns (
|
|
67
67
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
68
|
+
profile_id TEXT NOT NULL DEFAULT 'default',
|
|
68
69
|
pattern_type TEXT NOT NULL DEFAULT 'preference',
|
|
69
70
|
key TEXT NOT NULL,
|
|
70
71
|
value TEXT NOT NULL,
|
|
@@ -75,7 +76,7 @@ CREATE TABLE IF NOT EXISTS transferable_patterns (
|
|
|
75
76
|
contradictions TEXT DEFAULT '[]',
|
|
76
77
|
first_seen TEXT,
|
|
77
78
|
last_seen TEXT,
|
|
78
|
-
UNIQUE(pattern_type, key)
|
|
79
|
+
UNIQUE(profile_id, pattern_type, key)
|
|
79
80
|
);
|
|
80
81
|
|
|
81
82
|
CREATE TABLE IF NOT EXISTS memories (
|
|
@@ -115,25 +116,29 @@ class CrossProjectAggregator:
|
|
|
115
116
|
# Step 2: Merge with temporal decay
|
|
116
117
|
merged = self._merge_with_decay(profile_patterns)
|
|
117
118
|
|
|
118
|
-
# Step 3: Detect contradictions
|
|
119
|
+
# Step 3: Detect contradictions (scoped to the target profile)
|
|
119
120
|
for key, pattern_data in merged.items():
|
|
120
121
|
pattern_data["contradictions"] = self._detect_contradictions(
|
|
121
|
-
key, pattern_data
|
|
122
|
+
key, pattern_data, target_profile
|
|
122
123
|
)
|
|
123
124
|
|
|
124
|
-
# Step 4: Store and return
|
|
125
|
-
self._store_patterns(merged)
|
|
125
|
+
# Step 4: Store and return (scoped to the target profile)
|
|
126
|
+
self._store_patterns(merged, target_profile)
|
|
126
127
|
return [{"key": k, **v} for k, v in merged.items()]
|
|
127
128
|
|
|
128
|
-
def get_preferences(
|
|
129
|
-
|
|
129
|
+
def get_preferences(
|
|
130
|
+
self,
|
|
131
|
+
profile_id: str = "default",
|
|
132
|
+
min_confidence: float = 0.6,
|
|
133
|
+
) -> dict[str, dict]:
|
|
134
|
+
"""Retrieve stored transferable preferences for *profile_id* above *min_confidence*."""
|
|
130
135
|
conn = sqlite3.connect(str(self._db_path))
|
|
131
136
|
conn.row_factory = sqlite3.Row
|
|
132
137
|
try:
|
|
133
138
|
cur = conn.execute(
|
|
134
139
|
"SELECT * FROM transferable_patterns "
|
|
135
|
-
"WHERE confidence >= ? ORDER BY confidence DESC",
|
|
136
|
-
(min_confidence
|
|
140
|
+
"WHERE profile_id = ? AND confidence >= ? ORDER BY confidence DESC",
|
|
141
|
+
(profile_id, min_confidence),
|
|
137
142
|
)
|
|
138
143
|
result: dict[str, dict] = {}
|
|
139
144
|
for row in cur.fetchall():
|
|
@@ -277,18 +282,18 @@ class CrossProjectAggregator:
|
|
|
277
282
|
}
|
|
278
283
|
|
|
279
284
|
def _detect_contradictions(
|
|
280
|
-
self, pattern_key: str, pattern_data: dict
|
|
285
|
+
self, pattern_key: str, pattern_data: dict, profile_id: str = "default"
|
|
281
286
|
) -> list[str]:
|
|
282
287
|
contradictions: list[str] = []
|
|
283
288
|
|
|
284
|
-
# Check stored value vs new value
|
|
289
|
+
# Check stored value vs new value, scoped to this profile.
|
|
285
290
|
conn = sqlite3.connect(str(self._db_path))
|
|
286
291
|
conn.row_factory = sqlite3.Row
|
|
287
292
|
try:
|
|
288
293
|
cur = conn.execute(
|
|
289
294
|
"SELECT value, last_seen FROM transferable_patterns "
|
|
290
|
-
"WHERE key = ? AND pattern_type = 'preference'",
|
|
291
|
-
(pattern_key
|
|
295
|
+
"WHERE profile_id = ? AND key = ? AND pattern_type = 'preference'",
|
|
296
|
+
(profile_id, pattern_key),
|
|
292
297
|
)
|
|
293
298
|
row = cur.fetchone()
|
|
294
299
|
if row:
|
|
@@ -316,18 +321,21 @@ class CrossProjectAggregator:
|
|
|
316
321
|
finally:
|
|
317
322
|
conn.close()
|
|
318
323
|
|
|
319
|
-
def _store_patterns(
|
|
324
|
+
def _store_patterns(
|
|
325
|
+
self, merged: dict[str, dict], profile_id: str = "default"
|
|
326
|
+
) -> None:
|
|
327
|
+
"""Persist merged patterns for *profile_id*. Each profile has isolated rows."""
|
|
320
328
|
conn = sqlite3.connect(str(self._db_path))
|
|
321
329
|
now = datetime.now(UTC).isoformat()
|
|
322
330
|
try:
|
|
323
331
|
for key, data in merged.items():
|
|
324
332
|
conn.execute(
|
|
325
333
|
"""INSERT INTO transferable_patterns
|
|
326
|
-
(pattern_type, key, value, confidence,
|
|
327
|
-
profiles_seen, decay_factor,
|
|
328
|
-
first_seen, last_seen)
|
|
329
|
-
VALUES ('preference', ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
330
|
-
ON CONFLICT(pattern_type, key) DO UPDATE SET
|
|
334
|
+
(profile_id, pattern_type, key, value, confidence,
|
|
335
|
+
evidence_count, profiles_seen, decay_factor,
|
|
336
|
+
contradictions, first_seen, last_seen)
|
|
337
|
+
VALUES (?, 'preference', ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
338
|
+
ON CONFLICT(profile_id, pattern_type, key) DO UPDATE SET
|
|
331
339
|
value = excluded.value,
|
|
332
340
|
confidence = excluded.confidence,
|
|
333
341
|
evidence_count = excluded.evidence_count,
|
|
@@ -337,6 +345,7 @@ class CrossProjectAggregator:
|
|
|
337
345
|
last_seen = excluded.last_seen
|
|
338
346
|
""",
|
|
339
347
|
(
|
|
348
|
+
profile_id,
|
|
340
349
|
key,
|
|
341
350
|
data["value"],
|
|
342
351
|
data["confidence"],
|
|
@@ -34,6 +34,8 @@ import sqlite3
|
|
|
34
34
|
from datetime import datetime, timedelta, timezone
|
|
35
35
|
from typing import Final
|
|
36
36
|
|
|
37
|
+
from superlocalmemory.learning.model_cache import invalidate as invalidate_model_cache
|
|
38
|
+
|
|
37
39
|
logger = logging.getLogger(__name__)
|
|
38
40
|
|
|
39
41
|
|
|
@@ -235,6 +237,7 @@ class ModelRollback:
|
|
|
235
237
|
)
|
|
236
238
|
return False
|
|
237
239
|
|
|
240
|
+
invalidate_model_cache(self._profile_id)
|
|
238
241
|
logger.warning(
|
|
239
242
|
"AUTO-ROLLBACK profile=%s reason=%s observations=%d "
|
|
240
243
|
"baseline_ndcg=%.4f current_ndcg=%.4f",
|
|
@@ -33,6 +33,7 @@ from datetime import datetime, timezone
|
|
|
33
33
|
from pathlib import Path
|
|
34
34
|
from typing import Final
|
|
35
35
|
|
|
36
|
+
from superlocalmemory.learning.model_cache import invalidate as invalidate_model_cache
|
|
36
37
|
from superlocalmemory.learning.ranker_common import (
|
|
37
38
|
_build_training_matrix,
|
|
38
39
|
_compute_eval_metrics,
|
|
@@ -389,6 +390,7 @@ def _promote_candidate(
|
|
|
389
390
|
(json.dumps(meta), candidate_id),
|
|
390
391
|
)
|
|
391
392
|
conn.commit()
|
|
393
|
+
invalidate_model_cache(profile_id)
|
|
392
394
|
return True
|
|
393
395
|
except sqlite3.Error as exc:
|
|
394
396
|
conn.rollback()
|
|
@@ -73,6 +73,24 @@ _DWELL_MAX_MS: Final[int] = 3_600_000 # 1 h
|
|
|
73
73
|
_BUSY_TIMEOUT_MS: Final[int] = 50
|
|
74
74
|
|
|
75
75
|
|
|
76
|
+
def _feed_source_quality(
|
|
77
|
+
memory_db_path: Path,
|
|
78
|
+
rewards: list[tuple[str, str, list[str], float]],
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Fail-soft provenance bridge kept outside the reward transaction."""
|
|
81
|
+
try:
|
|
82
|
+
from superlocalmemory.learning.source_quality import (
|
|
83
|
+
update_source_quality_for_reward_batch,
|
|
84
|
+
)
|
|
85
|
+
update_source_quality_for_reward_batch(
|
|
86
|
+
memory_db_path=memory_db_path,
|
|
87
|
+
learning_db_path=memory_db_path.parent / "learning.db",
|
|
88
|
+
rewards=rewards,
|
|
89
|
+
)
|
|
90
|
+
except Exception as exc: # noqa: BLE001 - reward finalization must survive
|
|
91
|
+
logger.debug("source-quality reward feed failed (non-fatal): %s", exc)
|
|
92
|
+
|
|
93
|
+
|
|
76
94
|
# ---------------------------------------------------------------------------
|
|
77
95
|
# Signal contract — names match the manifest A.1 label formula.
|
|
78
96
|
# ---------------------------------------------------------------------------
|
|
@@ -604,6 +622,7 @@ class EngagementRewardModel:
|
|
|
604
622
|
# lock-scope — SQLite rows are tied to the connection.
|
|
605
623
|
_pid = pending["profile_id"]
|
|
606
624
|
_qid = pending["recall_query_id"]
|
|
625
|
+
_facts_json = str(pending["fact_ids_json"] or "[]")
|
|
607
626
|
except sqlite3.Error as exc:
|
|
608
627
|
logger.debug("finalize_outcome SQLite error: %s", exc)
|
|
609
628
|
return _FALLBACK_REWARD
|
|
@@ -633,6 +652,21 @@ class EngagementRewardModel:
|
|
|
633
652
|
except Exception as exc: # noqa: BLE001 — defence in depth
|
|
634
653
|
logger.debug("feed_recall_settled failed (non-fatal): %s", exc)
|
|
635
654
|
|
|
655
|
+
try:
|
|
656
|
+
_fact_ids = json.loads(_facts_json)
|
|
657
|
+
if not isinstance(_fact_ids, list):
|
|
658
|
+
_fact_ids = []
|
|
659
|
+
except (TypeError, ValueError, json.JSONDecodeError):
|
|
660
|
+
_fact_ids = []
|
|
661
|
+
_feed_source_quality(
|
|
662
|
+
self._db,
|
|
663
|
+
[(
|
|
664
|
+
str(_pid),
|
|
665
|
+
str(outcome_id),
|
|
666
|
+
[str(fid) for fid in _fact_ids if fid],
|
|
667
|
+
float(reward),
|
|
668
|
+
)],
|
|
669
|
+
)
|
|
636
670
|
return reward
|
|
637
671
|
|
|
638
672
|
# ------------------------------------------------------------------
|
|
@@ -749,6 +783,22 @@ class EngagementRewardModel:
|
|
|
749
783
|
except sqlite3.Error:
|
|
750
784
|
conn.execute("ROLLBACK")
|
|
751
785
|
raise
|
|
786
|
+
reward_sources = []
|
|
787
|
+
for item in i_chunk:
|
|
788
|
+
try:
|
|
789
|
+
fact_ids = json.loads(str(item[2] or "[]"))
|
|
790
|
+
except (TypeError, ValueError, json.JSONDecodeError):
|
|
791
|
+
fact_ids = []
|
|
792
|
+
reward_sources.append((
|
|
793
|
+
str(item[1]),
|
|
794
|
+
str(item[0]),
|
|
795
|
+
(
|
|
796
|
+
[str(fid) for fid in fact_ids if fid]
|
|
797
|
+
if isinstance(fact_ids, list) else []
|
|
798
|
+
),
|
|
799
|
+
float(item[4]),
|
|
800
|
+
))
|
|
801
|
+
_feed_source_quality(self._db, reward_sources)
|
|
752
802
|
except sqlite3.Error as exc: # pragma: no cover — defensive
|
|
753
803
|
logger.debug("reap_stale SQLite error: %s", exc)
|
|
754
804
|
return written
|