superlocalmemory 3.8.2 → 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.
Files changed (94) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/README.md +3 -2
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/CLAUDE.md +3 -3
  6. package/plugin/agents/slm-governance-advisor.md +1 -1
  7. package/plugin/agents/slm-loop-runner.md +1 -1
  8. package/plugin/agents/slm-memory-advisor.md +1 -1
  9. package/plugin/agents/slm-optimize-advisor.md +1 -1
  10. package/plugin/requirements.txt +1 -1
  11. package/plugin/skills/slm-cache/SKILL.md +1 -1
  12. package/plugin/skills/slm-compress/SKILL.md +1 -1
  13. package/plugin/skills/slm-governance/SKILL.md +1 -1
  14. package/plugin/skills/slm-graph/SKILL.md +1 -1
  15. package/plugin/skills/slm-loop/SKILL.md +1 -1
  16. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  17. package/plugin/skills/slm-profile/SKILL.md +1 -1
  18. package/plugin/skills/slm-recall/SKILL.md +1 -1
  19. package/plugin/skills/slm-remember/SKILL.md +1 -1
  20. package/plugin/skills/slm-scope/SKILL.md +1 -1
  21. package/plugin/skills/slm-session/SKILL.md +1 -1
  22. package/plugin/skills/slm-status/SKILL.md +1 -1
  23. package/plugin-src/rules/AGENTS.md +1 -1
  24. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  25. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  26. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  29. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  31. package/pyproject.toml +1 -1
  32. package/src/superlocalmemory/__init__.py +1 -1
  33. package/src/superlocalmemory/access/rbac.py +68 -76
  34. package/src/superlocalmemory/cli/commands.py +19 -0
  35. package/src/superlocalmemory/cli/ingest_cmd.py +11 -1
  36. package/src/superlocalmemory/cli/main.py +30 -0
  37. package/src/superlocalmemory/cli/pending_store.py +39 -14
  38. package/src/superlocalmemory/core/backend_orchestrator.py +93 -0
  39. package/src/superlocalmemory/core/config.py +78 -0
  40. package/src/superlocalmemory/core/consolidation_engine.py +79 -73
  41. package/src/superlocalmemory/core/engine.py +92 -11
  42. package/src/superlocalmemory/core/fact_consolidator.py +148 -30
  43. package/src/superlocalmemory/core/graph_pruner.py +436 -39
  44. package/src/superlocalmemory/core/ingestion_command.py +160 -31
  45. package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
  46. package/src/superlocalmemory/core/recall_pipeline.py +3 -0
  47. package/src/superlocalmemory/core/registry.py +5 -1
  48. package/src/superlocalmemory/core/remote_mode.py +3 -1
  49. package/src/superlocalmemory/core/scale_engine.py +41 -18
  50. package/src/superlocalmemory/core/store_pipeline.py +18 -4
  51. package/src/superlocalmemory/encoding/entity_resolver.py +18 -11
  52. package/src/superlocalmemory/hooks/_outcome_common.py +9 -2
  53. package/src/superlocalmemory/hooks/adapter_base.py +58 -44
  54. package/src/superlocalmemory/hooks/ide_connector.py +26 -8
  55. package/src/superlocalmemory/hooks/portable_kit.py +105 -9
  56. package/src/superlocalmemory/hooks/prewarm_auth.py +21 -2
  57. package/src/superlocalmemory/infra/auth_middleware.py +3 -1
  58. package/src/superlocalmemory/infra/cloud_backup.py +26 -27
  59. package/src/superlocalmemory/infra/event_bus.py +250 -88
  60. package/src/superlocalmemory/learning/consolidation_cycle.py +33 -16
  61. package/src/superlocalmemory/learning/entity_compiler.py +148 -132
  62. package/src/superlocalmemory/learning/memory_merge.py +97 -82
  63. package/src/superlocalmemory/learning/reward_archive.py +98 -90
  64. package/src/superlocalmemory/learning/reward_boost.py +40 -30
  65. package/src/superlocalmemory/mcp/http_transport.py +335 -3
  66. package/src/superlocalmemory/retrieval/engine.py +7 -1
  67. package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
  68. package/src/superlocalmemory/retrieval/reranker.py +98 -15
  69. package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
  70. package/src/superlocalmemory/retrieval/vector_store.py +84 -69
  71. package/src/superlocalmemory/server/loopback.py +91 -0
  72. package/src/superlocalmemory/server/origin.py +9 -4
  73. package/src/superlocalmemory/server/routes/backup.py +6 -2
  74. package/src/superlocalmemory/server/routes/behavioral.py +6 -12
  75. package/src/superlocalmemory/server/routes/compliance.py +20 -23
  76. package/src/superlocalmemory/server/routes/config_api.py +83 -0
  77. package/src/superlocalmemory/server/routes/helpers.py +24 -13
  78. package/src/superlocalmemory/server/routes/memories.py +139 -91
  79. package/src/superlocalmemory/server/routes/mesh.py +7 -2
  80. package/src/superlocalmemory/server/routes/profiles.py +20 -21
  81. package/src/superlocalmemory/server/routes/rbac.py +0 -1
  82. package/src/superlocalmemory/server/routes/tiers.py +42 -30
  83. package/src/superlocalmemory/server/routes/v3_api.py +67 -77
  84. package/src/superlocalmemory/server/unified_daemon.py +283 -39
  85. package/src/superlocalmemory/server/write_identity.py +22 -4
  86. package/src/superlocalmemory/storage/database.py +109 -19
  87. package/src/superlocalmemory/storage/deferred_writes.py +153 -0
  88. package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
  89. package/src/superlocalmemory/storage/memory_write.py +119 -0
  90. package/src/superlocalmemory/storage/migration_runner.py +7 -0
  91. package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +113 -78
  92. package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +80 -0
  93. package/src/superlocalmemory/storage/write_lock.py +88 -0
  94. package/src/superlocalmemory/ui/js/core.js +6 -1
