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,153 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under AGPL-3.0-or-later - see LICENSE file
|
|
3
|
+
"""Read-only persisted telemetry used by the Living Brain routes."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import sqlite3
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger("superlocalmemory.routes.learning")
|
|
12
|
+
|
|
13
|
+
SOURCE_SCORE_LIMIT = 50
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ReadOnlyRankerStore:
|
|
17
|
+
"""Minimal model-cache adapter that never creates or migrates tables."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, db_path: Path) -> None:
|
|
20
|
+
self._db_path = Path(db_path)
|
|
21
|
+
|
|
22
|
+
def count_signals(self, profile_id: str) -> int:
|
|
23
|
+
connection = _readonly_connection(self._db_path)
|
|
24
|
+
try:
|
|
25
|
+
row = connection.execute(
|
|
26
|
+
"SELECT COUNT(*) AS count FROM learning_signals "
|
|
27
|
+
"WHERE profile_id = ?",
|
|
28
|
+
(profile_id,),
|
|
29
|
+
).fetchone()
|
|
30
|
+
return int(row["count"] if row else 0)
|
|
31
|
+
finally:
|
|
32
|
+
connection.close()
|
|
33
|
+
|
|
34
|
+
def load_active_model(self, profile_id: str) -> dict | None:
|
|
35
|
+
connection = _readonly_connection(self._db_path)
|
|
36
|
+
try:
|
|
37
|
+
row = connection.execute(
|
|
38
|
+
"SELECT state_bytes, bytes_sha256, feature_names, trained_at, "
|
|
39
|
+
"model_version FROM learning_model_state "
|
|
40
|
+
"WHERE profile_id = ? AND is_active = 1 LIMIT 1",
|
|
41
|
+
(profile_id,),
|
|
42
|
+
).fetchone()
|
|
43
|
+
finally:
|
|
44
|
+
connection.close()
|
|
45
|
+
if row is None:
|
|
46
|
+
return None
|
|
47
|
+
return {
|
|
48
|
+
"state_bytes": bytes(row["state_bytes"]),
|
|
49
|
+
"bytes_sha256": row["bytes_sha256"],
|
|
50
|
+
"feature_names": row["feature_names"],
|
|
51
|
+
"trained_at": row["trained_at"],
|
|
52
|
+
"model_version": row["model_version"],
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _readonly_connection(db_path: Path) -> sqlite3.Connection:
|
|
57
|
+
connection = sqlite3.connect(
|
|
58
|
+
f"file:{db_path}?mode=ro", uri=True, timeout=1.0,
|
|
59
|
+
)
|
|
60
|
+
connection.execute("PRAGMA busy_timeout=1000")
|
|
61
|
+
connection.row_factory = sqlite3.Row
|
|
62
|
+
return connection
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def sqlite_status(exc: sqlite3.Error) -> str:
|
|
66
|
+
"""Return a stable, non-sensitive dashboard status for SQLite failures."""
|
|
67
|
+
message = str(exc).lower()
|
|
68
|
+
return (
|
|
69
|
+
"database_busy"
|
|
70
|
+
if "locked" in message or "busy" in message
|
|
71
|
+
else "query_error"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _table_exists(connection: sqlite3.Connection, table: str) -> bool:
|
|
76
|
+
row = connection.execute(
|
|
77
|
+
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
|
|
78
|
+
(table,),
|
|
79
|
+
).fetchone()
|
|
80
|
+
return row is not None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def load_source_quality_state(profile_id: str, db_path: Path) -> dict:
|
|
84
|
+
"""Read bounded source scores plus the profile's full source count."""
|
|
85
|
+
if not db_path.exists():
|
|
86
|
+
return {
|
|
87
|
+
"scores": {}, "tracked_sources": 0,
|
|
88
|
+
"status": "missing_database",
|
|
89
|
+
}
|
|
90
|
+
try:
|
|
91
|
+
connection = _readonly_connection(db_path)
|
|
92
|
+
try:
|
|
93
|
+
if not _table_exists(connection, "source_quality"):
|
|
94
|
+
return {
|
|
95
|
+
"scores": {}, "tracked_sources": 0,
|
|
96
|
+
"status": "missing_table",
|
|
97
|
+
}
|
|
98
|
+
count_row = connection.execute(
|
|
99
|
+
"SELECT COUNT(DISTINCT source_id) AS count "
|
|
100
|
+
"FROM source_quality WHERE profile_id = ? "
|
|
101
|
+
"AND source_id IS NOT NULL AND source_id != ''",
|
|
102
|
+
(profile_id,),
|
|
103
|
+
).fetchone()
|
|
104
|
+
rows = connection.execute(
|
|
105
|
+
"SELECT source_id, alpha, beta FROM source_quality "
|
|
106
|
+
"WHERE profile_id = ? "
|
|
107
|
+
"ORDER BY updated_at DESC LIMIT ?",
|
|
108
|
+
(profile_id, SOURCE_SCORE_LIMIT),
|
|
109
|
+
).fetchall()
|
|
110
|
+
finally:
|
|
111
|
+
connection.close()
|
|
112
|
+
except sqlite3.Error as exc:
|
|
113
|
+
status = sqlite_status(exc)
|
|
114
|
+
logger.warning("source quality telemetry %s: %s", status, exc)
|
|
115
|
+
return {"scores": {}, "tracked_sources": 0, "status": status}
|
|
116
|
+
|
|
117
|
+
scores: dict[str, float] = {}
|
|
118
|
+
for row in rows:
|
|
119
|
+
alpha, beta = float(row["alpha"] or 0), float(row["beta"] or 0)
|
|
120
|
+
source_id = str(row["source_id"] or "")
|
|
121
|
+
if alpha + beta > 0 and source_id and source_id not in scores:
|
|
122
|
+
scores[source_id] = round(alpha / (alpha + beta), 4)
|
|
123
|
+
return {
|
|
124
|
+
"scores": scores,
|
|
125
|
+
"tracked_sources": int(count_row["count"] if count_row else 0),
|
|
126
|
+
"status": "available",
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def load_model_state(profile_id: str, db_path: Path) -> dict:
|
|
131
|
+
"""Count persisted model artifacts without inferring them from signals."""
|
|
132
|
+
if not db_path.exists():
|
|
133
|
+
return {"models_trained": 0, "status": "missing_database"}
|
|
134
|
+
try:
|
|
135
|
+
connection = _readonly_connection(db_path)
|
|
136
|
+
try:
|
|
137
|
+
if not _table_exists(connection, "learning_model_state"):
|
|
138
|
+
return {"models_trained": 0, "status": "missing_table"}
|
|
139
|
+
row = connection.execute(
|
|
140
|
+
"SELECT COUNT(*) AS count FROM learning_model_state "
|
|
141
|
+
"WHERE profile_id = ?",
|
|
142
|
+
(profile_id,),
|
|
143
|
+
).fetchone()
|
|
144
|
+
finally:
|
|
145
|
+
connection.close()
|
|
146
|
+
except sqlite3.Error as exc:
|
|
147
|
+
status = sqlite_status(exc)
|
|
148
|
+
logger.warning("model-state telemetry %s: %s", status, exc)
|
|
149
|
+
return {"models_trained": 0, "status": status}
|
|
150
|
+
return {
|
|
151
|
+
"models_trained": int(row["count"] if row else 0),
|
|
152
|
+
"status": "available",
|
|
153
|
+
}
|
|
@@ -11,9 +11,10 @@ import json
|
|
|
11
11
|
import logging
|
|
12
12
|
import sqlite3
|
|
13
13
|
|
|
14
|
-
from fastapi import APIRouter
|
|
14
|
+
from fastapi import APIRouter, Request
|
|
15
15
|
|
|
16
|
-
from .helpers import get_active_profile, MEMORY_DIR, DB_PATH
|
|
16
|
+
from .helpers import get_active_profile, get_engine_lazy, MEMORY_DIR, DB_PATH
|
|
17
|
+
from superlocalmemory.server.route_mutations import authorize_route_mutation
|
|
17
18
|
|
|
18
19
|
logger = logging.getLogger("superlocalmemory.routes.lifecycle")
|
|
19
20
|
router = APIRouter()
|
|
@@ -121,15 +122,65 @@ async def lifecycle_status():
|
|
|
121
122
|
"recent_transitions": [],
|
|
122
123
|
"age_stats": age_stats,
|
|
123
124
|
}
|
|
124
|
-
except Exception
|
|
125
|
-
logger.
|
|
126
|
-
return {"available": False, "error":
|
|
125
|
+
except Exception:
|
|
126
|
+
logger.exception("lifecycle_status error")
|
|
127
|
+
return {"available": False, "error": "Internal server error"}
|
|
127
128
|
|
|
128
129
|
|
|
129
130
|
@router.post("/api/lifecycle/compact")
|
|
130
|
-
async def trigger_compaction(data: dict = {}):
|
|
131
|
-
"""Trigger lifecycle compaction
|
|
131
|
+
async def trigger_compaction(request: Request, data: dict = {}):
|
|
132
|
+
"""Trigger lifecycle compaction for the active profile.
|
|
133
|
+
|
|
134
|
+
Body: ``{"dry_run": true|false}`` (default true — preview only).
|
|
135
|
+
|
|
136
|
+
Recomputes each fact's lifecycle zone (active→warm→cold→archive) from its
|
|
137
|
+
age/access pattern. dry_run returns the proposed transitions without
|
|
138
|
+
mutating; execute applies them and is profile-scoped + mutation-authorized.
|
|
139
|
+
"""
|
|
132
140
|
if not LIFECYCLE_AVAILABLE:
|
|
133
141
|
return {"success": False, "error": "Lifecycle engine not available"}
|
|
134
142
|
|
|
135
|
-
|
|
143
|
+
dry_run = bool((data or {}).get("dry_run", True))
|
|
144
|
+
try:
|
|
145
|
+
engine = get_engine_lazy(request.app.state)
|
|
146
|
+
if engine is None:
|
|
147
|
+
return {"success": False, "error": "Engine not initialized"}
|
|
148
|
+
profile = get_active_profile()
|
|
149
|
+
|
|
150
|
+
mgr = LifecycleManager(engine._db)
|
|
151
|
+
facts = engine._db.get_all_facts(profile)
|
|
152
|
+
candidates = []
|
|
153
|
+
for f in facts:
|
|
154
|
+
new_state = mgr.get_lifecycle_state(f)
|
|
155
|
+
if new_state != f.lifecycle:
|
|
156
|
+
candidates.append({
|
|
157
|
+
"fact_id": f.fact_id,
|
|
158
|
+
"current": getattr(f.lifecycle, "value", str(f.lifecycle)),
|
|
159
|
+
"proposed": getattr(new_state, "value", str(new_state)),
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
applied = 0
|
|
163
|
+
if not dry_run and candidates:
|
|
164
|
+
authorization = authorize_route_mutation(
|
|
165
|
+
request,
|
|
166
|
+
operation="update",
|
|
167
|
+
source_agent_id="http-lifecycle-compact",
|
|
168
|
+
profile_id=profile,
|
|
169
|
+
)
|
|
170
|
+
for c in candidates:
|
|
171
|
+
engine._db.update_fact(c["fact_id"], {"lifecycle": c["proposed"]})
|
|
172
|
+
applied += 1
|
|
173
|
+
authorization.complete()
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
"success": True,
|
|
177
|
+
"dry_run": dry_run,
|
|
178
|
+
"active_profile": profile,
|
|
179
|
+
"total_facts": len(facts),
|
|
180
|
+
"candidates": len(candidates),
|
|
181
|
+
"applied": applied,
|
|
182
|
+
"transitions": candidates[:20],
|
|
183
|
+
}
|
|
184
|
+
except Exception as exc:
|
|
185
|
+
logger.exception("lifecycle compaction failed")
|
|
186
|
+
return {"success": False, "error": "internal error"}
|
|
@@ -20,6 +20,18 @@ logger = logging.getLogger("superlocalmemory.routes.memories")
|
|
|
20
20
|
router = APIRouter()
|
|
21
21
|
|
|
22
22
|
|
|
23
|
+
def _internal_error(detail: str = "Internal server error") -> HTTPException:
|
|
24
|
+
"""SEC-H-02: log the full traceback server-side; return a generic message.
|
|
25
|
+
|
|
26
|
+
Returning ``str(e)`` to HTTP clients leaks DB schema (column/constraint
|
|
27
|
+
names), the data-directory filesystem path, and possibly config internals —
|
|
28
|
+
a GDPR Art. 32 gap. Call this only from inside an ``except`` block so
|
|
29
|
+
``logger.exception`` captures the active traceback automatically.
|
|
30
|
+
"""
|
|
31
|
+
logger.exception("memories route error")
|
|
32
|
+
return HTTPException(status_code=500, detail=detail)
|
|
33
|
+
|
|
34
|
+
|
|
23
35
|
def _get_engine(request: Request):
|
|
24
36
|
"""Get V3 engine from app state, initializing lazily on first call."""
|
|
25
37
|
return get_engine_lazy(request.app.state)
|
|
@@ -41,6 +53,15 @@ def _authorize_memory_mutation(
|
|
|
41
53
|
getattr(request.app.state, "daemon_descriptor", None),
|
|
42
54
|
actor_kind="dashboard",
|
|
43
55
|
)
|
|
56
|
+
# RBAC (C3): on top of machine auth, enforce the caller's role on the active
|
|
57
|
+
# profile. delete → DELETE; every other mutation → WRITE. A viewer (or an
|
|
58
|
+
# owner in require_login mode) is rejected here; owner/admin/member pass.
|
|
59
|
+
from superlocalmemory.access.rbac import Permission as _Perm
|
|
60
|
+
from superlocalmemory.server.rbac_enforce import require_permission as _rbac_require
|
|
61
|
+
_rbac_require(
|
|
62
|
+
request,
|
|
63
|
+
_Perm.DELETE if operation == "delete" else _Perm.WRITE,
|
|
64
|
+
)
|
|
44
65
|
engine = _get_engine(request)
|
|
45
66
|
if engine is None:
|
|
46
67
|
raise HTTPException(503, detail="Engine not initialized")
|
|
@@ -253,6 +274,14 @@ async def get_memories(
|
|
|
253
274
|
None,
|
|
254
275
|
description="Named filter: 'high_reward' | 'being_forgotten'",
|
|
255
276
|
),
|
|
277
|
+
scope: Optional[str] = Query(
|
|
278
|
+
None,
|
|
279
|
+
description=(
|
|
280
|
+
"Scope view (v3 only): 'shared' (shared with this profile), "
|
|
281
|
+
"'global' (global memories), 'all' (this profile + global + shared). "
|
|
282
|
+
"Default None = this profile only (unchanged isolation)."
|
|
283
|
+
),
|
|
284
|
+
),
|
|
256
285
|
):
|
|
257
286
|
"""List memories with optional filtering and pagination.
|
|
258
287
|
|
|
@@ -274,15 +303,36 @@ async def get_memories(
|
|
|
274
303
|
use_v3 = _has_table(cursor, 'atomic_facts')
|
|
275
304
|
|
|
276
305
|
if use_v3:
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
306
|
+
# Scope view clause. Default (scope=None) is this-profile-only —
|
|
307
|
+
# identical isolation to before. Other values widen the view to
|
|
308
|
+
# global and/or shared-with-this-profile memories.
|
|
309
|
+
_esc = active_profile.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
310
|
+
_shared_pat = f'%"{_esc}"%'
|
|
311
|
+
if scope == "global":
|
|
312
|
+
scope_where = "scope = 'global'"
|
|
313
|
+
scope_params = []
|
|
314
|
+
elif scope == "shared":
|
|
315
|
+
scope_where = "scope = 'shared' AND shared_with LIKE ? ESCAPE '\\'"
|
|
316
|
+
scope_params = [_shared_pat]
|
|
317
|
+
elif scope == "all":
|
|
318
|
+
scope_where = (
|
|
319
|
+
"(profile_id = ? OR scope = 'global' "
|
|
320
|
+
"OR (scope = 'shared' AND shared_with LIKE ? ESCAPE '\\'))"
|
|
321
|
+
)
|
|
322
|
+
scope_params = [active_profile, _shared_pat]
|
|
323
|
+
else:
|
|
324
|
+
scope_where = "profile_id = ?"
|
|
325
|
+
scope_params = [active_profile]
|
|
326
|
+
|
|
327
|
+
query = (
|
|
328
|
+
"SELECT fact_id as id, memory_id, content, fact_type as category, "
|
|
329
|
+
"confidence as importance, access_count, "
|
|
330
|
+
"created_at, created_at as updated_at, "
|
|
331
|
+
"session_id as project_name, scope, shared_with "
|
|
332
|
+
f"FROM atomic_facts WHERE {scope_where}"
|
|
333
|
+
)
|
|
334
|
+
params = list(scope_params)
|
|
335
|
+
count_base = f"SELECT COUNT(*) as total FROM atomic_facts WHERE {scope_where}"
|
|
286
336
|
else:
|
|
287
337
|
query = """
|
|
288
338
|
SELECT id, content, summary, category, project_name, project_path,
|
|
@@ -293,7 +343,8 @@ async def get_memories(
|
|
|
293
343
|
params = [active_profile]
|
|
294
344
|
count_base = "SELECT COUNT(*) as total FROM memories WHERE profile = ?"
|
|
295
345
|
|
|
296
|
-
|
|
346
|
+
# count params must mirror the base WHERE params (scope-aware for v3).
|
|
347
|
+
count_params = list(params)
|
|
297
348
|
|
|
298
349
|
if category:
|
|
299
350
|
if use_v3:
|
|
@@ -396,8 +447,8 @@ async def get_memories(
|
|
|
396
447
|
"has_more": (offset + limit) < total,
|
|
397
448
|
}
|
|
398
449
|
|
|
399
|
-
except Exception
|
|
400
|
-
raise
|
|
450
|
+
except Exception:
|
|
451
|
+
raise _internal_error("Database error")
|
|
401
452
|
|
|
402
453
|
|
|
403
454
|
@router.get("/api/graph")
|
|
@@ -430,8 +481,8 @@ async def get_graph(
|
|
|
430
481
|
},
|
|
431
482
|
}
|
|
432
483
|
|
|
433
|
-
except Exception
|
|
434
|
-
raise
|
|
484
|
+
except Exception:
|
|
485
|
+
raise _internal_error("Graph error")
|
|
435
486
|
|
|
436
487
|
|
|
437
488
|
@router.post("/api/search")
|
|
@@ -455,17 +506,27 @@ async def search_memories(request: Request, body: SearchRequest):
|
|
|
455
506
|
# directly in an async route blocks the ASGI event loop — Chrome detects
|
|
456
507
|
# a stalled connection and aborts with "signal is aborted without reason"
|
|
457
508
|
# before the response arrives. Fix: run in a thread-pool executor so the
|
|
458
|
-
# event loop stays alive to send keepalive frames.
|
|
459
|
-
#
|
|
509
|
+
# event loop stays alive to send keepalive frames.
|
|
510
|
+
# v3.4.64 (regression fix): fast=True skips spreading_activation + agentic
|
|
511
|
+
# LLM rounds (saves ~7s on cold graph traversal). fast=False is the SLOW
|
|
512
|
+
# path that enables both; the earlier comment had this inverted.
|
|
460
513
|
import asyncio
|
|
461
514
|
import time as _time
|
|
462
515
|
engine = _get_engine(request)
|
|
463
516
|
if engine is not None:
|
|
464
|
-
loop = asyncio.
|
|
517
|
+
loop = asyncio.get_running_loop()
|
|
465
518
|
t0 = _time.monotonic()
|
|
519
|
+
# T-window: prefer an explicit ``window`` spec; otherwise derive a
|
|
520
|
+
# range from the legacy date_from/date_to pair when both are set.
|
|
521
|
+
_window = getattr(body, "window", None) or ""
|
|
522
|
+
if not _window and getattr(body, "date_from", None) and getattr(body, "date_to", None):
|
|
523
|
+
_window = f"{body.date_from}..{body.date_to}"
|
|
466
524
|
response = await loop.run_in_executor(
|
|
467
525
|
None,
|
|
468
|
-
lambda: engine.recall(
|
|
526
|
+
lambda: engine.recall(
|
|
527
|
+
body.query, limit=body.limit, fast=True,
|
|
528
|
+
window=_window or None,
|
|
529
|
+
),
|
|
469
530
|
)
|
|
470
531
|
elapsed_ms = round((_time.monotonic() - t0) * 1000, 1)
|
|
471
532
|
from superlocalmemory.server.recall_serializer import (
|
|
@@ -525,8 +586,8 @@ async def search_memories(request: Request, body: SearchRequest):
|
|
|
525
586
|
"no_confident_match": False,
|
|
526
587
|
}
|
|
527
588
|
|
|
528
|
-
except Exception
|
|
529
|
-
raise
|
|
589
|
+
except Exception:
|
|
590
|
+
raise _internal_error("Search error")
|
|
530
591
|
finally:
|
|
531
592
|
end_recall()
|
|
532
593
|
|
|
@@ -611,8 +672,8 @@ async def get_clusters(request: Request):
|
|
|
611
672
|
|
|
612
673
|
conn.close()
|
|
613
674
|
return {"clusters": clusters, "total_clusters": len(clusters), "unclustered_count": unclustered}
|
|
614
|
-
except Exception
|
|
615
|
-
raise
|
|
675
|
+
except Exception:
|
|
676
|
+
raise _internal_error("Cluster error")
|
|
616
677
|
|
|
617
678
|
|
|
618
679
|
@router.get("/api/clusters/{cluster_id}")
|
|
@@ -673,17 +734,19 @@ async def get_cluster_detail(request: Request, cluster_id: str, limit: int = Que
|
|
|
673
734
|
texts = [m.get("content", "")[:200] for m in members[:10] if m.get("content")]
|
|
674
735
|
if texts:
|
|
675
736
|
from superlocalmemory.core.summarizer import Summarizer
|
|
676
|
-
from superlocalmemory.server.profile_runtime import get_profile_runtime
|
|
677
737
|
from superlocalmemory.server.routes.helpers import get_engine_lazy
|
|
678
738
|
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
739
|
+
# v3.4.64: Do NOT call runtime.operation() synchronously in an
|
|
740
|
+
# async route — if a transition is pending, acquire_operation()
|
|
741
|
+
# blocks the event loop thread, deadlocking the drain. The
|
|
742
|
+
# middleware already holds an operation lease for this request;
|
|
743
|
+
# the engine is stable for the full duration of the handler.
|
|
744
|
+
engine = get_engine_lazy(request.app.state)
|
|
745
|
+
if engine is not None:
|
|
746
|
+
summarizer = Summarizer(engine._config)
|
|
747
|
+
summary = summarizer.summarize_cluster(
|
|
748
|
+
[{"content": t} for t in texts]
|
|
749
|
+
) or ""
|
|
687
750
|
except Exception:
|
|
688
751
|
pass
|
|
689
752
|
|
|
@@ -695,8 +758,8 @@ async def get_cluster_detail(request: Request, cluster_id: str, limit: int = Que
|
|
|
695
758
|
}
|
|
696
759
|
except HTTPException:
|
|
697
760
|
raise
|
|
698
|
-
except Exception
|
|
699
|
-
raise
|
|
761
|
+
except Exception:
|
|
762
|
+
raise _internal_error("Cluster detail error")
|
|
700
763
|
|
|
701
764
|
|
|
702
765
|
@router.get("/api/memories/{memory_id}/facts")
|
|
@@ -710,18 +773,21 @@ async def get_memory_facts(request: Request, memory_id: str):
|
|
|
710
773
|
engine instead, exactly like the /recall route.
|
|
711
774
|
"""
|
|
712
775
|
try:
|
|
713
|
-
from superlocalmemory.server.profile_runtime import get_profile_runtime
|
|
714
776
|
from superlocalmemory.server.routes.helpers import get_engine_lazy
|
|
715
777
|
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
778
|
+
# v3.4.64: Do NOT call runtime.operation() synchronously in an async
|
|
779
|
+
# route — blocks the event loop when a transition is pending. The
|
|
780
|
+
# middleware already holds an operation lease; the engine and its
|
|
781
|
+
# profile_id are stable for the full duration of this request.
|
|
782
|
+
engine = get_engine_lazy(request.app.state)
|
|
783
|
+
if engine is None:
|
|
784
|
+
raise HTTPException(status_code=503, detail="Engine not initialized")
|
|
785
|
+
active_profile = engine.profile_id
|
|
786
|
+
mem_map = engine._db.get_memory_content_batch(
|
|
787
|
+
[memory_id], active_profile, include_global=True, include_shared=True,
|
|
788
|
+
)
|
|
789
|
+
original = mem_map.get(memory_id, "")
|
|
790
|
+
facts = engine._db.get_facts_by_memory_id(memory_id, active_profile)
|
|
725
791
|
fact_list = [
|
|
726
792
|
{
|
|
727
793
|
"fact_id": f.fact_id,
|
|
@@ -744,8 +810,8 @@ async def get_memory_facts(request: Request, memory_id: str):
|
|
|
744
810
|
}
|
|
745
811
|
except HTTPException:
|
|
746
812
|
raise
|
|
747
|
-
except Exception
|
|
748
|
-
raise
|
|
813
|
+
except Exception:
|
|
814
|
+
raise _internal_error()
|
|
749
815
|
|
|
750
816
|
|
|
751
817
|
@router.get("/api/memories/{memory_id}/detail")
|
|
@@ -795,8 +861,8 @@ async def get_memory_detail(request: Request, memory_id: str):
|
|
|
795
861
|
}
|
|
796
862
|
except HTTPException:
|
|
797
863
|
raise
|
|
798
|
-
except Exception
|
|
799
|
-
raise
|
|
864
|
+
except Exception:
|
|
865
|
+
raise _internal_error("Detail error")
|
|
800
866
|
|
|
801
867
|
|
|
802
868
|
@router.get("/api/facts/{fact_id}")
|
|
@@ -835,8 +901,8 @@ async def get_fact_detail(request: Request, fact_id: str):
|
|
|
835
901
|
return row
|
|
836
902
|
except HTTPException:
|
|
837
903
|
raise
|
|
838
|
-
except Exception
|
|
839
|
-
raise
|
|
904
|
+
except Exception:
|
|
905
|
+
raise _internal_error("Fact detail error")
|
|
840
906
|
|
|
841
907
|
|
|
842
908
|
@router.delete("/api/memories/{fact_id}")
|
|
@@ -859,8 +925,8 @@ async def delete_memory(request: Request, fact_id: str):
|
|
|
859
925
|
return {"success": True, "deleted": fact_id}
|
|
860
926
|
except HTTPException:
|
|
861
927
|
raise
|
|
862
|
-
except Exception
|
|
863
|
-
raise
|
|
928
|
+
except Exception:
|
|
929
|
+
raise _internal_error("Delete error")
|
|
864
930
|
|
|
865
931
|
|
|
866
932
|
@router.post("/api/memories/{fact_id}/forget")
|
|
@@ -920,8 +986,8 @@ async def forget_memory(request: Request, fact_id: str):
|
|
|
920
986
|
return {"success": True, "fact_id": fact_id, "archived_at": archived_at}
|
|
921
987
|
except HTTPException:
|
|
922
988
|
raise
|
|
923
|
-
except Exception
|
|
924
|
-
raise
|
|
989
|
+
except Exception:
|
|
990
|
+
raise _internal_error("Forget error")
|
|
925
991
|
|
|
926
992
|
|
|
927
993
|
@router.post("/api/memories/{fact_id}/merge")
|
|
@@ -989,8 +1055,8 @@ async def merge_memory(request: Request, fact_id: str):
|
|
|
989
1055
|
}
|
|
990
1056
|
except HTTPException:
|
|
991
1057
|
raise
|
|
992
|
-
except Exception
|
|
993
|
-
raise
|
|
1058
|
+
except Exception:
|
|
1059
|
+
raise _internal_error("Merge error")
|
|
994
1060
|
|
|
995
1061
|
|
|
996
1062
|
@router.patch("/api/memories/{fact_id}")
|
|
@@ -1022,5 +1088,64 @@ async def edit_memory(request: Request, fact_id: str):
|
|
|
1022
1088
|
return {"success": True, "fact_id": fact_id, "content": new_content}
|
|
1023
1089
|
except HTTPException:
|
|
1024
1090
|
raise
|
|
1025
|
-
except Exception
|
|
1026
|
-
raise
|
|
1091
|
+
except Exception:
|
|
1092
|
+
raise _internal_error("Edit error")
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
_VALID_SCOPES = ("personal", "shared", "global")
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
@router.patch("/api/memories/{fact_id}/scope")
|
|
1099
|
+
async def set_memory_scope(request: Request, fact_id: str):
|
|
1100
|
+
"""Set a memory's scope (personal | shared | global) + shared_with.
|
|
1101
|
+
|
|
1102
|
+
Body: {"scope": "shared", "shared_with": "alice,bob"}. shared_with accepts a
|
|
1103
|
+
comma-separated string or a list; it is stored as a JSON array so the
|
|
1104
|
+
_scope_where LIKE match works. Mutation-authorized, and the fact must
|
|
1105
|
+
belong to the active profile (a caller cannot re-scope another profile's
|
|
1106
|
+
fact). This is the write side of multi-scope sharing from the dashboard.
|
|
1107
|
+
"""
|
|
1108
|
+
import json as _json
|
|
1109
|
+
try:
|
|
1110
|
+
body = await request.json()
|
|
1111
|
+
scope = (body.get("scope") or "").strip().lower()
|
|
1112
|
+
if scope not in _VALID_SCOPES:
|
|
1113
|
+
raise HTTPException(400, detail=f"scope must be one of {_VALID_SCOPES}")
|
|
1114
|
+
|
|
1115
|
+
raw_shared = body.get("shared_with", [])
|
|
1116
|
+
if isinstance(raw_shared, str):
|
|
1117
|
+
shared_list = [s.strip() for s in raw_shared.split(",") if s.strip()]
|
|
1118
|
+
elif isinstance(raw_shared, list):
|
|
1119
|
+
shared_list = [str(s).strip() for s in raw_shared if str(s).strip()]
|
|
1120
|
+
else:
|
|
1121
|
+
shared_list = []
|
|
1122
|
+
if scope == "shared" and not shared_list:
|
|
1123
|
+
raise HTTPException(
|
|
1124
|
+
400, detail="shared scope requires at least one profile in shared_with")
|
|
1125
|
+
# global/personal never carry a shared_with list.
|
|
1126
|
+
if scope != "shared":
|
|
1127
|
+
shared_list = []
|
|
1128
|
+
|
|
1129
|
+
engine, active_profile, _ctx = _authorize_memory_mutation(
|
|
1130
|
+
request, "update", fact_id, run_pre_hook=False,
|
|
1131
|
+
)
|
|
1132
|
+
# Ownership check: the fact must belong to the active profile.
|
|
1133
|
+
rows = engine._db.execute(
|
|
1134
|
+
"SELECT 1 FROM atomic_facts WHERE fact_id = ? AND profile_id = ?",
|
|
1135
|
+
(fact_id, active_profile),
|
|
1136
|
+
)
|
|
1137
|
+
if not rows:
|
|
1138
|
+
raise HTTPException(404, detail="Memory not found in this profile")
|
|
1139
|
+
|
|
1140
|
+
engine._db.update_fact(fact_id, {
|
|
1141
|
+
"scope": scope,
|
|
1142
|
+
"shared_with": _json.dumps(shared_list),
|
|
1143
|
+
}, profile_id=active_profile)
|
|
1144
|
+
return {
|
|
1145
|
+
"success": True, "fact_id": fact_id, "scope": scope,
|
|
1146
|
+
"shared_with": shared_list, "active_profile": active_profile,
|
|
1147
|
+
}
|
|
1148
|
+
except HTTPException:
|
|
1149
|
+
raise
|
|
1150
|
+
except Exception:
|
|
1151
|
+
raise _internal_error("Scope update error")
|