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
|
@@ -29,6 +29,8 @@ import uuid
|
|
|
29
29
|
from datetime import datetime, timezone
|
|
30
30
|
from pathlib import Path
|
|
31
31
|
|
|
32
|
+
from superlocalmemory.storage.write_lock import get_write_lock
|
|
33
|
+
|
|
32
34
|
logger = logging.getLogger(__name__)
|
|
33
35
|
|
|
34
36
|
|
|
@@ -55,64 +57,67 @@ def apply_merges(
|
|
|
55
57
|
if not candidates:
|
|
56
58
|
return 0
|
|
57
59
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
+
# Acquire the process-level write lock BEFORE opening the sqlite3
|
|
61
|
+
# connection. This serialises BEGIN IMMEDIATE → commit with all other
|
|
62
|
+
# in-process writers (DatabaseManager, VectorStore, adapter sync,
|
|
63
|
+
# reward_archive), eliminating SQLITE_BUSY races at the WAL layer.
|
|
60
64
|
applied = 0
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
65
|
+
with get_write_lock(memory_db_path):
|
|
66
|
+
conn = sqlite3.connect(str(memory_db_path), timeout=10.0)
|
|
67
|
+
conn.execute("PRAGMA busy_timeout=2000")
|
|
68
|
+
# S-L02: track the candidate list in flight so a rollback diagnostic
|
|
69
|
+
# can blame the exact set of (canonical, merged) pairs instead of a
|
|
70
|
+
# blanket "rollback" message.
|
|
71
|
+
in_flight: list[tuple[str, str]] = []
|
|
72
|
+
try:
|
|
73
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
74
|
+
for canonical_id, merged_id, cos, jac in candidates:
|
|
75
|
+
# Skip if already merged in a prior cycle.
|
|
76
|
+
row = conn.execute(
|
|
77
|
+
"SELECT archive_status FROM atomic_facts WHERE fact_id=?",
|
|
78
|
+
(merged_id,),
|
|
79
|
+
).fetchone()
|
|
80
|
+
if row is None:
|
|
81
|
+
continue
|
|
82
|
+
if row[0] == "merged":
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
conn.execute(
|
|
86
|
+
"INSERT INTO memory_merge_log "
|
|
87
|
+
"(merge_id, profile_id, canonical_fact_id, merged_fact_id, "
|
|
88
|
+
" cosine_sim, entity_jaccard, merged_at, reversible) "
|
|
89
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, 1)",
|
|
90
|
+
(
|
|
91
|
+
str(uuid.uuid4()),
|
|
92
|
+
profile_id,
|
|
93
|
+
canonical_id,
|
|
94
|
+
merged_id,
|
|
95
|
+
float(cos),
|
|
96
|
+
float(jac),
|
|
97
|
+
_iso_now(),
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
conn.execute(
|
|
101
|
+
"UPDATE atomic_facts "
|
|
102
|
+
"SET archive_status='merged', "
|
|
103
|
+
" archive_reason='cosine_dup', "
|
|
104
|
+
" merged_into=? "
|
|
105
|
+
"WHERE fact_id=?",
|
|
106
|
+
(canonical_id, merged_id),
|
|
107
|
+
)
|
|
108
|
+
applied += 1
|
|
109
|
+
in_flight.append((canonical_id, merged_id))
|
|
110
|
+
conn.commit()
|
|
111
|
+
except sqlite3.Error as exc:
|
|
112
|
+
conn.rollback()
|
|
113
|
+
logger.warning(
|
|
114
|
+
"apply_merges rollback: profile=%s pre-rollback_applied=%d "
|
|
115
|
+
"in_flight=%s error=%s",
|
|
116
|
+
profile_id, applied, in_flight, exc,
|
|
102
117
|
)
|
|
103
|
-
applied
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
except sqlite3.Error as exc:
|
|
107
|
-
conn.rollback()
|
|
108
|
-
logger.warning(
|
|
109
|
-
"apply_merges rollback: profile=%s pre-rollback_applied=%d "
|
|
110
|
-
"in_flight=%s error=%s",
|
|
111
|
-
profile_id, applied, in_flight, exc,
|
|
112
|
-
)
|
|
113
|
-
applied = 0
|
|
114
|
-
finally:
|
|
115
|
-
conn.close()
|
|
118
|
+
applied = 0
|
|
119
|
+
finally:
|
|
120
|
+
conn.close()
|
|
116
121
|
return applied
|
|
117
122
|
|
|
118
123
|
|
|
@@ -122,39 +127,49 @@ def unmerge(memory_db_path: str | Path, merge_id: str) -> bool:
|
|
|
122
127
|
Flips the merged fact's archive_status back to 'live', clears
|
|
123
128
|
merged_into, and marks the log row ``reversible=0``.
|
|
124
129
|
"""
|
|
125
|
-
|
|
126
|
-
|
|
130
|
+
# READ phase — check reversibility without holding the write lock.
|
|
131
|
+
conn_r = sqlite3.connect(str(memory_db_path), timeout=10.0)
|
|
127
132
|
try:
|
|
128
|
-
row =
|
|
133
|
+
row = conn_r.execute(
|
|
129
134
|
"SELECT merged_fact_id, reversible FROM memory_merge_log "
|
|
130
135
|
"WHERE merge_id=?",
|
|
131
136
|
(merge_id,),
|
|
132
137
|
).fetchone()
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
merged_fid, reversible = row
|
|
136
|
-
if not reversible:
|
|
137
|
-
return False
|
|
138
|
+
finally:
|
|
139
|
+
conn_r.close()
|
|
138
140
|
|
|
139
|
-
|
|
140
|
-
conn.execute(
|
|
141
|
-
"UPDATE atomic_facts "
|
|
142
|
-
"SET archive_status='live', archive_reason=NULL, merged_into=NULL "
|
|
143
|
-
"WHERE fact_id=?",
|
|
144
|
-
(merged_fid,),
|
|
145
|
-
)
|
|
146
|
-
conn.execute(
|
|
147
|
-
"UPDATE memory_merge_log SET reversible=0 WHERE merge_id=?",
|
|
148
|
-
(merge_id,),
|
|
149
|
-
)
|
|
150
|
-
conn.commit()
|
|
151
|
-
return True
|
|
152
|
-
except sqlite3.Error as exc:
|
|
153
|
-
conn.rollback()
|
|
154
|
-
logger.warning("unmerge rollback: %s", exc)
|
|
141
|
+
if row is None:
|
|
155
142
|
return False
|
|
156
|
-
|
|
157
|
-
|
|
143
|
+
merged_fid, reversible = row
|
|
144
|
+
if not reversible:
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
# WRITE phase — acquire the process-level write lock before
|
|
148
|
+
# opening the write connection, serialising with all other in-process
|
|
149
|
+
# writers and eliminating SQLITE_BUSY at the WAL layer.
|
|
150
|
+
with get_write_lock(memory_db_path):
|
|
151
|
+
conn = sqlite3.connect(str(memory_db_path), timeout=10.0)
|
|
152
|
+
conn.execute("PRAGMA busy_timeout=2000")
|
|
153
|
+
try:
|
|
154
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
155
|
+
conn.execute(
|
|
156
|
+
"UPDATE atomic_facts "
|
|
157
|
+
"SET archive_status='live', archive_reason=NULL, merged_into=NULL "
|
|
158
|
+
"WHERE fact_id=?",
|
|
159
|
+
(merged_fid,),
|
|
160
|
+
)
|
|
161
|
+
conn.execute(
|
|
162
|
+
"UPDATE memory_merge_log SET reversible=0 WHERE merge_id=?",
|
|
163
|
+
(merge_id,),
|
|
164
|
+
)
|
|
165
|
+
conn.commit()
|
|
166
|
+
return True
|
|
167
|
+
except sqlite3.Error as exc:
|
|
168
|
+
conn.rollback()
|
|
169
|
+
logger.warning("unmerge rollback: %s", exc)
|
|
170
|
+
return False
|
|
171
|
+
finally:
|
|
172
|
+
conn.close()
|
|
158
173
|
|
|
159
174
|
|
|
160
175
|
__all__ = ("apply_merges", "unmerge")
|
|
@@ -34,6 +34,7 @@ from pathlib import Path
|
|
|
34
34
|
from superlocalmemory.learning.fact_outcome_joins import (
|
|
35
35
|
has_recent_positive_reward,
|
|
36
36
|
)
|
|
37
|
+
from superlocalmemory.storage.write_lock import get_write_lock
|
|
37
38
|
|
|
38
39
|
logger = logging.getLogger(__name__)
|
|
39
40
|
|
|
@@ -80,13 +81,16 @@ def run_reward_gated_archive(
|
|
|
80
81
|
return []
|
|
81
82
|
|
|
82
83
|
archived: list[str] = []
|
|
83
|
-
conn = sqlite3.connect(str(memory_db_path), timeout=10.0)
|
|
84
|
-
conn.row_factory = sqlite3.Row
|
|
85
|
-
conn.execute("PRAGMA busy_timeout=2000")
|
|
86
84
|
|
|
85
|
+
# READ phase — no write lock needed (WAL allows concurrent reads).
|
|
86
|
+
# H-12/H-P-02: compute the archivable set BEFORE acquiring the writer
|
|
87
|
+
# lock so that the lock is held only for the bulk INSERT/UPDATE pass.
|
|
88
|
+
conn_r = sqlite3.connect(str(memory_db_path), timeout=10.0)
|
|
89
|
+
conn_r.row_factory = sqlite3.Row
|
|
90
|
+
conn_r.execute("PRAGMA busy_timeout=2000")
|
|
87
91
|
try:
|
|
88
92
|
placeholders = ",".join("?" for _ in candidate_fact_ids)
|
|
89
|
-
rows =
|
|
93
|
+
rows = conn_r.execute(
|
|
90
94
|
f"SELECT fact_id, content, canonical_entities_json, importance, "
|
|
91
95
|
f" confidence, embedding, created_at "
|
|
92
96
|
f"FROM atomic_facts "
|
|
@@ -95,13 +99,6 @@ def run_reward_gated_archive(
|
|
|
95
99
|
(profile_id, *candidate_fact_ids),
|
|
96
100
|
).fetchall()
|
|
97
101
|
|
|
98
|
-
# H-12/H-P-02: compute the archivable set BEFORE acquiring the
|
|
99
|
-
# writer lock. Previously ``BEGIN IMMEDIATE`` wrapped the full
|
|
100
|
-
# per-candidate reward-lookup loop — holding RESERVED for the
|
|
101
|
-
# entire scan starved concurrent ``record_recall`` writers out
|
|
102
|
-
# with SQLITE_BUSY after their 50 ms busy_timeout. Splitting the
|
|
103
|
-
# read phase keeps the writer lock held only for the bulk
|
|
104
|
-
# INSERT/UPDATE pass.
|
|
105
102
|
to_archive: list[dict] = []
|
|
106
103
|
for row in rows:
|
|
107
104
|
fid = row["fact_id"]
|
|
@@ -111,7 +108,7 @@ def run_reward_gated_archive(
|
|
|
111
108
|
# 2. Recent positive reward skip (criterion 2).
|
|
112
109
|
# H-06 fix — JSON1 equality join via helper.
|
|
113
110
|
if has_recent_positive_reward(
|
|
114
|
-
|
|
111
|
+
conn_r, profile_id, fid,
|
|
115
112
|
min_reward=ARCHIVE_REWARD_THRESHOLD,
|
|
116
113
|
window_days=REWARD_WINDOW_DAYS,
|
|
117
114
|
):
|
|
@@ -125,86 +122,97 @@ def run_reward_gated_archive(
|
|
|
125
122
|
"embedding": row["embedding"],
|
|
126
123
|
"created_at": row["created_at"],
|
|
127
124
|
})
|
|
125
|
+
finally:
|
|
126
|
+
conn_r.close()
|
|
128
127
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
"
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
# replaced with a minimal stub + ``truncated`` reason so the
|
|
165
|
-
# archive row stays within the I4 disk budget while still
|
|
166
|
-
# pointing back to the original ``fact_id`` in atomic_facts.
|
|
167
|
-
payload_str = json.dumps(payload)
|
|
168
|
-
reason = "reward_gated_ebbinghaus"
|
|
169
|
-
if len(payload_str.encode("utf-8")) > PAYLOAD_JSON_MAX_BYTES:
|
|
170
|
-
payload_str = json.dumps({
|
|
128
|
+
if not to_archive:
|
|
129
|
+
return []
|
|
130
|
+
|
|
131
|
+
# WRITE phase — acquire the process-level write lock BEFORE opening the
|
|
132
|
+
# write connection. This serialises BEGIN IMMEDIATE → commit with all
|
|
133
|
+
# other in-process writers (DatabaseManager, VectorStore, adapter sync,
|
|
134
|
+
# memory_merge, reward_boost), eliminating SQLITE_BUSY races at the WAL
|
|
135
|
+
# layer.
|
|
136
|
+
with get_write_lock(memory_db_path):
|
|
137
|
+
conn = sqlite3.connect(str(memory_db_path), timeout=10.0)
|
|
138
|
+
conn.row_factory = sqlite3.Row
|
|
139
|
+
conn.execute("PRAGMA busy_timeout=2000")
|
|
140
|
+
try:
|
|
141
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
142
|
+
# S9-SKEP-07: re-verify reward under RESERVED lock. Between the
|
|
143
|
+
# read-only reward scan above and this BEGIN IMMEDIATE another
|
|
144
|
+
# writer may have inserted a positive reward row. With the writer
|
|
145
|
+
# lock held we know no further inserts are landing; one last
|
|
146
|
+
# check per entry catches everything that raced in.
|
|
147
|
+
verified: list[dict] = []
|
|
148
|
+
for entry in to_archive:
|
|
149
|
+
if has_recent_positive_reward(
|
|
150
|
+
conn, profile_id, entry["fid"],
|
|
151
|
+
min_reward=ARCHIVE_REWARD_THRESHOLD,
|
|
152
|
+
window_days=REWARD_WINDOW_DAYS,
|
|
153
|
+
):
|
|
154
|
+
continue
|
|
155
|
+
verified.append(entry)
|
|
156
|
+
to_archive = verified
|
|
157
|
+
if not to_archive:
|
|
158
|
+
conn.execute("COMMIT")
|
|
159
|
+
return []
|
|
160
|
+
for entry in to_archive:
|
|
161
|
+
fid = entry["fid"]
|
|
162
|
+
payload = {
|
|
171
163
|
"fact_id": fid,
|
|
172
|
-
"
|
|
173
|
-
"
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
"
|
|
178
|
-
|
|
164
|
+
"content": entry["content"],
|
|
165
|
+
"canonical_entities_json": entry["canonical_entities_json"],
|
|
166
|
+
"importance": entry["importance"],
|
|
167
|
+
"confidence": entry["confidence"],
|
|
168
|
+
"embedding": entry["embedding"],
|
|
169
|
+
"created_at": entry["created_at"],
|
|
170
|
+
}
|
|
171
|
+
# SEC-L3 — cap payload_json at 256 KB. Oversize blobs are
|
|
172
|
+
# replaced with a minimal stub + ``truncated`` reason so the
|
|
173
|
+
# archive row stays within the I4 disk budget while still
|
|
174
|
+
# pointing back to the original ``fact_id`` in atomic_facts.
|
|
175
|
+
payload_str = json.dumps(payload)
|
|
176
|
+
reason = "reward_gated_ebbinghaus"
|
|
177
|
+
if len(payload_str.encode("utf-8")) > PAYLOAD_JSON_MAX_BYTES:
|
|
178
|
+
payload_str = json.dumps({
|
|
179
|
+
"fact_id": fid,
|
|
180
|
+
"truncated": True,
|
|
181
|
+
"original_bytes": len(payload_str.encode("utf-8")),
|
|
182
|
+
})
|
|
183
|
+
reason = "reward_gated_ebbinghaus_truncated"
|
|
184
|
+
logger.warning(
|
|
185
|
+
"memory_archive payload >%d bytes for fact_id=%s; "
|
|
186
|
+
"truncated to stub", PAYLOAD_JSON_MAX_BYTES, fid,
|
|
187
|
+
)
|
|
188
|
+
conn.execute(
|
|
189
|
+
"INSERT INTO memory_archive "
|
|
190
|
+
"(archive_id, fact_id, profile_id, payload_json, "
|
|
191
|
+
" archived_at, reason) VALUES (?, ?, ?, ?, ?, ?)",
|
|
192
|
+
(
|
|
193
|
+
str(uuid.uuid4()),
|
|
194
|
+
fid,
|
|
195
|
+
profile_id,
|
|
196
|
+
payload_str,
|
|
197
|
+
_iso_now(),
|
|
198
|
+
reason,
|
|
199
|
+
),
|
|
179
200
|
)
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
fid,
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
)
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
" archive_reason='reward_gated_ebbinghaus' "
|
|
197
|
-
"WHERE fact_id=? "
|
|
198
|
-
" AND (archive_status IS NULL OR archive_status='live')",
|
|
199
|
-
(fid,),
|
|
200
|
-
)
|
|
201
|
-
archived.append(fid)
|
|
202
|
-
|
|
203
|
-
conn.commit()
|
|
204
|
-
except sqlite3.Error as exc:
|
|
205
|
-
conn.rollback()
|
|
206
|
-
logger.warning("run_reward_gated_archive rollback: %s", exc)
|
|
207
|
-
finally:
|
|
208
|
-
conn.close()
|
|
201
|
+
conn.execute(
|
|
202
|
+
"UPDATE atomic_facts "
|
|
203
|
+
"SET archive_status='archived', "
|
|
204
|
+
" archive_reason='reward_gated_ebbinghaus' "
|
|
205
|
+
"WHERE fact_id=? "
|
|
206
|
+
" AND (archive_status IS NULL OR archive_status='live')",
|
|
207
|
+
(fid,),
|
|
208
|
+
)
|
|
209
|
+
archived.append(fid)
|
|
210
|
+
|
|
211
|
+
conn.commit()
|
|
212
|
+
except sqlite3.Error as exc:
|
|
213
|
+
conn.rollback()
|
|
214
|
+
logger.warning("run_reward_gated_archive rollback: %s", exc)
|
|
215
|
+
finally:
|
|
216
|
+
conn.close()
|
|
209
217
|
|
|
210
218
|
return archived
|
|
@@ -23,6 +23,7 @@ from pathlib import Path
|
|
|
23
23
|
from superlocalmemory.learning.fact_outcome_joins import (
|
|
24
24
|
aggregate_reward_for_fact,
|
|
25
25
|
)
|
|
26
|
+
from superlocalmemory.storage.write_lock import get_write_lock
|
|
26
27
|
|
|
27
28
|
|
|
28
29
|
# H-12/H-P-01: single-pass JSON1 aggregation across ALL facts for a profile.
|
|
@@ -123,45 +124,54 @@ def apply_strong_memory_boost(
|
|
|
123
124
|
|
|
124
125
|
Returns number of rows boosted.
|
|
125
126
|
"""
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
boosted = 0
|
|
127
|
+
# READ phase — no write lock needed (WAL allows concurrent reads).
|
|
128
|
+
conn_r = sqlite3.connect(str(memory_db_path), timeout=10.0)
|
|
129
129
|
try:
|
|
130
|
-
rows =
|
|
130
|
+
rows = conn_r.execute(
|
|
131
131
|
"SELECT fact_id FROM atomic_facts WHERE profile_id=? "
|
|
132
132
|
" AND (archive_status IS NULL OR archive_status='live')",
|
|
133
133
|
(profile_id,),
|
|
134
134
|
).fetchall()
|
|
135
135
|
if not rows:
|
|
136
|
+
conn_r.close()
|
|
136
137
|
return 0
|
|
137
|
-
|
|
138
138
|
# H-12/H-P-01: single JSON1 GROUP BY replaces the per-fact loop.
|
|
139
|
-
|
|
140
|
-
# SQLite without JSON1.
|
|
141
|
-
stats = _bulk_fact_reward_stats(conn, profile_id)
|
|
142
|
-
conn.execute("BEGIN IMMEDIATE")
|
|
143
|
-
for (fid,) in rows:
|
|
144
|
-
if stats:
|
|
145
|
-
count, mean = stats.get(fid, _MISS)
|
|
146
|
-
else:
|
|
147
|
-
count, mean = aggregate_reward_for_fact(conn, profile_id, fid)
|
|
148
|
-
if count < STRONG_BOOST_MIN_OUTCOMES:
|
|
149
|
-
continue
|
|
150
|
-
if mean <= STRONG_BOOST_MIN_MEAN:
|
|
151
|
-
continue
|
|
152
|
-
conn.execute(
|
|
153
|
-
"UPDATE atomic_facts "
|
|
154
|
-
"SET retrieval_prior = MIN(COALESCE(retrieval_prior, 0) + ?, ?) "
|
|
155
|
-
"WHERE fact_id=?",
|
|
156
|
-
(STRONG_BOOST_INCREMENT, STRONG_BOOST_CAP, fid),
|
|
157
|
-
)
|
|
158
|
-
boosted += 1
|
|
159
|
-
conn.commit()
|
|
160
|
-
except sqlite3.Error as exc:
|
|
161
|
-
conn.rollback()
|
|
162
|
-
logger.warning("apply_strong_memory_boost rollback: %s", exc)
|
|
139
|
+
stats = _bulk_fact_reward_stats(conn_r, profile_id)
|
|
163
140
|
finally:
|
|
164
|
-
|
|
141
|
+
conn_r.close()
|
|
142
|
+
|
|
143
|
+
# WRITE phase — acquire the process-level write lock BEFORE opening the
|
|
144
|
+
# write connection. This serialises BEGIN IMMEDIATE → commit with all
|
|
145
|
+
# other in-process writers (DatabaseManager, VectorStore, adapter sync),
|
|
146
|
+
# eliminating SQLITE_BUSY races at the SQLite WAL layer.
|
|
147
|
+
boosted = 0
|
|
148
|
+
with get_write_lock(memory_db_path):
|
|
149
|
+
conn = sqlite3.connect(str(memory_db_path), timeout=10.0)
|
|
150
|
+
conn.execute("PRAGMA busy_timeout=2000")
|
|
151
|
+
try:
|
|
152
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
153
|
+
for (fid,) in rows:
|
|
154
|
+
if stats:
|
|
155
|
+
count, mean = stats.get(fid, _MISS)
|
|
156
|
+
else:
|
|
157
|
+
count, mean = aggregate_reward_for_fact(conn, profile_id, fid)
|
|
158
|
+
if count < STRONG_BOOST_MIN_OUTCOMES:
|
|
159
|
+
continue
|
|
160
|
+
if mean <= STRONG_BOOST_MIN_MEAN:
|
|
161
|
+
continue
|
|
162
|
+
conn.execute(
|
|
163
|
+
"UPDATE atomic_facts "
|
|
164
|
+
"SET retrieval_prior = MIN(COALESCE(retrieval_prior, 0) + ?, ?) "
|
|
165
|
+
"WHERE fact_id=?",
|
|
166
|
+
(STRONG_BOOST_INCREMENT, STRONG_BOOST_CAP, fid),
|
|
167
|
+
)
|
|
168
|
+
boosted += 1
|
|
169
|
+
conn.commit()
|
|
170
|
+
except sqlite3.Error as exc:
|
|
171
|
+
conn.rollback()
|
|
172
|
+
logger.warning("apply_strong_memory_boost rollback: %s", exc)
|
|
173
|
+
finally:
|
|
174
|
+
conn.close()
|
|
165
175
|
return boosted
|
|
166
176
|
|
|
167
177
|
|
|
@@ -81,6 +81,7 @@ CREATE TABLE IF NOT EXISTS source_quality_repair_state (
|
|
|
81
81
|
_MAX_FACTS_PER_OUTCOME = 100
|
|
82
82
|
_MAX_SOURCES_PER_OUTCOME = 100
|
|
83
83
|
_PROVENANCE_QUERY_CHUNK = 500
|
|
84
|
+
_SCHEMA_INIT_LOCK = threading.RLock()
|
|
84
85
|
|
|
85
86
|
|
|
86
87
|
class SourceQualityRepairUnavailable(RuntimeError):
|
|
@@ -114,45 +115,47 @@ class SourceQualityScorer:
|
|
|
114
115
|
# ------------------------------------------------------------------
|
|
115
116
|
|
|
116
117
|
def _ensure_schema(self) -> None:
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
118
|
+
# Serialize scorer instances in this process before asking SQLite for
|
|
119
|
+
# its cross-process BEGIN IMMEDIATE lease. This prevents two startup
|
|
120
|
+
# threads from racing the journal-mode/schema bootstrap while SQLite's
|
|
121
|
+
# busy timeout protects the equivalent multi-process boundary.
|
|
122
|
+
with _SCHEMA_INIT_LOCK:
|
|
123
|
+
conn = self._connect()
|
|
124
|
+
try:
|
|
125
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
126
|
+
conn.execute(_CREATE_TABLE)
|
|
127
|
+
conn.execute(_CREATE_UNIQUE)
|
|
128
|
+
conn.execute(_CREATE_OBSERVATIONS)
|
|
129
|
+
conn.execute(_CREATE_REPAIR_STATE)
|
|
130
|
+
repair_columns = {
|
|
131
|
+
str(row["name"])
|
|
132
|
+
for row in conn.execute(
|
|
133
|
+
"PRAGMA table_info(source_quality_repair_state)"
|
|
134
|
+
).fetchall()
|
|
135
|
+
}
|
|
136
|
+
if "last_settled_at" not in repair_columns:
|
|
137
|
+
conn.execute(
|
|
138
|
+
"ALTER TABLE source_quality_repair_state "
|
|
139
|
+
"ADD COLUMN last_settled_at TEXT NOT NULL DEFAULT ''"
|
|
140
|
+
)
|
|
141
|
+
if "last_outcome_id" not in repair_columns:
|
|
142
|
+
conn.execute(
|
|
143
|
+
"ALTER TABLE source_quality_repair_state "
|
|
144
|
+
"ADD COLUMN last_outcome_id TEXT NOT NULL DEFAULT ''"
|
|
145
|
+
)
|
|
146
|
+
conn.commit()
|
|
147
|
+
except Exception:
|
|
148
|
+
conn.rollback()
|
|
149
|
+
raise
|
|
150
|
+
finally:
|
|
151
|
+
conn.close()
|
|
151
152
|
|
|
152
153
|
def _connect(self) -> sqlite3.Connection:
|
|
153
154
|
conn = sqlite3.connect(str(self._db_path), timeout=10)
|
|
155
|
+
# Install the busy handler before journal negotiation. On a fresh
|
|
156
|
+
# database, PRAGMA journal_mode itself may contend with another scorer.
|
|
157
|
+
conn.execute("PRAGMA busy_timeout=10000")
|
|
154
158
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
155
|
-
conn.execute("PRAGMA busy_timeout=5000")
|
|
156
159
|
conn.row_factory = sqlite3.Row
|
|
157
160
|
return conn
|
|
158
161
|
|