@@ -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 = conn.execute(
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
- conn, profile_id, fid,
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
- if not to_archive:
130
- return []
131
-
132
- conn.execute("BEGIN IMMEDIATE")
133
- # S9-SKEP-07: re-verify reward under RESERVED lock. Between the
134
- # read-only reward scan above and this BEGIN IMMEDIATE another
135
- # writer may have inserted a positive reward row ("user liked
136
- # the memory we are about to archive"). With the writer lock
137
- # held we now know no further inserts are landing; one last
138
- # check per entry catches everything that raced in.
139
- verified: list[dict] = []
140
- for entry in to_archive:
141
- if has_recent_positive_reward(
142
- conn, profile_id, entry["fid"],
143
- min_reward=ARCHIVE_REWARD_THRESHOLD,
144
- window_days=REWARD_WINDOW_DAYS,
145
- ):
146
- continue
147
- verified.append(entry)
148
- to_archive = verified
149
- if not to_archive:
150
- conn.execute("COMMIT")
151
- return []
152
- for entry in to_archive:
153
- fid = entry["fid"]
154
- payload = {
155
- "fact_id": fid,
156
- "content": entry["content"],
157
- "canonical_entities_json": entry["canonical_entities_json"],
158
- "importance": entry["importance"],
159
- "confidence": entry["confidence"],
160
- "embedding": entry["embedding"],
161
- "created_at": entry["created_at"],
162
- }
163
- # SEC-L3 — cap payload_json at 256 KB. Oversize blobs are
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
- "truncated": True,
173
- "original_bytes": len(payload_str.encode("utf-8")),
174
- })
175
- reason = "reward_gated_ebbinghaus_truncated"
176
- logger.warning(
177
- "memory_archive payload >%d bytes for fact_id=%s; "
178
- "truncated to stub", PAYLOAD_JSON_MAX_BYTES, fid,
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
- conn.execute(
181
- "INSERT INTO memory_archive "
182
- "(archive_id, fact_id, profile_id, payload_json, "
183
- " archived_at, reason) VALUES (?, ?, ?, ?, ?, ?)",
184
- (
185
- str(uuid.uuid4()),
186
- fid,
187
- profile_id,
188
- payload_str,
189
- _iso_now(),
190
- reason,
191
- ),
192
- )
193
- conn.execute(
194
- "UPDATE atomic_facts "
195
- "SET archive_status='archived', "
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
- conn = sqlite3.connect(str(memory_db_path), timeout=10.0)
127
- conn.execute("PRAGMA busy_timeout=2000")
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 = conn.execute(
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
- # Fallback to per-fact helper preserves legacy behaviour on
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
- conn.close()
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