superlocalmemory 3.8.3 → 3.8.6
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/CHANGELOG.md +76 -0
- package/README.md +3 -2
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +9 -4
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/rbac.py +68 -76
- package/src/superlocalmemory/cli/commands.py +158 -404
- package/src/superlocalmemory/cli/ingest_cmd.py +11 -1
- package/src/superlocalmemory/cli/main.py +30 -0
- package/src/superlocalmemory/cli/pending_store.py +39 -14
- package/src/superlocalmemory/core/backend_orchestrator.py +93 -0
- package/src/superlocalmemory/core/component_registry.py +4 -2
- package/src/superlocalmemory/core/config.py +78 -0
- package/src/superlocalmemory/core/consolidation_engine.py +79 -73
- package/src/superlocalmemory/core/embeddings.py +33 -6
- package/src/superlocalmemory/core/engine.py +186 -60
- package/src/superlocalmemory/core/engine_ingestion.py +150 -63
- package/src/superlocalmemory/core/fact_consolidator.py +148 -30
- package/src/superlocalmemory/core/graph_pruner.py +436 -39
- package/src/superlocalmemory/core/ingestion_command.py +273 -32
- package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
- package/src/superlocalmemory/core/mutations.py +32 -10
- package/src/superlocalmemory/core/recall_pipeline.py +111 -74
- package/src/superlocalmemory/core/registry.py +5 -1
- package/src/superlocalmemory/core/remember_admission.py +152 -0
- package/src/superlocalmemory/core/remember_runtime.py +712 -0
- package/src/superlocalmemory/core/remote_mode.py +3 -1
- package/src/superlocalmemory/core/scale_engine.py +41 -18
- package/src/superlocalmemory/core/store_pipeline.py +18 -4
- package/src/superlocalmemory/encoding/entity_resolver.py +18 -11
- package/src/superlocalmemory/graph/cozo_backend.py +5 -5
- package/src/superlocalmemory/hooks/_outcome_common.py +9 -2
- package/src/superlocalmemory/hooks/adapter_base.py +58 -44
- package/src/superlocalmemory/hooks/ide_connector.py +26 -8
- package/src/superlocalmemory/hooks/portable_kit.py +105 -9
- package/src/superlocalmemory/hooks/prewarm_auth.py +21 -2
- package/src/superlocalmemory/infra/auth_middleware.py +3 -1
- package/src/superlocalmemory/infra/cloud_backup.py +26 -27
- package/src/superlocalmemory/infra/event_bus.py +250 -88
- package/src/superlocalmemory/learning/bandit.py +50 -1
- package/src/superlocalmemory/learning/consolidation_cycle.py +33 -16
- package/src/superlocalmemory/learning/entity_compiler.py +148 -132
- package/src/superlocalmemory/learning/memory_merge.py +97 -82
- package/src/superlocalmemory/learning/reward_archive.py +98 -90
- package/src/superlocalmemory/learning/reward_boost.py +40 -30
- package/src/superlocalmemory/learning/source_quality.py +38 -35
- package/src/superlocalmemory/mcp/_daemon_proxy.py +38 -15
- package/src/superlocalmemory/mcp/http_transport.py +335 -3
- package/src/superlocalmemory/mcp/tools_active.py +4 -41
- package/src/superlocalmemory/mcp/tools_core.py +26 -87
- package/src/superlocalmemory/mcp/tools_evolution.py +5 -10
- package/src/superlocalmemory/optimize/proxy/capture.py +196 -8
- package/src/superlocalmemory/retrieval/engine.py +15 -4
- package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
- package/src/superlocalmemory/retrieval/reranker.py +130 -22
- package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
- package/src/superlocalmemory/retrieval/vector_store.py +84 -69
- package/src/superlocalmemory/server/loopback.py +85 -0
- package/src/superlocalmemory/server/origin.py +9 -4
- package/src/superlocalmemory/server/profile_runtime.py +14 -0
- package/src/superlocalmemory/server/routes/abstraction.py +2 -4
- package/src/superlocalmemory/server/routes/agents.py +3 -5
- package/src/superlocalmemory/server/routes/backup.py +6 -2
- package/src/superlocalmemory/server/routes/behavioral.py +11 -25
- package/src/superlocalmemory/server/routes/brain.py +6 -9
- package/src/superlocalmemory/server/routes/compliance.py +20 -23
- package/src/superlocalmemory/server/routes/config_api.py +83 -0
- package/src/superlocalmemory/server/routes/entity.py +3 -7
- package/src/superlocalmemory/server/routes/evolution.py +3 -5
- package/src/superlocalmemory/server/routes/helpers.py +57 -25
- package/src/superlocalmemory/server/routes/insights.py +2 -4
- package/src/superlocalmemory/server/routes/learning.py +2 -5
- package/src/superlocalmemory/server/routes/lifecycle.py +2 -4
- package/src/superlocalmemory/server/routes/memories.py +119 -98
- package/src/superlocalmemory/server/routes/mesh.py +7 -2
- package/src/superlocalmemory/server/routes/profiles.py +20 -21
- package/src/superlocalmemory/server/routes/rbac.py +0 -1
- package/src/superlocalmemory/server/routes/tiers.py +28 -35
- package/src/superlocalmemory/server/routes/timeline.py +2 -4
- package/src/superlocalmemory/server/routes/v3_api.py +85 -93
- package/src/superlocalmemory/server/unified_daemon.py +400 -140
- package/src/superlocalmemory/server/write_identity.py +22 -4
- package/src/superlocalmemory/storage/admission_codec.py +119 -0
- package/src/superlocalmemory/storage/admission_journal.py +728 -0
- package/src/superlocalmemory/storage/database.py +168 -19
- package/src/superlocalmemory/storage/deferred_writes.py +209 -0
- package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
- package/src/superlocalmemory/storage/memory_write.py +115 -0
- package/src/superlocalmemory/storage/migration_runner.py +44 -0
- package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +113 -78
- package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +80 -0
- package/src/superlocalmemory/storage/migrations/M032_write_coordinator_admission.py +188 -0
- package/src/superlocalmemory/storage/read_connection.py +115 -0
- package/src/superlocalmemory/storage/write_coordinator.py +756 -0
- package/src/superlocalmemory/storage/write_lock.py +88 -0
- package/src/superlocalmemory/ui/index.html +1 -1
- package/src/superlocalmemory/ui/js/auto-settings.js +14 -1
- package/src/superlocalmemory/ui/js/od-settings.js +9 -3
|
@@ -14,7 +14,7 @@ from fastapi import APIRouter, HTTPException, Query, Request
|
|
|
14
14
|
|
|
15
15
|
from superlocalmemory.infra.data_root import state_path
|
|
16
16
|
|
|
17
|
-
from .helpers import DB_PATH
|
|
17
|
+
from .helpers import DB_PATH, get_read_connection
|
|
18
18
|
|
|
19
19
|
logger = logging.getLogger("superlocalmemory.routes.agents")
|
|
20
20
|
router = APIRouter()
|
|
@@ -103,8 +103,7 @@ async def get_agent_memory_activity(
|
|
|
103
103
|
total = 0
|
|
104
104
|
|
|
105
105
|
if DB_PATH.exists():
|
|
106
|
-
conn =
|
|
107
|
-
conn.row_factory = sqlite3.Row
|
|
106
|
+
conn = get_read_connection(DB_PATH)
|
|
108
107
|
try:
|
|
109
108
|
try:
|
|
110
109
|
rows = conn.execute(
|
|
@@ -188,8 +187,7 @@ async def get_trust_stats(request: Request):
|
|
|
188
187
|
by_signal_type = {}
|
|
189
188
|
|
|
190
189
|
if DB_PATH.exists():
|
|
191
|
-
conn =
|
|
192
|
-
conn.row_factory = sqlite3.Row
|
|
190
|
+
conn = get_read_connection(DB_PATH)
|
|
193
191
|
try:
|
|
194
192
|
try:
|
|
195
193
|
# Count trust signals
|
|
@@ -87,9 +87,13 @@ def _require_oauth_start(request: Request) -> None:
|
|
|
87
87
|
detail="OAuth initiation requires the local dashboard origin.",
|
|
88
88
|
)
|
|
89
89
|
|
|
90
|
+
from superlocalmemory.server.loopback import is_loopback as _is_loopback_host
|
|
91
|
+
|
|
90
92
|
host = request.client.host if request.client else ""
|
|
91
|
-
|
|
92
|
-
|
|
93
|
+
# "testclient" is included for in-process test compatibility (preserved
|
|
94
|
+
# from original behaviour; was in the original frozenset).
|
|
95
|
+
_from_loopback = _is_loopback_host(host) or host == "testclient"
|
|
96
|
+
if not _from_loopback and principal.get("kind") != "user":
|
|
93
97
|
raise HTTPException(
|
|
94
98
|
status_code=403,
|
|
95
99
|
detail=(
|
|
@@ -17,6 +17,8 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
|
17
17
|
from pydantic import BaseModel, ConfigDict, Field, StrictStr, field_validator
|
|
18
18
|
|
|
19
19
|
from .helpers import MEMORY_DIR, get_active_profile
|
|
20
|
+
from superlocalmemory.storage.memory_write import memory_write
|
|
21
|
+
from superlocalmemory.storage.read_connection import ReadConnectionFactory
|
|
20
22
|
|
|
21
23
|
logger = logging.getLogger("superlocalmemory.routes.behavioral")
|
|
22
24
|
router = APIRouter()
|
|
@@ -145,8 +147,7 @@ def _load_action_outcomes(profile_id: str) -> dict:
|
|
|
145
147
|
if not db_path.exists():
|
|
146
148
|
return empty
|
|
147
149
|
try:
|
|
148
|
-
conn =
|
|
149
|
-
conn.row_factory = sqlite3.Row
|
|
150
|
+
conn = ReadConnectionFactory(db_path).open()
|
|
150
151
|
try:
|
|
151
152
|
columns = {
|
|
152
153
|
str(row["name"])
|
|
@@ -368,7 +369,6 @@ def report_outcome(request: Request, data: ReportOutcomeRequest):
|
|
|
368
369
|
action_type = data.action_type
|
|
369
370
|
context_note = data.context
|
|
370
371
|
|
|
371
|
-
import sqlite3
|
|
372
372
|
import uuid
|
|
373
373
|
from datetime import datetime, timezone
|
|
374
374
|
|
|
@@ -385,10 +385,9 @@ def report_outcome(request: Request, data: ReportOutcomeRequest):
|
|
|
385
385
|
"action_type": action_type,
|
|
386
386
|
"source": "dashboard_report_outcome",
|
|
387
387
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
conn.execute("BEGIN IMMEDIATE")
|
|
388
|
+
# memory_write acquires the process write lock (serialises in-process)
|
|
389
|
+
# and sets PRAGMA busy_timeout (cross-process writers wait, not error).
|
|
390
|
+
with memory_write(memory_db_path) as conn:
|
|
392
391
|
_validate_profile_fact_ids(
|
|
393
392
|
conn,
|
|
394
393
|
profile_id=profile,
|
|
@@ -407,9 +406,6 @@ def report_outcome(request: Request, data: ReportOutcomeRequest):
|
|
|
407
406
|
now_iso, reward, now_iso,
|
|
408
407
|
),
|
|
409
408
|
)
|
|
410
|
-
conn.commit()
|
|
411
|
-
finally:
|
|
412
|
-
conn.close()
|
|
413
409
|
|
|
414
410
|
try:
|
|
415
411
|
from superlocalmemory.learning.source_quality import (
|
|
@@ -454,10 +450,8 @@ def get_assertions(
|
|
|
454
450
|
):
|
|
455
451
|
"""Get learned behavioral assertions for dashboard display."""
|
|
456
452
|
try:
|
|
457
|
-
import sqlite3 as _sqlite3
|
|
458
453
|
profile = get_active_profile()
|
|
459
|
-
conn =
|
|
460
|
-
conn.row_factory = _sqlite3.Row
|
|
454
|
+
conn = ReadConnectionFactory(MEMORY_DIR / "memory.db").open()
|
|
461
455
|
|
|
462
456
|
query = (
|
|
463
457
|
"SELECT id, trigger_condition, action, category, confidence, "
|
|
@@ -498,10 +492,8 @@ def get_tool_events(
|
|
|
498
492
|
):
|
|
499
493
|
"""Get recent tool events for dashboard display."""
|
|
500
494
|
try:
|
|
501
|
-
import sqlite3 as _sqlite3
|
|
502
495
|
profile = get_active_profile()
|
|
503
|
-
conn =
|
|
504
|
-
conn.row_factory = _sqlite3.Row
|
|
496
|
+
conn = ReadConnectionFactory(MEMORY_DIR / "memory.db").open()
|
|
505
497
|
|
|
506
498
|
query = (
|
|
507
499
|
"SELECT id, tool_name, event_type, input_summary, output_summary, "
|
|
@@ -531,10 +523,8 @@ def get_soft_prompts(request: Request):
|
|
|
531
523
|
"""Get active soft prompt templates for dashboard display."""
|
|
532
524
|
_require_read(request)
|
|
533
525
|
try:
|
|
534
|
-
import sqlite3 as _sqlite3
|
|
535
526
|
profile = get_active_profile()
|
|
536
|
-
conn =
|
|
537
|
-
conn.row_factory = _sqlite3.Row
|
|
527
|
+
conn = ReadConnectionFactory(MEMORY_DIR / "memory.db").open()
|
|
538
528
|
rows = conn.execute(
|
|
539
529
|
"SELECT prompt_id, category, content, confidence, effectiveness, "
|
|
540
530
|
"token_count, active, version, created_at "
|
|
@@ -573,7 +563,6 @@ def log_tool_event_api(request: Request, data: dict):
|
|
|
573
563
|
_require_write(request)
|
|
574
564
|
try:
|
|
575
565
|
import os
|
|
576
|
-
import sqlite3 as _sqlite3
|
|
577
566
|
from datetime import datetime, timezone
|
|
578
567
|
|
|
579
568
|
tool_name = data.get("tool_name", "unknown")
|
|
@@ -589,8 +578,8 @@ def log_tool_event_api(request: Request, data: dict):
|
|
|
589
578
|
input_summary = str(input_summary)[:500] if input_summary else ""
|
|
590
579
|
output_summary = str(output_summary)[:500] if output_summary else ""
|
|
591
580
|
|
|
592
|
-
|
|
593
|
-
|
|
581
|
+
# memory_write: process write lock + busy_timeout for SQLITE_BUSY safety.
|
|
582
|
+
with memory_write(MEMORY_DIR / "memory.db") as conn:
|
|
594
583
|
conn.execute(
|
|
595
584
|
"INSERT INTO tool_events "
|
|
596
585
|
"(session_id, profile_id, project_path, tool_name, event_type, "
|
|
@@ -599,9 +588,6 @@ def log_tool_event_api(request: Request, data: dict):
|
|
|
599
588
|
(session_id, profile, project_path, tool_name, event_type,
|
|
600
589
|
input_summary, output_summary, now),
|
|
601
590
|
)
|
|
602
|
-
conn.commit()
|
|
603
|
-
finally:
|
|
604
|
-
conn.close()
|
|
605
591
|
return {"ok": True}
|
|
606
592
|
except Exception:
|
|
607
593
|
logger.exception("behavioral route error")
|
|
@@ -60,6 +60,7 @@ from superlocalmemory.core.security_primitives import (
|
|
|
60
60
|
from superlocalmemory.learning.database import LearningDatabase
|
|
61
61
|
from superlocalmemory.learning.features import FEATURE_DIM
|
|
62
62
|
from superlocalmemory.infra.data_root import canonical_data_root
|
|
63
|
+
from superlocalmemory.storage.read_connection import ReadConnectionFactory
|
|
63
64
|
from .helpers import get_active_profile
|
|
64
65
|
|
|
65
66
|
logger = logging.getLogger("superlocalmemory.routes.brain")
|
|
@@ -554,7 +555,7 @@ def _compute_cache_stats() -> dict:
|
|
|
554
555
|
size = db.stat().st_size
|
|
555
556
|
entry_count = 0
|
|
556
557
|
try:
|
|
557
|
-
conn =
|
|
558
|
+
conn = ReadConnectionFactory(db).open()
|
|
558
559
|
try:
|
|
559
560
|
row = conn.execute(
|
|
560
561
|
"SELECT COUNT(*) AS cnt FROM atomic_facts",
|
|
@@ -579,14 +580,11 @@ def _adapter_last_sync_ago(adapter_name: str) -> int | None:
|
|
|
579
580
|
honest empty rather than a fabricated number.
|
|
580
581
|
"""
|
|
581
582
|
try:
|
|
582
|
-
import sqlite3 as _sqlite3
|
|
583
583
|
from datetime import datetime as _dt, timezone as _tz
|
|
584
584
|
memory_db = _memory_dir() / "memory.db"
|
|
585
585
|
if not memory_db.exists():
|
|
586
586
|
return None
|
|
587
|
-
conn =
|
|
588
|
-
f"file:{memory_db}?mode=ro", uri=True, timeout=1.0,
|
|
589
|
-
)
|
|
587
|
+
conn = ReadConnectionFactory(memory_db).open()
|
|
590
588
|
try:
|
|
591
589
|
cur = conn.execute(
|
|
592
590
|
"SELECT last_sync_at FROM cross_platform_sync_log "
|
|
@@ -914,7 +912,7 @@ def _compute_action_outcomes_preview(profile_id: str) -> dict:
|
|
|
914
912
|
if not db_path.exists():
|
|
915
913
|
return empty
|
|
916
914
|
try:
|
|
917
|
-
conn =
|
|
915
|
+
conn = ReadConnectionFactory(db_path).open()
|
|
918
916
|
try:
|
|
919
917
|
row = conn.execute(
|
|
920
918
|
"SELECT COUNT(*) FROM action_outcomes WHERE profile_id = ?",
|
|
@@ -1036,7 +1034,6 @@ def _compute_outcome_queue_stats(profile_id: str) -> dict:
|
|
|
1036
1034
|
``pending_outcomes`` so the operator can see the closed loop is
|
|
1037
1035
|
actually flowing: recall → enqueue → persist → finalize.
|
|
1038
1036
|
"""
|
|
1039
|
-
import sqlite3
|
|
1040
1037
|
try:
|
|
1041
1038
|
from superlocalmemory.learning.outcome_queue import (
|
|
1042
1039
|
get_counters, queue_size,
|
|
@@ -1050,7 +1047,7 @@ def _compute_outcome_queue_stats(profile_id: str) -> dict:
|
|
|
1050
1047
|
pending_now = 0
|
|
1051
1048
|
if db.exists():
|
|
1052
1049
|
try:
|
|
1053
|
-
conn =
|
|
1050
|
+
conn = ReadConnectionFactory(db).open()
|
|
1054
1051
|
try:
|
|
1055
1052
|
row = conn.execute(
|
|
1056
1053
|
"SELECT COUNT(*) FROM pending_outcomes "
|
|
@@ -1089,7 +1086,7 @@ def _compute_reward_preview(profile_id: str) -> dict:
|
|
|
1089
1086
|
if not db.exists():
|
|
1090
1087
|
return default
|
|
1091
1088
|
try:
|
|
1092
|
-
conn =
|
|
1089
|
+
conn = ReadConnectionFactory(db).open()
|
|
1093
1090
|
try:
|
|
1094
1091
|
row = conn.execute(
|
|
1095
1092
|
"SELECT COUNT(*) AS c, AVG(reward) AS m "
|
|
@@ -10,7 +10,6 @@ Uses V3 compliance modules: ABACEngine, AuditChain, RetentionEngine.
|
|
|
10
10
|
"""
|
|
11
11
|
import json
|
|
12
12
|
import logging
|
|
13
|
-
import sqlite3
|
|
14
13
|
from typing import Optional
|
|
15
14
|
|
|
16
15
|
from fastapi import APIRouter, Query, Request
|
|
@@ -18,6 +17,7 @@ from fastapi.responses import JSONResponse
|
|
|
18
17
|
|
|
19
18
|
from .helpers import get_active_profile, get_engine_lazy, MEMORY_DIR, DB_PATH
|
|
20
19
|
from superlocalmemory.server.route_mutations import authorize_route_mutation
|
|
20
|
+
from superlocalmemory.storage.memory_write import memory_write
|
|
21
21
|
|
|
22
22
|
logger = logging.getLogger("superlocalmemory.routes.compliance")
|
|
23
23
|
router = APIRouter()
|
|
@@ -61,13 +61,14 @@ async def compliance_status():
|
|
|
61
61
|
except Exception as exc:
|
|
62
62
|
logger.debug("audit chain: %s", exc)
|
|
63
63
|
|
|
64
|
-
# Retention policies (scoped to the active profile)
|
|
64
|
+
# Retention policies (scoped to the active profile).
|
|
65
|
+
# RetentionEngine.__init__ runs DDL (CREATE TABLE IF NOT EXISTS) so even
|
|
66
|
+
# list_rules() is a write at the constructor level — use memory_write().
|
|
65
67
|
retention_policies = []
|
|
66
68
|
try:
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
conn.close()
|
|
69
|
+
with memory_write(DB_PATH) as conn:
|
|
70
|
+
engine = RetentionEngine(conn)
|
|
71
|
+
retention_policies = engine.list_rules(profile)
|
|
71
72
|
except Exception as exc:
|
|
72
73
|
logger.debug("retention engine: %s", exc)
|
|
73
74
|
|
|
@@ -163,15 +164,13 @@ async def create_retention_policy(data: dict):
|
|
|
163
164
|
|
|
164
165
|
try:
|
|
165
166
|
profile = get_active_profile()
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
)
|
|
174
|
-
conn.close()
|
|
167
|
+
with memory_write(DB_PATH) as conn:
|
|
168
|
+
engine = RetentionEngine(conn)
|
|
169
|
+
rule_id = engine.create_rule(
|
|
170
|
+
name=name, framework=framework,
|
|
171
|
+
retention_days=retention_days, action=action,
|
|
172
|
+
applies_to=applies_to, profile_id=profile,
|
|
173
|
+
)
|
|
175
174
|
|
|
176
175
|
return {
|
|
177
176
|
"success": True, "rule_id": rule_id,
|
|
@@ -190,10 +189,9 @@ async def delete_retention_policy(name: str = Query(...)):
|
|
|
190
189
|
return {"success": False, "error": "Compliance engine not available"}
|
|
191
190
|
try:
|
|
192
191
|
profile = get_active_profile()
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
conn.close()
|
|
192
|
+
with memory_write(DB_PATH) as conn:
|
|
193
|
+
engine = RetentionEngine(conn)
|
|
194
|
+
removed = engine.delete_rule(profile, name)
|
|
197
195
|
if not removed:
|
|
198
196
|
return {"success": False, "error": f"Policy '{name}' not found"}
|
|
199
197
|
return {"success": True, "active_profile": profile,
|
|
@@ -214,10 +212,9 @@ async def enforce_retention():
|
|
|
214
212
|
return {"success": False, "error": "Compliance engine not available"}
|
|
215
213
|
try:
|
|
216
214
|
profile = get_active_profile()
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
conn.close()
|
|
215
|
+
with memory_write(DB_PATH) as conn:
|
|
216
|
+
engine = RetentionEngine(conn)
|
|
217
|
+
result = engine.enforce(profile)
|
|
221
218
|
return {"success": True, **result}
|
|
222
219
|
except Exception:
|
|
223
220
|
logger.exception("enforce_retention error")
|
|
@@ -436,3 +436,86 @@ def put_forgetting_config(request: Request, body: ForgettingConfigUpdate):
|
|
|
436
436
|
except Exception:
|
|
437
437
|
logger.exception("put_forgetting_config failed")
|
|
438
438
|
return JSONResponse({"error": "Internal server error"}, status_code=500)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# ---------------------------------------------------------------------------
|
|
442
|
+
# Graph Pruning config (v3.8.4-G — GitHub #84)
|
|
443
|
+
# Dedicated endpoint so the forgetting config partial-update contract stays
|
|
444
|
+
# clean. Callers that only want graph knobs do not need to know about the
|
|
445
|
+
# Ebbinghaus curve, and vice-versa.
|
|
446
|
+
# ---------------------------------------------------------------------------
|
|
447
|
+
|
|
448
|
+
_GRAPH_PRUNING_DEFAULTS: dict = {
|
|
449
|
+
"max_degree_per_node": 100,
|
|
450
|
+
"min_edge_weight": 0.0,
|
|
451
|
+
"enabled": True,
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
class GraphPruningConfigUpdate(BaseModel):
|
|
456
|
+
"""Partial update model for graph pruning configuration.
|
|
457
|
+
|
|
458
|
+
All fields are optional so a PUT with only ``max_degree_per_node`` does
|
|
459
|
+
NOT reset ``min_edge_weight`` back to the default. Existing values are
|
|
460
|
+
preserved; only the supplied fields are overwritten.
|
|
461
|
+
"""
|
|
462
|
+
|
|
463
|
+
model_config = ConfigDict(extra="forbid")
|
|
464
|
+
|
|
465
|
+
max_degree_per_node: Optional[int] = Field(None, ge=1)
|
|
466
|
+
min_edge_weight: Optional[float] = Field(None, ge=0.0, le=1.0)
|
|
467
|
+
enabled: Optional[StrictBool] = None
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
# ---------------------------------------------------------------------------
|
|
471
|
+
# GET /api/v3/graph/config
|
|
472
|
+
# ---------------------------------------------------------------------------
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
@router.get("/graph/config")
|
|
476
|
+
def get_graph_config():
|
|
477
|
+
"""Return current graph thinning configuration.
|
|
478
|
+
|
|
479
|
+
Returns all three fields with their defaults when config.json has no
|
|
480
|
+
``graph_pruning`` section (old installations).
|
|
481
|
+
"""
|
|
482
|
+
try:
|
|
483
|
+
data = _read_config()
|
|
484
|
+
stored = data.get("graph_pruning", {})
|
|
485
|
+
result = {**_GRAPH_PRUNING_DEFAULTS, **stored}
|
|
486
|
+
# Return only known fields
|
|
487
|
+
return {k: result[k] for k in _GRAPH_PRUNING_DEFAULTS}
|
|
488
|
+
except Exception:
|
|
489
|
+
logger.exception("get_graph_config failed")
|
|
490
|
+
return JSONResponse({"error": "Internal server error"}, status_code=500)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
# ---------------------------------------------------------------------------
|
|
494
|
+
# PUT /api/v3/graph/config
|
|
495
|
+
# ---------------------------------------------------------------------------
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
@router.put("/graph/config")
|
|
499
|
+
def put_graph_config(request: Request, body: GraphPruningConfigUpdate):
|
|
500
|
+
"""Update graph thinning configuration.
|
|
501
|
+
|
|
502
|
+
Only supplied fields are written; all other graph pruning fields are
|
|
503
|
+
preserved. Changes take effect at the next maintenance cycle without
|
|
504
|
+
requiring a daemon restart (the scheduler reads graph_pruning live from
|
|
505
|
+
the config object, which the daemon reloads from disk on each cycle).
|
|
506
|
+
"""
|
|
507
|
+
_require_admin(request)
|
|
508
|
+
try:
|
|
509
|
+
updates = body.model_dump(exclude_none=True)
|
|
510
|
+
|
|
511
|
+
def mutate(data: dict) -> None:
|
|
512
|
+
stored = data.get("graph_pruning", {})
|
|
513
|
+
merged = {**_GRAPH_PRUNING_DEFAULTS, **stored, **updates}
|
|
514
|
+
data["graph_pruning"] = merged
|
|
515
|
+
|
|
516
|
+
data = _update_config(mutate)
|
|
517
|
+
merged = data["graph_pruning"]
|
|
518
|
+
return {k: merged[k] for k in _GRAPH_PRUNING_DEFAULTS}
|
|
519
|
+
except Exception:
|
|
520
|
+
logger.exception("put_graph_config failed")
|
|
521
|
+
return JSONResponse({"error": "Internal server error"}, status_code=500)
|
|
@@ -8,7 +8,7 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
from fastapi import APIRouter, HTTPException, Query, Request
|
|
10
10
|
|
|
11
|
-
from .helpers import get_active_profile, require_engine
|
|
11
|
+
from .helpers import get_active_profile, get_read_connection, require_engine
|
|
12
12
|
|
|
13
13
|
router = APIRouter(prefix="/api/entity", tags=["entity"])
|
|
14
14
|
|
|
@@ -82,9 +82,7 @@ def list_entities(
|
|
|
82
82
|
profile = profile or get_active_profile()
|
|
83
83
|
_require_read(request, profile)
|
|
84
84
|
|
|
85
|
-
|
|
86
|
-
conn = sqlite3.connect(str(engine._config.db_path))
|
|
87
|
-
conn.row_factory = sqlite3.Row
|
|
85
|
+
conn = get_read_connection(engine._config.db_path)
|
|
88
86
|
try:
|
|
89
87
|
where = ["ce.profile_id = ?"]
|
|
90
88
|
params: list[object] = [profile]
|
|
@@ -158,9 +156,7 @@ def get_entity(
|
|
|
158
156
|
_require_read(request, profile)
|
|
159
157
|
|
|
160
158
|
import json
|
|
161
|
-
|
|
162
|
-
conn = sqlite3.connect(str(engine._config.db_path))
|
|
163
|
-
conn.row_factory = sqlite3.Row
|
|
159
|
+
conn = get_read_connection(engine._config.db_path)
|
|
164
160
|
try:
|
|
165
161
|
# Search by canonical_name (case-insensitive)
|
|
166
162
|
row = conn.execute("""
|
|
@@ -15,6 +15,7 @@ from fastapi import APIRouter, Request
|
|
|
15
15
|
from pydantic import BaseModel
|
|
16
16
|
|
|
17
17
|
from superlocalmemory.server.config_file import read_config, update_config
|
|
18
|
+
from superlocalmemory.storage.read_connection import ReadConnectionFactory
|
|
18
19
|
|
|
19
20
|
from .helpers import MEMORY_DIR, get_active_profile
|
|
20
21
|
|
|
@@ -261,12 +262,9 @@ def evolution_lineage(request: Request, skill_name: str = ""):
|
|
|
261
262
|
_require_read(request)
|
|
262
263
|
conn = None
|
|
263
264
|
try:
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
db_path = str(MEMORY_DIR / "memory.db")
|
|
265
|
+
db_path = MEMORY_DIR / "memory.db"
|
|
267
266
|
profile_id = get_active_profile()
|
|
268
|
-
conn =
|
|
269
|
-
conn.row_factory = _sqlite3.Row
|
|
267
|
+
conn = ReadConnectionFactory(db_path).open()
|
|
270
268
|
|
|
271
269
|
if skill_name:
|
|
272
270
|
rows = conn.execute(
|
|
@@ -22,6 +22,7 @@ from fastapi import HTTPException, Request
|
|
|
22
22
|
from pydantic import BaseModel, Field
|
|
23
23
|
|
|
24
24
|
from superlocalmemory.infra.data_root import DynamicStatePath, canonical_data_root
|
|
25
|
+
from superlocalmemory.storage.memory_write import memory_read, memory_write
|
|
25
26
|
|
|
26
27
|
|
|
27
28
|
_engine_logger = logging.getLogger("superlocalmemory.engine")
|
|
@@ -216,14 +217,48 @@ def log_mode_change(
|
|
|
216
217
|
)
|
|
217
218
|
|
|
218
219
|
|
|
219
|
-
|
|
220
|
-
"""
|
|
221
|
-
|
|
220
|
+
class _RouteReadConnection:
|
|
221
|
+
"""Compatibility wrapper for legacy route callers that close manually.
|
|
222
|
+
|
|
223
|
+
New routes should prefer ``with memory_read(path)``. A few shared route
|
|
224
|
+
callers still expect ``get_db_connection()`` to return a connection they
|
|
225
|
+
can close themselves, so this wrapper preserves that contract without
|
|
226
|
+
reopening canonical ``memory.db`` in writable mode.
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
def __init__(self, db_path: Path) -> None:
|
|
230
|
+
object.__setattr__(self, "_snapshot", memory_read(db_path))
|
|
231
|
+
object.__setattr__(self, "_connection", self._snapshot.__enter__())
|
|
232
|
+
object.__setattr__(self, "_closed", False)
|
|
233
|
+
|
|
234
|
+
def __getattr__(self, name: str):
|
|
235
|
+
return getattr(self._connection, name)
|
|
236
|
+
|
|
237
|
+
def __setattr__(self, name: str, value) -> None:
|
|
238
|
+
if name.startswith("_"):
|
|
239
|
+
object.__setattr__(self, name, value)
|
|
240
|
+
else:
|
|
241
|
+
setattr(self._connection, name, value)
|
|
242
|
+
|
|
243
|
+
def close(self) -> None:
|
|
244
|
+
if not self._closed:
|
|
245
|
+
self._snapshot.__exit__(None, None, None)
|
|
246
|
+
object.__setattr__(self, "_closed", True)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def get_read_connection(db_path: Path = DB_PATH) -> _RouteReadConnection:
|
|
250
|
+
"""Return a legacy-compatible, physically read-only canonical connection."""
|
|
251
|
+
if not db_path.exists():
|
|
222
252
|
raise HTTPException(
|
|
223
253
|
status_code=500,
|
|
224
|
-
detail="Memory database not found. Run 'slm init' to initialize."
|
|
254
|
+
detail="Memory database not found. Run 'slm init' to initialize.",
|
|
225
255
|
)
|
|
226
|
-
return
|
|
256
|
+
return _RouteReadConnection(db_path)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def get_db_connection() -> _RouteReadConnection:
|
|
260
|
+
"""Return the shared dashboard read connection for canonical ``memory.db``."""
|
|
261
|
+
return get_read_connection(DB_PATH)
|
|
227
262
|
|
|
228
263
|
|
|
229
264
|
def dict_factory(cursor: sqlite3.Cursor, row: tuple) -> dict:
|
|
@@ -261,20 +296,22 @@ def validate_profile_name(name: str) -> bool:
|
|
|
261
296
|
|
|
262
297
|
|
|
263
298
|
def ensure_profile_in_db(name: str, description: str = "") -> None:
|
|
264
|
-
"""Ensure a profile row exists in SQLite (idempotent).
|
|
299
|
+
"""Ensure a profile row exists in SQLite (idempotent).
|
|
300
|
+
|
|
301
|
+
Hot path: called on every authenticated request. Uses ``memory_write()``
|
|
302
|
+
so in-process writers serialise through the write lock and cross-process
|
|
303
|
+
writers (hooks / CLI) wait via PRAGMA busy_timeout instead of getting
|
|
304
|
+
SQLITE_BUSY.
|
|
305
|
+
"""
|
|
265
306
|
if not DB_PATH.exists():
|
|
266
307
|
return
|
|
267
|
-
|
|
268
|
-
try:
|
|
308
|
+
with memory_write(DB_PATH) as conn:
|
|
269
309
|
conn.execute("PRAGMA foreign_keys=ON")
|
|
270
310
|
conn.execute(
|
|
271
311
|
"INSERT OR IGNORE INTO profiles (profile_id, name, description) "
|
|
272
312
|
"VALUES (?, ?, ?)",
|
|
273
313
|
(name, name, description or f"Memory profile: {name}"),
|
|
274
314
|
)
|
|
275
|
-
conn.commit()
|
|
276
|
-
finally:
|
|
277
|
-
conn.close()
|
|
278
315
|
|
|
279
316
|
|
|
280
317
|
def ensure_profile_in_json(name: str, description: str = "") -> None:
|
|
@@ -347,11 +384,12 @@ def delete_profile_from_db(name: str) -> None:
|
|
|
347
384
|
rbac_memberships has no FK to profiles, so CASCADE does not remove role
|
|
348
385
|
grants — they would otherwise survive deletion and silently re-activate if
|
|
349
386
|
a profile of the same name is later recreated. Remove them explicitly.
|
|
387
|
+
Uses ``memory_write()`` so the multi-statement DELETE is atomic and
|
|
388
|
+
serialised against other in-process writers.
|
|
350
389
|
"""
|
|
351
390
|
if not DB_PATH.exists():
|
|
352
391
|
return
|
|
353
|
-
|
|
354
|
-
try:
|
|
392
|
+
with memory_write(DB_PATH) as conn:
|
|
355
393
|
conn.execute("PRAGMA foreign_keys=ON")
|
|
356
394
|
# Purge role grants for this workspace (no FK CASCADE covers these).
|
|
357
395
|
for tbl in ("rbac_memberships",):
|
|
@@ -360,27 +398,21 @@ def delete_profile_from_db(name: str) -> None:
|
|
|
360
398
|
except sqlite3.OperationalError:
|
|
361
399
|
pass # table may not exist on older installs
|
|
362
400
|
conn.execute("DELETE FROM profiles WHERE profile_id = ?", (name,))
|
|
363
|
-
conn.commit()
|
|
364
|
-
finally:
|
|
365
|
-
conn.close()
|
|
366
401
|
|
|
367
402
|
|
|
368
403
|
def _get_db_profiles() -> list[dict]:
|
|
369
404
|
"""Read all profiles from SQLite."""
|
|
370
405
|
if not DB_PATH.exists():
|
|
371
406
|
return []
|
|
372
|
-
conn = sqlite3.connect(str(DB_PATH))
|
|
373
|
-
conn.row_factory = sqlite3.Row
|
|
374
407
|
try:
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
408
|
+
with memory_read(DB_PATH) as conn:
|
|
409
|
+
rows = conn.execute(
|
|
410
|
+
"SELECT profile_id, name, description, created_at, last_used "
|
|
411
|
+
"FROM profiles ORDER BY name"
|
|
412
|
+
).fetchall()
|
|
413
|
+
return [dict(r) for r in rows]
|
|
380
414
|
except sqlite3.OperationalError:
|
|
381
415
|
return []
|
|
382
|
-
finally:
|
|
383
|
-
conn.close()
|
|
384
416
|
|
|
385
417
|
|
|
386
418
|
def _load_profiles_json() -> dict:
|
|
@@ -17,7 +17,7 @@ from typing import Any, Callable
|
|
|
17
17
|
|
|
18
18
|
from fastapi import APIRouter, Query
|
|
19
19
|
from fastapi.responses import JSONResponse
|
|
20
|
-
from superlocalmemory.server.routes.helpers import DB_PATH, get_active_profile
|
|
20
|
+
from superlocalmemory.server.routes.helpers import DB_PATH, get_active_profile, get_read_connection
|
|
21
21
|
|
|
22
22
|
logger = logging.getLogger(__name__)
|
|
23
23
|
|
|
@@ -31,9 +31,7 @@ def _get_conn(profile: str = "") -> tuple[sqlite3.Connection | None, str]:
|
|
|
31
31
|
pid = profile or get_active_profile()
|
|
32
32
|
if not DB_PATH.exists():
|
|
33
33
|
return None, pid
|
|
34
|
-
|
|
35
|
-
conn.row_factory = sqlite3.Row
|
|
36
|
-
return conn, pid
|
|
34
|
+
return get_read_connection(DB_PATH), pid
|
|
37
35
|
|
|
38
36
|
|
|
39
37
|
# ── Action Handlers ───────────────────────────────────────────────
|
|
@@ -12,7 +12,6 @@ Uses V3 learning modules: FeedbackCollector, EngagementTracker, AdaptiveLearner.
|
|
|
12
12
|
import logging
|
|
13
13
|
import shutil
|
|
14
14
|
import sqlite3
|
|
15
|
-
from contextlib import closing
|
|
16
15
|
from datetime import datetime, timezone
|
|
17
16
|
from pathlib import Path
|
|
18
17
|
|
|
@@ -30,6 +29,7 @@ from .learning_telemetry import (
|
|
|
30
29
|
from .learning_telemetry import (
|
|
31
30
|
sqlite_status as _sqlite_status,
|
|
32
31
|
)
|
|
32
|
+
from superlocalmemory.storage.memory_write import memory_read
|
|
33
33
|
|
|
34
34
|
logger = logging.getLogger("superlocalmemory.routes.learning")
|
|
35
35
|
router = APIRouter()
|
|
@@ -667,12 +667,9 @@ def get_patterns():
|
|
|
667
667
|
|
|
668
668
|
# Graph intelligence contribution to learning (v3.4.1)
|
|
669
669
|
try:
|
|
670
|
-
import sqlite3 as _sqlite3
|
|
671
|
-
|
|
672
670
|
from superlocalmemory.server.routes.helpers import DB_PATH
|
|
673
671
|
if DB_PATH.exists():
|
|
674
|
-
with
|
|
675
|
-
conn.row_factory = _sqlite3.Row
|
|
672
|
+
with memory_read(DB_PATH) as conn:
|
|
676
673
|
row = conn.execute(
|
|
677
674
|
"SELECT COUNT(*) AS cnt, "
|
|
678
675
|
"COUNT(DISTINCT community_id) AS communities, "
|
|
@@ -7,13 +7,12 @@
|
|
|
7
7
|
Routes: /api/lifecycle/status, /api/lifecycle/compact
|
|
8
8
|
Uses V3 compliance.lifecycle.LifecycleManager.
|
|
9
9
|
"""
|
|
10
|
-
import json
|
|
11
10
|
import logging
|
|
12
11
|
import sqlite3
|
|
13
12
|
|
|
14
13
|
from fastapi import APIRouter, Request
|
|
15
14
|
|
|
16
|
-
from .helpers import get_active_profile, get_engine_lazy,
|
|
15
|
+
from .helpers import DB_PATH, get_active_profile, get_engine_lazy, get_read_connection
|
|
17
16
|
from superlocalmemory.server.route_mutations import authorize_route_mutation
|
|
18
17
|
|
|
19
18
|
logger = logging.getLogger("superlocalmemory.routes.lifecycle")
|
|
@@ -36,8 +35,7 @@ async def lifecycle_status():
|
|
|
36
35
|
|
|
37
36
|
try:
|
|
38
37
|
profile = get_active_profile()
|
|
39
|
-
conn =
|
|
40
|
-
conn.row_factory = sqlite3.Row
|
|
38
|
+
conn = get_read_connection(DB_PATH)
|
|
41
39
|
|
|
42
40
|
# V3.3: Use fact_retention.lifecycle_zone (Ebbinghaus-driven, authoritative)
|
|
43
41
|
# Falls back to atomic_facts.lifecycle for pre-3.3 databases
|