superlocalmemory 3.6.3 → 3.6.4
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 +21 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/core/consolidation_engine.py +8 -0
- package/src/superlocalmemory/core/engine.py +6 -1
- package/src/superlocalmemory/core/fact_consolidator.py +54 -13
- package/src/superlocalmemory/core/graph_pruner.py +55 -19
- package/src/superlocalmemory/core/maintenance.py +9 -0
- package/src/superlocalmemory/core/store_pipeline.py +120 -51
- package/src/superlocalmemory/mcp/_stdin_guard.py +60 -0
- package/src/superlocalmemory/mcp/server.py +34 -5
- package/src/superlocalmemory/retrieval/engine.py +17 -6
- package/src/superlocalmemory/retrieval/entity_channel.py +10 -1
- package/src/superlocalmemory/retrieval/hopfield_channel.py +3 -1
- package/src/superlocalmemory/storage/database.py +113 -9
- package/src/superlocalmemory.egg-info/PKG-INFO +1 -1
- package/src/superlocalmemory.egg-info/SOURCES.txt +2 -0
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
|
+
"version": "3.6.4",
|
|
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
|
@@ -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=
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
346
|
-
c.execute("
|
|
347
|
-
c.execute("
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
|
197
|
+
if fid not in fused_ids:
|
|
198
|
+
new_score = score * 0.8
|
|
191
199
|
fused.append(FusionResult(
|
|
192
|
-
fact_id=fid, fused_score=
|
|
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
|
|
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
|
-
|
|
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(
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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)
|
|
@@ -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
|