superlocalmemory 3.6.3 → 3.6.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 CHANGED
@@ -5,6 +5,27 @@ All notable changes to SuperLocalMemory V3 will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.6.4] - 2026-06-09 — Memory-integrity & reliability hardening
9
+
10
+ ### Fixed
11
+
12
+ - **`remember` write-path integrity:** fact storage is now idempotent across all memory
13
+ lifecycle states and every write path — storing the same fact twice is one fact. A transient
14
+ backend error during extraction or consolidation can no longer leave a memory without a
15
+ retrievable fact (graceful fallback).
16
+ - **Graph & vector consistency:** edges and embeddings stay correct as memories age,
17
+ consolidate, and are archived (no stale or orphaned graph/vector entries influencing recall).
18
+ - **MCP stdio stability:** resolved a connection-lifecycle edge case that could prematurely
19
+ end a session with strict MCP hosts.
20
+
21
+ ### Performance
22
+
23
+ - Faster recall on large knowledge bases; tighter memory bounds in hot paths.
24
+
25
+ ### Internal
26
+
27
+ - Expanded automated test coverage for write-path and graph integrity.
28
+
8
29
  ## [3.6.3] - 2026-06-08 — Cache + compression now work for Claude Code, Claude Desktop, Codex CLI
9
30
 
10
31
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superlocalmemory",
3
- "version": "3.6.3",
3
+ "version": "3.6.5",
4
4
  "description": "Information-geometric agent memory with mathematical guarantees. 4-channel retrieval, Fisher-Rao similarity, zero-LLM mode, EU AI Act compliant. Works with Claude, Cursor, Windsurf, and 17+ AI tools.",
