superlocalmemory 3.8.3 → 3.8.5
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 +42 -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 +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/rbac.py +68 -76
- package/src/superlocalmemory/cli/commands.py +19 -0
- 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/config.py +78 -0
- package/src/superlocalmemory/core/consolidation_engine.py +79 -73
- package/src/superlocalmemory/core/engine.py +92 -11
- 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 +160 -31
- package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
- package/src/superlocalmemory/core/recall_pipeline.py +3 -0
- package/src/superlocalmemory/core/registry.py +5 -1
- 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/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/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/mcp/http_transport.py +335 -3
- package/src/superlocalmemory/retrieval/engine.py +7 -1
- package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
- package/src/superlocalmemory/retrieval/reranker.py +98 -15
- package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
- package/src/superlocalmemory/retrieval/vector_store.py +84 -69
- package/src/superlocalmemory/server/loopback.py +91 -0
- package/src/superlocalmemory/server/origin.py +9 -4
- package/src/superlocalmemory/server/routes/backup.py +6 -2
- package/src/superlocalmemory/server/routes/behavioral.py +6 -12
- package/src/superlocalmemory/server/routes/compliance.py +20 -23
- package/src/superlocalmemory/server/routes/config_api.py +83 -0
- package/src/superlocalmemory/server/routes/helpers.py +24 -13
- package/src/superlocalmemory/server/routes/memories.py +67 -68
- 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 +42 -30
- package/src/superlocalmemory/server/routes/v3_api.py +67 -77
- package/src/superlocalmemory/server/unified_daemon.py +200 -31
- package/src/superlocalmemory/server/write_identity.py +22 -4
- package/src/superlocalmemory/storage/database.py +109 -19
- package/src/superlocalmemory/storage/deferred_writes.py +153 -0
- package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
- package/src/superlocalmemory/storage/memory_write.py +119 -0
- package/src/superlocalmemory/storage/migration_runner.py +7 -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/write_lock.py +88 -0
|
@@ -18,7 +18,6 @@ from __future__ import annotations
|
|
|
18
18
|
|
|
19
19
|
import json
|
|
20
20
|
import logging
|
|
21
|
-
import sqlite3
|
|
22
21
|
import time
|
|
23
22
|
import uuid
|
|
24
23
|
from datetime import datetime, timezone
|
|
@@ -50,27 +49,30 @@ class EntityCompiler:
|
|
|
50
49
|
"""Compile all entities that have new facts across all projects.
|
|
51
50
|
|
|
52
51
|
Returns stats: {compiled: N, skipped: N, errors: N}
|
|
52
|
+
|
|
53
|
+
Concurrency fix (v3.8.4): each operation uses a scoped connection via
|
|
54
|
+
memory_read() / memory_write() so the process write lock is never held
|
|
55
|
+
across Ollama / network calls and no long raw connection is kept open.
|
|
53
56
|
"""
|
|
57
|
+
from superlocalmemory.storage.memory_write import memory_read
|
|
58
|
+
|
|
54
59
|
if self._config and not getattr(self._config, 'entity_compilation_enabled', True):
|
|
55
60
|
return {"compiled": 0, "skipped": 0, "errors": 0, "reason": "disabled"}
|
|
56
61
|
|
|
57
62
|
stats = {"compiled": 0, "skipped": 0, "errors": 0}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
# Get all distinct projects for this profile
|
|
63
|
+
|
|
64
|
+
with memory_read(self._db_path) as conn:
|
|
61
65
|
projects = conn.execute(
|
|
62
66
|
"SELECT DISTINCT project_name FROM entity_profiles WHERE profile_id = ?",
|
|
63
67
|
(profile_id,),
|
|
64
68
|
).fetchall()
|
|
65
69
|
project_names = [r[0] for r in projects] if projects else [""]
|
|
66
70
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
finally:
|
|
73
|
-
conn.close()
|
|
71
|
+
for project_name in project_names:
|
|
72
|
+
result = self._compile_project(profile_id, project_name)
|
|
73
|
+
stats["compiled"] += result["compiled"]
|
|
74
|
+
stats["skipped"] += result["skipped"]
|
|
75
|
+
stats["errors"] += result["errors"]
|
|
74
76
|
|
|
75
77
|
if stats["compiled"] > 0:
|
|
76
78
|
logger.info("Entity compilation: %d compiled, %d skipped, %d errors",
|
|
@@ -80,57 +82,52 @@ class EntityCompiler:
|
|
|
80
82
|
def compile_entity(self, profile_id: str, project_name: str,
|
|
81
83
|
entity_id: str, entity_name: str) -> dict | None:
|
|
82
84
|
"""Compile a single entity. Returns compiled truth or None."""
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
conn.execute("PRAGMA busy_timeout=5000")
|
|
94
|
-
conn.row_factory = sqlite3.Row
|
|
95
|
-
return conn
|
|
96
|
-
|
|
97
|
-
def _compile_project(self, conn: sqlite3.Connection, profile_id: str,
|
|
98
|
-
project_name: str) -> dict:
|
|
99
|
-
"""Compile all entities needing update in a project."""
|
|
85
|
+
return self._compile_single(profile_id, project_name, entity_id, entity_name)
|
|
86
|
+
|
|
87
|
+
def _compile_project(self, profile_id: str, project_name: str) -> dict:
|
|
88
|
+
"""Compile all entities needing update in a project.
|
|
89
|
+
|
|
90
|
+
Uses scoped memory_read() for the entity query so no connection is
|
|
91
|
+
held across entity compilation iterations.
|
|
92
|
+
"""
|
|
93
|
+
from superlocalmemory.storage.memory_write import memory_read
|
|
94
|
+
|
|
100
95
|
stats = {"compiled": 0, "skipped": 0, "errors": 0}
|
|
101
96
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
97
|
+
with memory_read(self._db_path) as conn:
|
|
98
|
+
entities = conn.execute("""
|
|
99
|
+
SELECT DISTINCT ce.entity_id, ce.canonical_name, ce.entity_type
|
|
100
|
+
FROM canonical_entities ce
|
|
101
|
+
WHERE ce.profile_id = ?
|
|
102
|
+
AND (
|
|
103
|
+
EXISTS (
|
|
104
|
+
SELECT 1 FROM atomic_facts af
|
|
105
|
+
WHERE af.canonical_entities_json LIKE '%' || ce.entity_id || '%'
|
|
106
|
+
AND af.profile_id = ?
|
|
107
|
+
AND af.created_at > COALESCE(
|
|
108
|
+
(SELECT last_compiled_at FROM entity_profiles
|
|
109
|
+
WHERE entity_id = ce.entity_id
|
|
110
|
+
AND profile_id = ?
|
|
111
|
+
AND project_name = ?),
|
|
112
|
+
'1970-01-01')
|
|
113
|
+
)
|
|
114
|
+
OR NOT EXISTS (
|
|
115
|
+
SELECT 1 FROM entity_profiles
|
|
116
|
+
WHERE entity_id = ce.entity_id
|
|
117
|
+
AND profile_id = ?
|
|
118
|
+
AND project_name = ?
|
|
119
|
+
AND last_compiled_at IS NOT NULL
|
|
120
|
+
)
|
|
125
121
|
)
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
122
|
+
""", (profile_id, profile_id, profile_id, project_name,
|
|
123
|
+
profile_id, project_name)).fetchall()
|
|
124
|
+
# Detach rows from the read connection before closing it
|
|
125
|
+
entities = [dict(e) for e in entities]
|
|
129
126
|
|
|
130
127
|
for entity in entities:
|
|
131
128
|
try:
|
|
132
129
|
result = self._compile_single(
|
|
133
|
-
|
|
130
|
+
profile_id, project_name,
|
|
134
131
|
entity["entity_id"], entity["canonical_name"],
|
|
135
132
|
entity_type=entity["entity_type"],
|
|
136
133
|
)
|
|
@@ -145,30 +142,20 @@ class EntityCompiler:
|
|
|
145
142
|
|
|
146
143
|
return stats
|
|
147
144
|
|
|
148
|
-
def _compile_single(self,
|
|
145
|
+
def _compile_single(self, profile_id: str,
|
|
149
146
|
project_name: str, entity_id: str, entity_name: str,
|
|
150
147
|
entity_type: str = "unknown") -> dict | None:
|
|
151
|
-
"""Compile one entity. Returns the compiled truth dict or None.
|
|
152
|
-
|
|
153
|
-
# Gather atomic facts for this entity
|
|
154
|
-
facts = conn.execute("""
|
|
155
|
-
SELECT af.fact_id, af.content, af.confidence, af.created_at,
|
|
156
|
-
fi.pagerank_score, fi.community_id
|
|
157
|
-
FROM atomic_facts af
|
|
158
|
-
LEFT JOIN fact_importance fi ON af.fact_id = fi.fact_id
|
|
159
|
-
WHERE af.canonical_entities_json LIKE ? AND af.profile_id = ?
|
|
160
|
-
ORDER BY fi.pagerank_score DESC NULLS LAST, af.confidence DESC
|
|
161
|
-
LIMIT 50
|
|
162
|
-
""", (f"%{entity_id}%", profile_id)).fetchall()
|
|
148
|
+
"""Compile one entity. Returns the compiled truth dict or None.
|
|
163
149
|
|
|
164
|
-
|
|
165
|
-
|
|
150
|
+
Concurrency fix (v3.8.4): reads use memory_read(), writes use
|
|
151
|
+
memory_write(). The Ollama call (Mode B) happens with NO lock held.
|
|
152
|
+
Lock-ordering invariant preserved: get_write_lock (outermost) →
|
|
153
|
+
sqlite BEGIN ... COMMIT.
|
|
154
|
+
"""
|
|
155
|
+
from superlocalmemory.storage.memory_write import memory_read, memory_write
|
|
166
156
|
|
|
167
|
-
#
|
|
168
|
-
|
|
169
|
-
if not has_pagerank and len(facts) > 2:
|
|
170
|
-
self._compute_pagerank(conn, [f["fact_id"] for f in facts], profile_id)
|
|
171
|
-
# Re-fetch with scores
|
|
157
|
+
# ── Phase 1: read facts (no write lock) ──────────────────────────────
|
|
158
|
+
with memory_read(self._db_path) as conn:
|
|
172
159
|
facts = conn.execute("""
|
|
173
160
|
SELECT af.fact_id, af.content, af.confidence, af.created_at,
|
|
174
161
|
fi.pagerank_score, fi.community_id
|
|
@@ -178,8 +165,31 @@ class EntityCompiler:
|
|
|
178
165
|
ORDER BY fi.pagerank_score DESC NULLS LAST, af.confidence DESC
|
|
179
166
|
LIMIT 50
|
|
180
167
|
""", (f"%{entity_id}%", profile_id)).fetchall()
|
|
168
|
+
facts = [dict(f) for f in facts]
|
|
181
169
|
|
|
182
|
-
|
|
170
|
+
if not facts:
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
has_pagerank = any(f["pagerank_score"] is not None for f in facts)
|
|
174
|
+
|
|
175
|
+
# ── Phase 2: PageRank (short write, NO Ollama) ────────────────────────
|
|
176
|
+
if not has_pagerank and len(facts) > 2:
|
|
177
|
+
self._compute_pagerank([f["fact_id"] for f in facts], profile_id)
|
|
178
|
+
# Re-fetch with updated scores
|
|
179
|
+
with memory_read(self._db_path) as conn:
|
|
180
|
+
facts = conn.execute("""
|
|
181
|
+
SELECT af.fact_id, af.content, af.confidence, af.created_at,
|
|
182
|
+
fi.pagerank_score, fi.community_id
|
|
183
|
+
FROM atomic_facts af
|
|
184
|
+
LEFT JOIN fact_importance fi ON af.fact_id = fi.fact_id
|
|
185
|
+
WHERE af.canonical_entities_json LIKE ? AND af.profile_id = ?
|
|
186
|
+
ORDER BY fi.pagerank_score DESC NULLS LAST, af.confidence DESC
|
|
187
|
+
LIMIT 50
|
|
188
|
+
""", (f"%{entity_id}%", profile_id)).fetchall()
|
|
189
|
+
facts = [dict(f) for f in facts]
|
|
190
|
+
|
|
191
|
+
# ── Phase 3: generate compiled truth — NO write lock held ────────────
|
|
192
|
+
# Mode B calls Ollama (up to 30 s) — write lock MUST NOT be held here.
|
|
183
193
|
if self._mode in ("b", "c") and len(facts) > 3:
|
|
184
194
|
compiled = self._compile_mode_b(entity_name, facts)
|
|
185
195
|
if not compiled:
|
|
@@ -187,10 +197,8 @@ class EntityCompiler:
|
|
|
187
197
|
else:
|
|
188
198
|
compiled = self._compile_mode_a(entity_name, entity_type, facts)
|
|
189
199
|
|
|
190
|
-
# Truncate to limit
|
|
191
200
|
compiled = self._truncate(compiled, _MAX_COMPILED_TRUTH_CHARS)
|
|
192
201
|
|
|
193
|
-
# Build timeline entry
|
|
194
202
|
now = datetime.now(timezone.utc).isoformat()
|
|
195
203
|
timeline_entry = {
|
|
196
204
|
"date": now,
|
|
@@ -199,50 +207,50 @@ class EntityCompiler:
|
|
|
199
207
|
"mode": self._mode,
|
|
200
208
|
}
|
|
201
209
|
|
|
202
|
-
# Load existing timeline
|
|
203
|
-
existing = conn.execute(
|
|
204
|
-
"SELECT timeline, profile_entry_id FROM entity_profiles "
|
|
205
|
-
"WHERE entity_id = ? AND profile_id = ? AND project_name = ?",
|
|
206
|
-
(entity_id, profile_id, project_name),
|
|
207
|
-
).fetchone()
|
|
208
|
-
|
|
209
|
-
timeline = []
|
|
210
|
-
if existing and existing["timeline"]:
|
|
211
|
-
try:
|
|
212
|
-
timeline = json.loads(existing["timeline"])
|
|
213
|
-
except (json.JSONDecodeError, TypeError):
|
|
214
|
-
timeline = []
|
|
215
|
-
timeline.append(timeline_entry)
|
|
216
|
-
# Cap at 100 entries
|
|
217
|
-
if len(timeline) > _MAX_TIMELINE_ENTRIES:
|
|
218
|
-
timeline = timeline[-_MAX_TIMELINE_ENTRIES:]
|
|
219
|
-
|
|
220
210
|
fact_ids = [f["fact_id"] for f in facts]
|
|
221
211
|
avg_conf = sum(f["confidence"] or 0.5 for f in facts) / max(len(facts), 1)
|
|
222
212
|
|
|
223
|
-
#
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
WHERE entity_id = ? AND profile_id = ? AND project_name = ?
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
213
|
+
# ── Phase 4: short write — hold write lock for INSERT/UPDATE only ────
|
|
214
|
+
with memory_write(self._db_path) as conn:
|
|
215
|
+
# Re-read inside the write lock for correct timeline merge and to
|
|
216
|
+
# decide INSERT vs UPDATE atomically (prevents lost-update race).
|
|
217
|
+
existing = conn.execute(
|
|
218
|
+
"SELECT timeline, profile_entry_id FROM entity_profiles "
|
|
219
|
+
"WHERE entity_id = ? AND profile_id = ? AND project_name = ?",
|
|
220
|
+
(entity_id, profile_id, project_name),
|
|
221
|
+
).fetchone()
|
|
222
|
+
|
|
223
|
+
timeline: list = []
|
|
224
|
+
if existing and existing["timeline"]:
|
|
225
|
+
try:
|
|
226
|
+
timeline = json.loads(existing["timeline"])
|
|
227
|
+
except (json.JSONDecodeError, TypeError):
|
|
228
|
+
timeline = []
|
|
229
|
+
timeline.append(timeline_entry)
|
|
230
|
+
if len(timeline) > _MAX_TIMELINE_ENTRIES:
|
|
231
|
+
timeline = timeline[-_MAX_TIMELINE_ENTRIES:]
|
|
232
|
+
|
|
233
|
+
if existing:
|
|
234
|
+
conn.execute("""
|
|
235
|
+
UPDATE entity_profiles SET
|
|
236
|
+
compiled_truth = ?, timeline = ?, fact_ids_json = ?,
|
|
237
|
+
last_compiled_at = ?, compilation_confidence = ?,
|
|
238
|
+
last_updated = ?
|
|
239
|
+
WHERE entity_id = ? AND profile_id = ? AND project_name = ?
|
|
240
|
+
""", (compiled, json.dumps(timeline), json.dumps(fact_ids),
|
|
241
|
+
now, round(avg_conf, 3), now,
|
|
242
|
+
entity_id, profile_id, project_name))
|
|
243
|
+
else:
|
|
244
|
+
entry_id = str(uuid.uuid4())[:16]
|
|
245
|
+
conn.execute("""
|
|
246
|
+
INSERT INTO entity_profiles
|
|
247
|
+
(profile_entry_id, entity_id, profile_id, project_name,
|
|
248
|
+
knowledge_summary, compiled_truth, timeline, fact_ids_json,
|
|
249
|
+
last_compiled_at, compilation_confidence, last_updated)
|
|
250
|
+
VALUES (?, ?, ?, ?, '', ?, ?, ?, ?, ?, ?)
|
|
251
|
+
""", (entry_id, entity_id, profile_id, project_name,
|
|
252
|
+
compiled, json.dumps(timeline), json.dumps(fact_ids),
|
|
253
|
+
now, round(avg_conf, 3), now))
|
|
246
254
|
|
|
247
255
|
return {
|
|
248
256
|
"entity_name": entity_name,
|
|
@@ -332,34 +340,42 @@ class EntityCompiler:
|
|
|
332
340
|
|
|
333
341
|
# -- Helpers --
|
|
334
342
|
|
|
335
|
-
def _compute_pagerank(self,
|
|
336
|
-
|
|
337
|
-
|
|
343
|
+
def _compute_pagerank(self, fact_ids: list[str], profile_id: str) -> None:
|
|
344
|
+
"""Compute PageRank for a set of facts and store in fact_importance.
|
|
345
|
+
|
|
346
|
+
Concurrency fix (v3.8.4): uses memory_write() for the INSERT so the
|
|
347
|
+
process write lock is acquired and busy_timeout is set correctly.
|
|
348
|
+
Pure PageRank computation (networkx) happens BEFORE the write lock.
|
|
349
|
+
"""
|
|
350
|
+
from superlocalmemory.storage.memory_write import memory_write
|
|
351
|
+
|
|
338
352
|
try:
|
|
339
353
|
import networkx as nx
|
|
340
354
|
G = nx.Graph()
|
|
341
355
|
for fid in fact_ids:
|
|
342
356
|
G.add_node(fid)
|
|
343
|
-
# Add edges based on shared entities
|
|
344
357
|
for i, fid1 in enumerate(fact_ids):
|
|
345
358
|
for fid2 in fact_ids[i + 1:]:
|
|
346
|
-
# Simple heuristic: facts about same entity are connected
|
|
347
359
|
G.add_edge(fid1, fid2, weight=0.5)
|
|
348
360
|
|
|
349
361
|
if len(G.nodes) < 2:
|
|
350
362
|
return
|
|
351
363
|
|
|
364
|
+
# Compute scores in pure Python — no lock held yet.
|
|
352
365
|
scores = nx.pagerank(G, alpha=0.85)
|
|
353
366
|
now = datetime.now(timezone.utc).isoformat()
|
|
354
367
|
|
|
355
|
-
for
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
368
|
+
# Short write: lock held only for the INSERT batch.
|
|
369
|
+
with memory_write(self._db_path) as conn:
|
|
370
|
+
for fid, score in scores.items():
|
|
371
|
+
conn.execute("""
|
|
372
|
+
INSERT INTO fact_importance
|
|
373
|
+
(fact_id, profile_id, pagerank_score, computed_at)
|
|
374
|
+
VALUES (?, ?, ?, ?)
|
|
375
|
+
ON CONFLICT(fact_id) DO UPDATE
|
|
376
|
+
SET pagerank_score = excluded.pagerank_score,
|
|
377
|
+
computed_at = excluded.computed_at
|
|
378
|
+
""", (fid, profile_id, round(score, 6), now))
|
|
363
379
|
except ImportError:
|
|
364
380
|
logger.debug("NetworkX not available — skipping PageRank")
|
|
365
381
|
except Exception as exc:
|
|
@@ -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")
|