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
|
@@ -32,6 +32,8 @@ import sqlite3
|
|
|
32
32
|
from datetime import datetime, timezone
|
|
33
33
|
from pathlib import Path
|
|
34
34
|
|
|
35
|
+
from superlocalmemory.storage.write_lock import get_write_lock
|
|
36
|
+
|
|
35
37
|
logger = logging.getLogger(__name__)
|
|
36
38
|
|
|
37
39
|
__all__ = ("ConsolidationWorker",)
|
|
@@ -100,6 +102,10 @@ class ConsolidationWorker:
|
|
|
100
102
|
conn_ga.execute("PRAGMA busy_timeout=5000")
|
|
101
103
|
conn_ga.row_factory = sqlite3.Row
|
|
102
104
|
|
|
105
|
+
# Wrap _DBProxy.execute writes in the process-level write lock.
|
|
106
|
+
# Reads pass through without the lock (WAL-safe).
|
|
107
|
+
_ga_write_lock = get_write_lock(self._memory_db)
|
|
108
|
+
|
|
103
109
|
class _DBProxy:
|
|
104
110
|
"""Minimal DB proxy for GraphAnalyzer compatibility."""
|
|
105
111
|
|
|
@@ -107,13 +113,14 @@ class ConsolidationWorker:
|
|
|
107
113
|
self._conn = connection
|
|
108
114
|
|
|
109
115
|
def execute(self, sql: str, params: tuple = ()) -> list:
|
|
110
|
-
cursor = self._conn.execute(sql, params)
|
|
111
116
|
if sql.strip().upper().startswith(
|
|
112
117
|
("INSERT", "UPDATE", "DELETE", "ALTER", "CREATE"),
|
|
113
118
|
):
|
|
114
|
-
|
|
119
|
+
with _ga_write_lock:
|
|
120
|
+
cursor = self._conn.execute(sql, params)
|
|
121
|
+
self._conn.commit()
|
|
115
122
|
return []
|
|
116
|
-
return
|
|
123
|
+
return self._conn.execute(sql, params).fetchall()
|
|
117
124
|
|
|
118
125
|
ga = GraphAnalyzer(_DBProxy(conn_ga))
|
|
119
126
|
if not dry_run:
|
|
@@ -238,15 +245,17 @@ class ConsolidationWorker:
|
|
|
238
245
|
os.environ.get("SLM_LEGACY_DEDUP_SCAN_CAP", "100000")
|
|
239
246
|
)
|
|
240
247
|
try:
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
248
|
+
# READ phase — no write lock needed (WAL allows concurrent reads).
|
|
249
|
+
conn_r = sqlite3.connect(self._memory_db, timeout=10)
|
|
250
|
+
conn_r.execute("PRAGMA busy_timeout=5000")
|
|
251
|
+
conn_r.row_factory = sqlite3.Row
|
|
244
252
|
|
|
245
|
-
rows =
|
|
253
|
+
rows = conn_r.execute(
|
|
246
254
|
"SELECT fact_id, content FROM atomic_facts "
|
|
247
255
|
"WHERE profile_id = ? ORDER BY created_at LIMIT ?",
|
|
248
256
|
(profile_id, _LEGACY_DEDUP_SCAN_CAP),
|
|
249
257
|
).fetchall()
|
|
258
|
+
conn_r.close()
|
|
250
259
|
|
|
251
260
|
seen_prefixes: dict[str, str] = {}
|
|
252
261
|
duplicates = []
|
|
@@ -260,16 +269,24 @@ class ConsolidationWorker:
|
|
|
260
269
|
seen_prefixes[prefix] = d["fact_id"]
|
|
261
270
|
|
|
262
271
|
if duplicates and not dry_run:
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
272
|
+
# WRITE phase — acquire the process-level write lock before
|
|
273
|
+
# opening the sqlite3 connection, so this UPDATE is serialised
|
|
274
|
+
# with all other in-process writers (DatabaseManager,
|
|
275
|
+
# VectorStore, adapter sync) and cannot cause SQLITE_BUSY.
|
|
276
|
+
with get_write_lock(self._memory_db):
|
|
277
|
+
conn_w = sqlite3.connect(self._memory_db, timeout=10)
|
|
278
|
+
try:
|
|
279
|
+
for fid in duplicates:
|
|
280
|
+
conn_w.execute(
|
|
281
|
+
"UPDATE atomic_facts "
|
|
282
|
+
"SET confidence = MAX(0.1, confidence * 0.5) "
|
|
283
|
+
"WHERE fact_id = ?",
|
|
284
|
+
(fid,),
|
|
285
|
+
)
|
|
286
|
+
conn_w.commit()
|
|
287
|
+
finally:
|
|
288
|
+
conn_w.close()
|
|
271
289
|
|
|
272
|
-
conn.close()
|
|
273
290
|
return len(duplicates)
|
|
274
291
|
except Exception:
|
|
275
292
|
return 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:
|