5
5
  "keywords": [
6
6
  "ai-memory",
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "superlocalmemory"
3
- version = "3.6.3"
3
+ version = "3.6.5"
4
4
  description = "Information-geometric agent memory with mathematical guarantees"
5
5
  readme = "README.md"
6
6
  license = {text = "AGPL-3.0-or-later"}
@@ -28,7 +28,7 @@ if "OMP_NUM_THREADS" not in os.environ:
28
28
  os.environ["OMP_NUM_THREADS"] = "2"
29
29
  # ---------------------------------------------------------------------------
30
30
 
31
- __version__ = "3.6.3"
31
+ __version__ = "3.6.5"
32
32
 
33
33
  _REQUIRED_VERSIONS = {
34
34
  "sentence_transformers": "5.3.0",
@@ -36,22 +36,35 @@ _REQUIRED_VERSIONS = {
36
36
  }
37
37
 
38
38
 
39
+ # Module name -> distribution name for metadata lookup.
40
+ _DIST_NAMES = {
41
+ "sentence_transformers": "sentence-transformers",
42
+ "onnxruntime": "onnxruntime",
43
+ }
44
+
45
+
39
46
  def _check_critical_deps() -> None:
40
- """Warn if embedding-critical packages have wrong versions."""
47
+ """Warn if embedding-critical packages have wrong versions.
48
+
49
+ Reads installed versions from package metadata — does NOT import the
50
+ packages. Importing sentence_transformers here would eagerly load torch
51
+ (native) into every process: a memory blow-up on Apple Silicon and, on
52
+ some interpreters, a source of native-heap instability at teardown.
53
+ """
41
54
  import warnings
55
+ from importlib import metadata
42
56
  for mod_name, expected in _REQUIRED_VERSIONS.items():
43
57
  try:
44
- mod = __import__(mod_name)
45
- actual = getattr(mod, "__version__", None)
46
- if actual and actual != expected:
47
- warnings.warn(
48
- f"SuperLocalMemory requires {mod_name}=={expected} but "
49
- f"{actual} is installed. This causes memory blow-up on "
50
- f"Apple Silicon. Fix: pip install {mod_name}=={expected}",
51
- stacklevel=2,
52
- )
53
- except ImportError:
54
- pass
58
+ actual = metadata.version(_DIST_NAMES[mod_name])
59
+ except metadata.PackageNotFoundError:
60
+ continue
61
+ if actual != expected:
62
+ warnings.warn(
63
+ f"SuperLocalMemory requires {mod_name}=={expected} but "
64
+ f"{actual} is installed. This causes memory blow-up on "
65
+ f"Apple Silicon. Fix: pip install {mod_name}=={expected}",
66
+ stacklevel=2,
67
+ )
55
68
 
56
69
 
57
70
  # Only run the dep check when a full (non-LIGHT) engine is in use.
@@ -745,6 +745,14 @@ class ConsolidationEngine:
745
745
  """
746
746
  from superlocalmemory.storage.models import _new_id
747
747
 
748
+ # NOTE (core-promotion-02, intentionally NOT changed): the audit
749
+ # flagged placeholder core blocks ("No data available.") + version
750
+ # churn. Investigation showed BOTH the "always 5 blocks" layout and the
751
+ # monotonic version-on-recompile counter are intentional, tested design
752
+ # (see test_consolidation_engine), and the read-side injection filter
753
+ # already hides placeholders from agents. Suppressing or de-churning
754
+ # here breaks that design for a cosmetic gain — so it is left as-is.
755
+
748
756
  # Get existing version for increment
749
757
  existing = self._db.get_core_block(profile_id, block_type)
750
758
  version = (existing["version"] + 1) if existing else 1
@@ -281,13 +281,18 @@ class MemoryEngine:
281
281
  # V3.2: ConsolidationEngine (Phase 5) -- sleep-time consolidation
282
282
  from superlocalmemory.core.summarizer import Summarizer
283
283
  summarizer = Summarizer(self._config)
284
+ # P1-5 (core-promotion-01): wire a real behavioral store. Previously
285
+ # hardcoded None → _compile_behavioral_block always returned the
286
+ # "No behavioral patterns detected yet." placeholder, so behavioral
287
+ # patterns never reached the always-injected core block (dead feature).
288
+ from superlocalmemory.core.recall_pipeline import _get_behavioral_tracker
284
289
  self._consolidation_engine = _init_consolidation(
285
290
  self._config, self._db,
286
291
  auto_linker=self._auto_linker,
287
292
  graph_analyzer=self._graph_analyzer,
288
293
  temporal_validator=self._temporal_validator,
289
294
  summarizer=summarizer,
290
- behavioral_store=None,
295
+ behavioral_store=_get_behavioral_tracker(self._db),
291
296
  embedder=self._embedder, # v3.4.7: for CCQ worker
292
297
  llm=getattr(self, "_llm", None), # v3.4.7: for CCQ worker
293
298
  )
@@ -226,19 +226,41 @@ def _consolidate_cluster(
226
226
  except (json.JSONDecodeError, TypeError):
227
227
  pass
228
228
 
229
- c.execute("""
230
- INSERT INTO atomic_facts
231
- (fact_id, memory_id, profile_id, content, fact_type,
232
- entities_json, canonical_entities_json,
233
- confidence, importance, evidence_count, access_count,
234
- created_at, lifecycle)
235
- VALUES (?, '', ?, ?, 'semantic', ?, ?, ?, 0.8, ?, 0, ?, 'active')
236
- """, (
237
- new_fact_id, profile_id, summary,
238
- json.dumps(list(all_entities)),
239
- json.dumps(list(all_entities)),
240
- round(avg_confidence, 3), len(facts), now,
241
- ))
229
+ # P0-3 (dedup-complete-01): apply the SAME content-idempotency invariant
230
+ # as storage.database.store_fact — but on THIS cursor so it stays inside
231
+ # the cluster SAVEPOINT. Previously this raw INSERT bypassed dedup, so a
232
+ # consolidated summary identical to an existing live fact created a
233
+ # duplicate row and never reinforced evidence. Now: reinforce-or-insert.
234
+ # (Excludes 'archived' = soft-deleted, mirroring store_fact.)
235
+ _existing = c.execute(
236
+ "SELECT fact_id FROM atomic_facts "
237
+ "WHERE profile_id = ? AND content = ? "
238
+ "AND lifecycle IN ('active', 'warm', 'cold') "
239
+ "ORDER BY created_at LIMIT 1",
240
+ (profile_id, summary),
241
+ ).fetchone()
242
+ if _existing:
243
+ new_fact_id = _existing["fact_id"]
244
+ c.execute(
245
+ "UPDATE atomic_facts "
246
+ "SET evidence_count = evidence_count + ?, access_count = access_count + 1 "
247
+ "WHERE fact_id = ?",
248
+ (len(facts), new_fact_id),
249
+ )
250
+ else:
251
+ c.execute("""
252
+ INSERT INTO atomic_facts
253
+ (fact_id, memory_id, profile_id, content, fact_type,
254
+ entities_json, canonical_entities_json,
255
+ confidence, importance, evidence_count, access_count,
256
+ created_at, lifecycle)
257
+ VALUES (?, '', ?, ?, 'semantic', ?, ?, ?, 0.8, ?, 0, ?, 'active')
258
+ """, (
259
+ new_fact_id, profile_id, summary,
260
+ json.dumps(list(all_entities)),
261
+ json.dumps(list(all_entities)),
262
+ round(avg_confidence, 3), len(facts), now,
263
+ ))
242
264
 
243
265
  # Record the consolidation
244
266
  consolidation_id = uuid.uuid4().hex[:16]
@@ -257,6 +279,25 @@ def _consolidate_cluster(
257
279
  (*fact_ids, profile_id),
258
280
  )
259
281
 
282
+ # P1-4 (graph-integrity-01): archived facts must stop influencing
283
+ # graph-based ranking. The association_edges FK is ON DELETE CASCADE
284
+ # only (no ON UPDATE), so archiving via UPDATE leaves orphaned edges
285
+ # that spreading_activation still reads. Remove edges touching the
286
+ # archived facts, and set their retention zone so ForgettingFilter
287
+ # excludes them. Inside the SAVEPOINT for atomicity.
288
+ c.execute(
289
+ f"DELETE FROM association_edges "
290
+ f"WHERE profile_id = ? "
291
+ f"AND (source_fact_id IN ({placeholders}) "
292
+ f" OR target_fact_id IN ({placeholders}))",
293
+ (profile_id, *fact_ids, *fact_ids),
294
+ )
295
+ c.execute(
296
+ f"UPDATE fact_retention SET lifecycle_zone = 'archive' "
297
+ f"WHERE profile_id = ? AND fact_id IN ({placeholders})",
298
+ (profile_id, *fact_ids),
299
+ )
300
+
260
301
  c.execute(f"RELEASE SAVEPOINT {savepoint_name}")
261
302
 
262
303
  except Exception:
@@ -56,6 +56,7 @@ def prune_graph(
56
56
  "self_loops_removed": 0,
57
57
  "duplicates_removed": 0,
58
58
  "hub_edges_removed": 0,
59
+ "association_orphans_removed": 0, # gi-04
59
60
  "total_before": 0,
60
61
  "total_after": 0,
61
62
  }
@@ -83,6 +84,9 @@ def prune_graph(
83
84
  stats["hub_edges_removed"] = _cap_node_degree(
84
85
  c, profile_id, _MAX_DEGREE_PER_NODE, dry_run,
85
86
  )
87
+ stats["association_orphans_removed"] = _remove_orphan_association_edges(
88
+ c, profile_id, dry_run,
89
+ )
86
90
 
87
91
  if dry_run:
88
92
  c.execute("ROLLBACK")
@@ -122,6 +126,34 @@ def prune_graph(
122
126
  return stats
123
127
 
124
128
 
129
+ def _remove_orphan_association_edges(
130
+ c: sqlite3.Cursor,
131
+ profile_id: str,
132
+ dry_run: bool,
133
+ ) -> int:
134
+ """gi-04: remove association_edges whose source/target fact no longer
135
+ exists in atomic_facts.
136
+
137
+ prune_graph historically only touched graph_edges despite its docstring
138
+ claiming "all graph pruning". Hard-deleted facts leave orphaned
139
+ association_edges (the FK cascade only fires under FK-on connections),
140
+ which spreading_activation then has to scan. Returns rows removed.
141
+ """
142
+ where = (
143
+ "profile_id = ? AND ("
144
+ "source_fact_id NOT IN (SELECT fact_id FROM atomic_facts WHERE profile_id = ?) "
145
+ "OR target_fact_id NOT IN (SELECT fact_id FROM atomic_facts WHERE profile_id = ?))"
146
+ )
147
+ c.execute(f"SELECT COUNT(*) AS cnt FROM association_edges WHERE {where}",
148
+ (profile_id, profile_id, profile_id))
149
+ n = c.fetchone()["cnt"]
150
+ if dry_run or not n:
151
+ return n
152
+ c.execute(f"DELETE FROM association_edges WHERE {where}",
153
+ (profile_id, profile_id, profile_id))
154
+ return c.rowcount
155
+
156
+
125
157
  def _remove_orphan_edges(
126
158
  c: sqlite3.Cursor,
127
159
  profile_id: str,
@@ -321,60 +353,64 @@ def _cap_node_degree(
321
353
  2. Edges with rn > max_degree are deleted in a single DELETE statement.
322
354
  Requires SQLite 3.25+ (window functions). System is on 3.53.1.
323
355
  """
356
+ # gi-03: cap BOTH out-degree (PARTITION BY source_id) AND in-degree
357
+ # (PARTITION BY target_id). Previously only out-degree was capped, so hub
358
+ # nodes accumulated unbounded in-degree (observed up to 1457), inflating
359
+ # entity-channel fan-in cost. An edge is removed if it exceeds max_degree
360
+ # in EITHER direction (low-weight to both its endpoints). Computed in one
361
+ # window-function pass, no Python loops.
324
362
  if dry_run:
325
363
  c.execute(
326
364
  """
327
365
  SELECT COUNT(*) as cnt FROM (
328
366
  SELECT edge_id,
329
- ROW_NUMBER() OVER (
330
- PARTITION BY source_id ORDER BY weight DESC
331
- ) as rn
367
+ ROW_NUMBER() OVER (PARTITION BY source_id ORDER BY weight DESC) as out_rn,
368
+ ROW_NUMBER() OVER (PARTITION BY target_id ORDER BY weight DESC) as in_rn
332
369
  FROM graph_edges
333
370
  WHERE profile_id = ?
334
- ) WHERE rn > ?
371
+ ) WHERE out_rn > ? OR in_rn > ?
335
372
  """,
336
- (profile_id, max_degree),
373
+ (profile_id, max_degree, max_degree),
337
374
  )
338
375
  excess = c.fetchone()["cnt"]
339
376
  logger.info(
340
- "(dry-run) _cap_node_degree: ~%d edges would be removed (max_degree=%d)",
377
+ "(dry-run) _cap_node_degree: ~%d edges would be removed (max_degree=%d, in+out)",
341
378
  excess, max_degree,
342
379
  )
343
380
  return excess
344
381
 
345
- # Step 1: build temp keep-list in one pass (ROW_NUMBER ranks by weight DESC)
346
- c.execute("CREATE TEMP TABLE IF NOT EXISTS _slm_keep_edges (edge_id TEXT PRIMARY KEY)")
347
- c.execute("DELETE FROM _slm_keep_edges") # idempotent if called twice
382
+ # Step 1: collect edges exceeding the cap in either direction (one pass).
383
+ c.execute("DROP TABLE IF EXISTS _slm_cap_del")
384
+ c.execute("CREATE TEMP TABLE _slm_cap_del (edge_id TEXT PRIMARY KEY)")
348
385
  c.execute(
349
386
  """
350
- INSERT INTO _slm_keep_edges (edge_id)
387
+ INSERT OR IGNORE INTO _slm_cap_del (edge_id)
351
388
  SELECT edge_id FROM (
352
389
  SELECT edge_id,
353
- ROW_NUMBER() OVER (
354
- PARTITION BY source_id ORDER BY weight DESC
355
- ) as rn
390
+ ROW_NUMBER() OVER (PARTITION BY source_id ORDER BY weight DESC) as out_rn,
391
+ ROW_NUMBER() OVER (PARTITION BY target_id ORDER BY weight DESC) as in_rn
356
392
  FROM graph_edges
357
393
  WHERE profile_id = ?
358
- ) WHERE rn <= ?
394
+ ) WHERE out_rn > ? OR in_rn > ?
359
395
  """,
360
- (profile_id, max_degree),
396
+ (profile_id, max_degree, max_degree),
361
397
  )
362
398
 
363
- # Step 2: delete everything not in keep-list (single DELETE)
399
+ # Step 2: delete the over-cap edges (single DELETE).
364
400
  c.execute(
365
401
  """
366
402
  DELETE FROM graph_edges
367
403
  WHERE profile_id = ?
368
- AND edge_id NOT IN (SELECT edge_id FROM _slm_keep_edges)
404
+ AND edge_id IN (SELECT edge_id FROM _slm_cap_del)
369
405
  """,
370
406
  (profile_id,),
371
407
  )
372
408
  deleted = c.rowcount
373
409
 
374
- c.execute("DROP TABLE IF EXISTS _slm_keep_edges")
410
+ c.execute("DROP TABLE IF EXISTS _slm_cap_del")
375
411
 
376
412
  logger.info(
377
- "_cap_node_degree: deleted %d low-weight edges (max_degree=%d)",
413
+ "_cap_node_degree: deleted %d low-weight edges (max_degree=%d, in+out capped)",
378
414
  deleted, max_degree,
379
415
  )
380
416
  return deleted
@@ -107,8 +107,17 @@ def run_maintenance(
107
107
  "fisher_coupled": 0,
108
108
  "sheaf_checked": 0,
109
109
  "entity_summaries_consolidated": 0, # V3.4.40
110
+ "orphan_metadata_gc": 0, # v3.6.4 (P1-3)
110
111
  }
111
112
 
113
+ # P1-3 (embeddings-vector-02): sweep orphaned embedding_metadata left by
114
+ # any FK-off delete path, so the semantic channel never maps to dead facts.
115
+ # Runs before the early-return so it sweeps even for empty profiles.
116
+ try:
117
+ counts["orphan_metadata_gc"] = db.gc_orphaned_embedding_metadata()
118
+ except Exception as exc: # pragma: no cover - defensive
119
+ logger.debug("orphan metadata GC skipped: %s", exc)
120
+
112
121
  facts = db.get_all_facts(profile_id)
113
122
  if not facts:
114
123
  return counts
@@ -102,6 +102,37 @@ def enrich_fact(
102
102
  )
103
103
 
104
104
 
105
+ # ---------------------------------------------------------------------------
106
+ # Vector dual-write helper (P1-2 / embeddings-vector-01)
107
+ # ---------------------------------------------------------------------------
108
+
109
+ def _upsert_fact_vectors(fact, profile_id, ann_index, vector_store, embedder=None):
110
+ """Dual-write a fact's embedding to the ANN index + sqlite-vec store.
111
+
112
+ Embeds on-demand when the fact has no embedding (e.g. consolidated
113
+ summary facts created without one), so UPDATE/SUPERSEDE and consolidated
114
+ facts remain visible to the semantic channel instead of having a row in
115
+ ``atomic_facts`` but none in the vector store.
116
+ """
117
+ if not getattr(fact, "embedding", None) and embedder is not None and fact.content:
118
+ try:
119
+ fact.embedding = embedder.embed(fact.content)
120
+ except Exception as _emb_exc: # pragma: no cover - defensive
121
+ logger.debug("on-demand embed failed for %s: %s", fact.fact_id, _emb_exc)
122
+ return
123
+ if not getattr(fact, "embedding", None):
124
+ return
125
+ if ann_index:
126
+ ann_index.add(fact.fact_id, fact.embedding)
127
+ # V3.2: VectorStore upsert (sqlite-vec) -- dual-write (Rule 12)
128
+ if vector_store and getattr(vector_store, "available", False):
129
+ vector_store.upsert(
130
+ fact_id=fact.fact_id,
131
+ profile_id=profile_id,
132
+ embedding=fact.embedding,
133
+ )
134
+
135
+
105
136
  # ---------------------------------------------------------------------------
106
137
  # run_store (was MemoryEngine.store)
107
138
  # ---------------------------------------------------------------------------
@@ -175,10 +206,20 @@ def run_store(
175
206
  )
176
207
  db.store_memory(record)
177
208
 
178
- facts = fact_extractor.extract_facts(
179
- turns=[content], session_id=session_id,
180
- session_date=parsed_date, speaker_a=speaker,
181
- )
209
+ try:
210
+ facts = fact_extractor.extract_facts(
211
+ turns=[content], session_id=session_id,
212
+ session_date=parsed_date, speaker_a=speaker,
213
+ )
214
+ except Exception as _extract_exc:
215
+ # P0-1 (remember-write-04): an extractor EXCEPTION (transient LLM/embed
216
+ # backend error) must NOT orphan the already-committed memory. The None
217
+ # guard below only handled a None *return*, not a raise. Treat a raise
218
+ # as "no facts" so the verbatim/raw fallback persists the content.
219
+ logger.warning(
220
+ "extract_facts() raised — falling back to raw fact: %s", _extract_exc,
221
+ )
222
+ facts = None
182
223
 
183
224
  # v3.4.38: Defensive None guard. extract_facts() returns None on transient
184
225
  # failures (embedding worker timeout, LLM call fail). Without this guard,
@@ -257,58 +298,77 @@ def run_store(
257
298
  )
258
299
 
259
300
  if consolidator:
260
- action = consolidator.consolidate(fact, profile_id)
261
- if action.action_type.value == "noop":
262
- continue
263
-
264
- # Opinion confidence tracking: reinforce or decay
265
- if fact.fact_type == FactType.OPINION and action.action_type.value == "update":
266
- try:
267
- existing = db.get_fact(action.new_fact_id)
268
- if existing and existing.fact_type == FactType.OPINION:
269
- new_conf = min(1.0, existing.confidence + 0.1)
270
- db.update_fact(action.new_fact_id, {"confidence": new_conf})
271
- except Exception:
272
- pass
273
- elif fact.fact_type == FactType.OPINION and action.action_type.value == "supersede":
274
- try:
275
- old_id = getattr(action, "old_fact_id", None)
276
- if old_id:
277
- old_fact = db.get_fact(old_id)
278
- if old_fact:
279
- new_conf = max(0.0, old_fact.confidence - 0.2)
280
- db.update_fact(old_id, {"confidence": new_conf})
281
- except Exception:
282
- pass
283
-
284
- if action.action_type.value in ("update", "supersede"):
285
- updated_fact = db.get_fact(action.new_fact_id)
286
- if updated_fact:
287
- if graph_builder:
288
- graph_builder.build_edges(updated_fact, profile_id)
289
- if observation_builder:
290
- for eid in updated_fact.canonical_entities:
291
- observation_builder.update_profile(
292
- eid, updated_fact, profile_id,
293
- )
294
- stored_ids.append(action.new_fact_id)
295
- continue
296
- # ADD case: consolidator already stored the fact (F8 fix)
297
- # Fall through to post-processing below
301
+ try:
302
+ action = consolidator.consolidate(fact, profile_id)
303
+ except Exception as _consolidate_exc:
304
+ # P0-1 (remember-write-03): a consolidate failure (e.g. LLM
305
+ # timeout) must NOT orphan the already-committed memory. Fall
306
+ # back to storing the raw enriched fact so the content stays
307
+ # retrievable across all channels.
308
+ logger.warning(
309
+ "consolidate() failed for fact %s — storing raw fact as "
310
+ "fallback: %s", fact.fact_id, _consolidate_exc,
311
+ )
312
+ action = None
313
+
314
+ if action is not None:
315
+ if action.action_type.value == "noop":
316
+ continue
317
+
318
+ # Opinion confidence tracking: reinforce or decay
319
+ if fact.fact_type == FactType.OPINION and action.action_type.value == "update":
320
+ try:
321
+ existing = db.get_fact(action.new_fact_id)
322
+ if existing and existing.fact_type == FactType.OPINION:
323
+ new_conf = min(1.0, existing.confidence + 0.1)
324
+ db.update_fact(action.new_fact_id, {"confidence": new_conf})
325
+ except Exception:
326
+ pass
327
+ elif fact.fact_type == FactType.OPINION and action.action_type.value == "supersede":
328
+ try:
329
+ old_id = getattr(action, "old_fact_id", None)
330
+ if old_id:
331
+ old_fact = db.get_fact(old_id)
332
+ if old_fact:
333
+ new_conf = max(0.0, old_fact.confidence - 0.2)
334
+ db.update_fact(old_id, {"confidence": new_conf})
335
+ except Exception:
336
+ pass
337
+
338
+ if action.action_type.value in ("update", "supersede"):
339
+ updated_fact = db.get_fact(action.new_fact_id)
340
+ if updated_fact:
341
+ # P1-2 (embeddings-vector-01): the merged/superseding
342
+ # fact must reach the vector store (embed on-demand if
343
+ # it has none) — otherwise it is invisible to the
344
+ # semantic channel despite living in atomic_facts.
345
+ _upsert_fact_vectors(
346
+ updated_fact, profile_id, ann_index, vector_store, embedder,
347
+ )
348
+ if graph_builder:
349
+ graph_builder.build_edges(updated_fact, profile_id)
350
+ if observation_builder:
351
+ for eid in updated_fact.canonical_entities:
352
+ observation_builder.update_profile(
353
+ eid, updated_fact, profile_id,
354
+ )
355
+ stored_ids.append(action.new_fact_id)
356
+ continue
357
+ # ADD case: consolidator already stored the fact (F8 fix)
358
+ # Fall through to post-processing below
359
+ else:
360
+ # Consolidate failed → store the raw fact ourselves so the
361
+ # memory is never left without a retrievable fact, then fall
362
+ # through to post-processing (embeddings, graph, context).
363
+ db.store_fact(fact)
298
364
  else:
299
365
  db.store_fact(fact)
300
366
 
301
367
  stored_ids.append(fact.fact_id)
302
368
 
303
- if fact.embedding and ann_index:
304
- ann_index.add(fact.fact_id, fact.embedding)
305
- # V3.2: VectorStore upsert (sqlite-vec) -- dual-write (Rule 12)
306
- if fact.embedding and vector_store and vector_store.available:
307
- vector_store.upsert(
308
- fact_id=fact.fact_id,
309
- profile_id=profile_id,
310
- embedding=fact.embedding,
311
- )
369
+ # Dual-write embedding to ANN index + vector store (embed on-demand if
370
+ # a consolidated ADD fact arrived without one). See _upsert_fact_vectors.
371
+ _upsert_fact_vectors(fact, profile_id, ann_index, vector_store, embedder)
312
372
  # Phase 2: Generate contextual description (after consolidator, before graph_builder)
313
373
  if context_generator:
314
374
  try:
@@ -489,6 +549,15 @@ def run_store_fact_direct(
489
549
  and graph edges are all populated — even for auxiliary data.
490
550
  Creates a parent memory record to satisfy FK constraint.
491
551
  """
552
+ # remember-write-02: gate low-quality content (empty, bare category tags,
553
+ # placeholder/template leakage) at the WRITE boundary, matching run_store's
554
+ # gate. Previously this direct path had no filter, so junk entered the KB
555
+ # and polluted evidence/stats/embeddings (the read-side filter only hid it).
556
+ from superlocalmemory.core.injection import is_low_quality
557
+ if is_low_quality(fact.content):
558
+ logger.debug("run_store_fact_direct: skipping low-quality content")
559
+ return fact.fact_id
560
+
492
561
  # Create parent memory record (FK: atomic_facts.memory_id → memories.memory_id)
493
562
  if not fact.memory_id:
494
563
  record = MemoryRecord(
@@ -0,0 +1,60 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """Pure decision logic for the MCP stdin-EOF self-termination monitor.
6
+
7
+ Isolated here (no imports, no side effects) so the kqueue ``EV_EOF``
8
+ handling contract is unit-testable without importing the FastMCP server
9
+ module, which starts daemon threads and auto-starts the SLM daemon at
10
+ import time.
11
+
12
+ Background (v3.6.4 fix)
13
+ -----------------------
14
+ The stdin-EOF monitor (``superlocalmemory.mcp.server._stdin_eof_monitor``)
15
+ exists to reap orphaned ``slm mcp`` processes when an IDE/agent abandons the
16
+ stdio pipe without quitting. It registers ``EVFILT_READ | EV_EOF`` on stdin
17
+ and terminates the process when the write-end closes.
18
+
19
+ On macOS, ``EVFILT_READ`` reports ``EV_EOF`` *together with* still-readable
20
+ bytes (``ev.data > 0``) when the write-end is closed while a final request
21
+ is still buffered in the pipe. The original monitor exited on the EOF flag
22
+ alone — dropping that buffered request and tearing down a session that
23
+ still had a pending in-flight call. Under strict MCP hosts whose transport
24
+ half-closes stdin around reconnect/teardown, this surfaced as the server
25
+ self-terminating mid-request, which the host then logged as a keepalive
26
+ failure and respawned (observed against the Hermes agent).
27
+
28
+ The guard defers termination until the buffer is genuinely drained
29
+ (``ev.data <= 0``), letting the FastMCP reader consume the last request
30
+ first. Genuine disconnects (EOF with an empty buffer) still terminate
31
+ immediately — behaviour identical to before for the common case.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ __all__ = ["eof_action"]
37
+
38
+
39
+ def eof_action(flags: int, data: int, eof_flag: int) -> str:
40
+ """Decide how the stdin-EOF monitor should react to one kqueue event.
41
+
42
+ Args:
43
+ flags: ``kevent.flags`` bitmask returned by ``kqueue.control``.
44
+ data: ``kevent.data`` — for ``EVFILT_READ`` this is the number of
45
+ bytes still readable on the descriptor.
46
+ eof_flag: the platform ``select.KQ_EV_EOF`` constant (injected so
47
+ this function stays import-free and trivially testable).
48
+
49
+ Returns:
50
+ - ``"exit"`` — genuine end-of-stream: write-end closed and the
51
+ buffer is drained (``data <= 0``). Safe to self-terminate.
52
+ - ``"drain"`` — write-end closed but unread bytes remain
53
+ (``data > 0``). Must NOT terminate yet; let the reader consume
54
+ the buffered request first, otherwise it is silently dropped.
55
+ - ``"ignore"`` — no EOF on this event (ordinary readability or a
56
+ spurious wake); nothing to do.
57
+ """
58
+ if not (flags & eof_flag):
59
+ return "ignore"
60
+ return "drain" if data > 0 else "exit"
@@ -288,11 +288,26 @@ _watchdog_thread.start()
288
288
  # closes WITHOUT consuming any bytes, so it cannot race with FastMCP's asyncio
289
289
  # stdin reader. On Linux (no kqueue), the watchdog alone provides coverage.
290
290
  def _stdin_eof_monitor() -> None:
291
- """Exit when the IDE closes our stdin pipe (kqueue — macOS only)."""
292
- import select as _sel, os as _os_eof
291
+ """Exit when the IDE closes our stdin pipe (kqueue — macOS only).
292
+
293
+ V3.6.4: kqueue ``EVFILT_READ`` reports ``EV_EOF`` *together with*
294
+ still-readable bytes (``ev.data > 0``) when the write-end closes while a
295
+ final request is buffered. Exiting on the EOF flag alone (pre-3.6.4)
296
+ dropped that in-flight request and self-terminated a session that still
297
+ had work to deliver — strict MCP hosts (e.g. the Hermes agent) then
298
+ logged a keepalive failure and respawned the process. We now defer
299
+ termination until the buffer is genuinely drained (see ``_stdin_guard``).
300
+ """
301
+ import select as _sel, os as _os_eof, time as _time
302
+ from superlocalmemory.mcp._stdin_guard import eof_action
293
303
  _mlog = logging.getLogger(__name__ + ".stdin_monitor")
294
304
  if not hasattr(_sel, "kqueue"):
295
305
  return # Linux / non-macOS: watchdog covers process death
306
+ # Bounded grace for the FastMCP reader to drain a buffered final request
307
+ # before we tear down. ~2 s ceiling (40 × 50 ms): a genuine EOF means the
308
+ # session is ending regardless, so we never wait indefinitely.
309
+ _DRAIN_POLL_S = 0.05
310
+ _DRAIN_MAX_POLLS = 40
296
311
  try:
297
312
  fd = sys.stdin.fileno()
298
313
  kq = _sel.kqueue()
@@ -301,9 +316,23 @@ def _stdin_eof_monitor() -> None:
301
316
  while True:
302
317
  evs = kq.control(None, 4, 30.0) # 30 s poll — low cost
303
318
  for ev in evs:
304
- if ev.flags & _sel.KQ_EV_EOF:
305
- _mlog.info("stdin write-end closed (kqueue EOF), self-terminating")
306
- _os_eof._exit(0)
319
+ action = eof_action(ev.flags, ev.data, _sel.KQ_EV_EOF)
320
+ if action == "ignore":
321
+ continue
322
+ if action == "drain":
323
+ # Write-end closed but unread bytes remain. Let the
324
+ # FastMCP reader consume the final request(s); poll until
325
+ # drained or the grace ceiling elapses.
326
+ for _ in range(_DRAIN_MAX_POLLS):
327
+ _time.sleep(_DRAIN_POLL_S)
328
+ recheck = kq.control(None, 4, 0) # non-blocking
329
+ if not recheck:
330
+ break
331
+ ev = recheck[0]
332
+ if eof_action(ev.flags, ev.data, _sel.KQ_EV_EOF) != "drain":
333
+ break
334
+ _mlog.info("stdin write-end closed (kqueue EOF, drained), self-terminating")
335
+ _os_eof._exit(0)
307
336
  except Exception as exc:
308
337
  _mlog.debug("stdin EOF monitor error: %s — watchdog will cover", exc)
309
338
 
@@ -182,16 +182,26 @@ class RetrievalEngine:
182
182
  # V3.3.19: Only bridge.discover() (86ms). Removed bridge.spreading_activation()
183
183
  # which did per-node SQL queries across 254K edges → 78s latency.
184
184
  # The SYNAPSE SA channel already provides proper SA with in-memory caching.
185
+ # recall-retrieval-01: O(1) membership/score lookups instead of repeated
186
+ # O(N) `any(...)`/`next(...)` scans inside the bridge + scene loops
187
+ # (was O(N^2) per recall, ~400 ms on large sessions). Kept in sync as
188
+ # `fused` grows so behaviour is identical.
189
+ fused_ids = {fr.fact_id for fr in fused}
190
+ fused_scores = {fr.fact_id: fr.fused_score for fr in fused}
191
+
185
192
  if self._bridge is not None and strat.query_type in ("multi_hop", "entity", "factual", "general"):
186
193
  try:
187
194
  seed_ids = [fr.fact_id for fr in fused[:10]]
188
195
  bridges = self._bridge.discover(seed_ids, profile_id, max_bridges=10)
189
196
  for fid, score in bridges:
190
- if not any(fr.fact_id == fid for fr in fused):
197
+ if fid not in fused_ids:
198
+ new_score = score * 0.8
191
199
  fused.append(FusionResult(
192
- fact_id=fid, fused_score=score * 0.8,
200
+ fact_id=fid, fused_score=new_score,
193
201
  channel_ranks={}, channel_scores={},
194
202
  ))
203
+ fused_ids.add(fid)
204
+ fused_scores[fid] = new_score
195
205
  except Exception as exc:
196
206
  logger.warning("Bridge discovery: %s", exc)
197
207
 
@@ -206,14 +216,15 @@ class RetrievalEngine:
206
216
  for fid in top_ids:
207
217
  for scene in scenes_map.get(fid, [])[:2]:
208
218
  for sfid in scene.fact_ids:
209
- if not any(f.fact_id == sfid for f in fused) and sfid not in expanded_ids:
219
+ if sfid not in fused_ids and sfid not in expanded_ids:
210
220
  expanded_ids.add(sfid)
221
+ new_score = fused_scores.get(fid, 0.5) * 0.8
211
222
  fused.append(FusionResult(
212
- fact_id=sfid, fused_score=(
213
- next((f.fused_score for f in fused if f.fact_id == fid), 0.5) * 0.8
214
- ),
223
+ fact_id=sfid, fused_score=new_score,
215
224
  channel_ranks={}, channel_scores={},
216
225
  ))
226
+ fused_ids.add(sfid)
227
+ fused_scores[sfid] = new_score
217
228
  except Exception as exc:
218
229
  logger.warning("Scene expansion: %s", exc)
219
230
 
@@ -118,9 +118,17 @@ class EntityGraphChannel:
118
118
  """
119
119
  # Check staleness: profile changed or new edges added since last load
120
120
  current_count = self._get_edge_count(profile_id)
121
+ # memory-bounding-01: also reload if the cache is older than the TTL,
122
+ # even when the edge COUNT is unchanged. Edge weights/pruning can mutate
123
+ # the graph without changing the count (e.g. store_edge MAX-merge), and a
124
+ # count-stable window would otherwise serve a stale adjacency map.
125
+ import time as _t_ec
126
+ _now_ec = _t_ec.monotonic()
127
+ _fresh = (_now_ec - getattr(self, "_adj_loaded_at", 0.0)) < 300.0
121
128
  if (self._adj_profile == profile_id
122
129
  and self._adj
123
- and self._adj_edge_count == current_count):
130
+ and self._adj_edge_count == current_count
131
+ and _fresh):
124
132
  return
125
133
  adj: dict[str, list[tuple[str, float]]] = defaultdict(list)
126
134
  try:
@@ -138,6 +146,7 @@ class EntityGraphChannel:
138
146
  self._adj = dict(adj) # Convert defaultdict to regular dict (no accidental growth)
139
147
  self._adj_profile = profile_id
140
148
  self._adj_edge_count = current_count
149
+ self._adj_loaded_at = _now_ec # memory-bounding-01: TTL reference
141
150
  # Also load entity maps (same staleness lifecycle)
142
151
  self._load_entity_maps(profile_id)
143
152
  # v3.4.1: Load graph intelligence metrics (P0)
@@ -302,7 +302,9 @@ class HopfieldChannel:
302
302
  return (self._cached_matrix, self._cached_fact_ids)
303
303
 
304
304
  # Step 2: Load facts (V3.3.12: cap to most recent 5000 to bound memory)
305
- facts = self._db.get_all_facts(profile_id)[:5000]
305
+ # memory-bounding-02: push the cap into SQL (LIMIT) so we don't
306
+ # deserialize the whole table just to slice it.
307
+ facts = self._db.get_all_facts(profile_id, limit=5000)
306
308
  if not facts:
307
309
  return (None, [])
308
310
 
@@ -188,7 +188,47 @@ class DatabaseManager:
188
188
  return ""
189
189
 
190
190
  def store_fact(self, fact: AtomicFact) -> str:
191
- """Persist an atomic fact. Returns fact_id."""
191
+ """Persist an atomic fact. Returns fact_id.
192
+
193
+ v3.6.4 — idempotent on content. If an ACTIVE fact with identical
194
+ content already exists for this profile, reinforce it (bump
195
+ evidence_count + access_count) and return its fact_id instead of
196
+ inserting a duplicate row. The passed fact's ``fact_id`` is rewritten
197
+ to the canonical id so downstream writes keyed on it (embeddings,
198
+ graph edges, context) target the real fact rather than orphaning.
199
+
200
+ This enforces the memory-system invariant "storing the same fact
201
+ twice is one fact" — preventing the duplicate explosion that poisons
202
+ importance ranking and core-memory promotion. Empty/whitespace
203
+ content is exempt (handled by placeholder filtering, not dedup).
204
+ """
205
+ if fact.content and fact.content.strip():
206
+ # Dedup across all LIVE lifecycle zones (active/warm/cold). Excludes
207
+ # 'archived' — that is soft-deleted/forgotten, so re-storing the same
208
+ # content correctly re-learns it as a fresh fact. Matching only
209
+ # 'active' (pre-3.6.4) re-opened the duplication window for every
210
+ # fact that aged to warm/cold (the bulk of the KB).
211
+ existing = self.execute(
212
+ "SELECT fact_id FROM atomic_facts "
213
+ "WHERE profile_id = ? AND content = ? "
214
+ "AND lifecycle IN ('active', 'warm', 'cold') "
215
+ "ORDER BY created_at LIMIT 1",
216
+ (fact.profile_id, fact.content),
217
+ )
218
+ if existing:
219
+ canonical_id = dict(existing[0])["fact_id"]
220
+ self.execute(
221
+ "UPDATE atomic_facts "
222
+ "SET evidence_count = evidence_count + 1, "
223
+ " access_count = access_count + 1 "
224
+ "WHERE fact_id = ?",
225
+ (canonical_id,),
226
+ )
227
+ # Rewrite caller's id so downstream embedding/graph/context
228
+ # writes target the canonical fact (idempotent), not an
229
+ # orphaned id that was never inserted.
230
+ fact.fact_id = canonical_id
231
+ return canonical_id
192
232
  self.execute(
193
233
  """INSERT OR REPLACE INTO atomic_facts
194
234
  (fact_id, memory_id, profile_id, content, fact_type,
@@ -259,12 +299,26 @@ class DatabaseManager:
259
299
  )
260
300
  return [self._row_to_fact(r) for r in rows]
261
301
 
262
- def get_all_facts(self, profile_id: str) -> list[AtomicFact]:
263
- """All facts for a profile, newest first."""
264
- rows = self.execute(
265
- "SELECT * FROM atomic_facts WHERE profile_id = ? ORDER BY created_at DESC",
266
- (profile_id,),
267
- )
302
+ def get_all_facts(
303
+ self, profile_id: str, limit: int | None = None,
304
+ ) -> list[AtomicFact]:
305
+ """All facts for a profile, newest first.
306
+
307
+ memory-bounding-02: optional SQL LIMIT so callers needing only the
308
+ most-recent N (e.g. the Hopfield channel's 5000 cap) don't deserialize
309
+ the entire table into AtomicFact objects. Default (None) = all facts.
310
+ """
311
+ if limit is not None:
312
+ rows = self.execute(
313
+ "SELECT * FROM atomic_facts WHERE profile_id = ? "
314
+ "ORDER BY created_at DESC LIMIT ?",
315
+ (profile_id, int(limit)),
316
+ )
317
+ else:
318
+ rows = self.execute(
319
+ "SELECT * FROM atomic_facts WHERE profile_id = ? ORDER BY created_at DESC",
320
+ (profile_id,),
321
+ )
268
322
  return [self._row_to_fact(r) for r in rows]
269
323
 
270
324
  _MAX_FACTS_PER_ENTITY_LOOKUP: int = 100
@@ -325,9 +379,38 @@ class DatabaseManager:
325
379
  )
326
380
 
327
381
  def delete_fact(self, fact_id: str) -> None:
328
- """Hard-delete a fact."""
382
+ """Hard-delete a fact.
383
+
384
+ DatabaseManager connections enforce FKs (PRAGMA foreign_keys=ON), so
385
+ embedding_metadata / fact_retention / edges cascade. The explicit
386
+ embedding_metadata delete below is belt-and-suspenders for the case a
387
+ future caller routes through a connection without FK enforcement.
388
+ """
389
+ self.execute("DELETE FROM embedding_metadata WHERE fact_id = ?", (fact_id,))
329
390
  self.execute("DELETE FROM atomic_facts WHERE fact_id = ?", (fact_id,))
330
391
 
392
+ def gc_orphaned_embedding_metadata(self) -> int:
393
+ """Remove embedding_metadata rows whose parent atomic_fact is gone.
394
+
395
+ P1-3 (embeddings-vector-02): orphans accumulate when facts are deleted
396
+ through a connection that has FK enforcement OFF (the ON DELETE CASCADE
397
+ never fires). Vector search maps a vec0 rowid → fact_id via this table;
398
+ orphans return stale fact_ids that fail downstream fetch. This
399
+ maintenance sweep removes them regardless of how they were created.
400
+ Returns the number of rows deleted.
401
+ """
402
+ rows = self.execute(
403
+ "SELECT COUNT(*) AS c FROM embedding_metadata "
404
+ "WHERE fact_id NOT IN (SELECT fact_id FROM atomic_facts)"
405
+ )
406
+ n = int(rows[0]["c"]) if rows else 0
407
+ if n:
408
+ self.execute(
409
+ "DELETE FROM embedding_metadata "
410
+ "WHERE fact_id NOT IN (SELECT fact_id FROM atomic_facts)"
411
+ )
412
+ return n
413
+
331
414
  def get_fact_count(self, profile_id: str) -> int:
332
415
  """Total fact count for a profile."""
333
416
  rows = self.execute(
@@ -408,7 +491,28 @@ class DatabaseManager:
408
491
  return [self._row_to_fact(r) for r in rows]
409
492
 
410
493
  def store_edge(self, edge: GraphEdge) -> str:
411
- """Persist a graph edge. Returns edge_id."""
494
+ """Persist a graph edge. Returns edge_id.
495
+
496
+ graph-integrity-02: dedup on the LOGICAL edge identity
497
+ (profile, source, target, type). The PK is a random edge_id, so
498
+ without this every re-link created a duplicate row, and NetworkX
499
+ builds read last-weight-wins — corrupting PageRank/centrality. On a
500
+ duplicate we keep the MAX weight (strongest association wins) and
501
+ return the existing edge_id.
502
+ """
503
+ existing = self.execute(
504
+ "SELECT edge_id FROM graph_edges "
505
+ "WHERE profile_id = ? AND source_id = ? AND target_id = ? AND edge_type = ? "
506
+ "LIMIT 1",
507
+ (edge.profile_id, edge.source_id, edge.target_id, edge.edge_type.value),
508
+ )
509
+ if existing:
510
+ canonical_id = dict(existing[0])["edge_id"]
511
+ self.execute(
512
+ "UPDATE graph_edges SET weight = MAX(weight, ?) WHERE edge_id = ?",
513
+ (edge.weight, canonical_id),
514
+ )
515
+ return canonical_id
412
516
  self.execute(
413
517
  """INSERT OR REPLACE INTO graph_edges
414
518
  (edge_id, profile_id, source_id, target_id, edge_type, weight, created_at)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: superlocalmemory
3
- Version: 3.6.3
3
+ Version: 3.6.5
4
4
  Summary: Information-geometric agent memory with mathematical guarantees
5
5
  Author-email: Varun Pratap Bhardwaj <admin@superlocalmemory.com>
6
6
  License: AGPL-3.0-or-later
@@ -268,6 +268,7 @@ src/superlocalmemory/math/turbo_quant.py
268
268
  src/superlocalmemory/mcp/__init__.py
269
269
  src/superlocalmemory/mcp/_daemon_proxy.py
270
270
  src/superlocalmemory/mcp/_pool_adapter.py
271
+ src/superlocalmemory/mcp/_stdin_guard.py
271
272
  src/superlocalmemory/mcp/resources.py
272
273
  src/superlocalmemory/mcp/server.py
273
274
  src/superlocalmemory/mcp/shared.py
@@ -503,6 +504,7 @@ tests/test_engine_hooks.py
503
504
  tests/test_event_bus.py
504
505
  tests/test_features.py
505
506
  tests/test_final_locomo_mini.py
507
+ tests/test_graph_integrity.py
506
508
  tests/test_hook_handlers.py
507
509
  tests/test_ide_connector.py
508
510
  tests/test_infra.py