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
@@ -18,6 +18,8 @@ Part of Qualixar | Author: Varun Pratap Bhardwaj
18
18
  from __future__ import annotations
19
19
 
20
20
  import logging
21
+ import os
22
+ import threading
21
23
  from pathlib import Path
22
24
  from typing import Any
23
25
 
@@ -26,7 +28,7 @@ from superlocalmemory.core.engine_capabilities import Capabilities, CapabilityEr
26
28
  from superlocalmemory.core.modes import get_capabilities
27
29
  from superlocalmemory.learning.outcome_queue import RecallEvent, enqueue_recall
28
30
  from superlocalmemory.storage.models import (
29
- AtomicFact, MemoryRecord, Mode, RecallResponse,
31
+ AtomicFact, FactType, MemoryRecord, Mode, RecallResponse,
30
32
  )
31
33
 
32
34
  logger = logging.getLogger(__name__)
@@ -47,6 +49,34 @@ def _verify_ingestion_schema(memory_db: Path) -> bool:
47
49
  connection.close()
48
50
 
49
51
 
52
+ # ---------------------------------------------------------------------------
53
+ # Workstream D (3.8.4) — warm-guard sync embed helpers
54
+ # ---------------------------------------------------------------------------
55
+
56
+ def _is_remote_embedder(embedder: object) -> bool:
57
+ """Return True if *embedder* makes remote HTTP calls (cloud / OpenAI-compatible).
58
+
59
+ Remote embedders (100–400ms round-trip) must never block the store_fast()
60
+ write path synchronously — they stay on the async materializer path.
61
+
62
+ Local embedders:
63
+ - EmbeddingService (subprocess, local ONNX/sentence-transformers): has
64
+ ``_config`` with ``is_cloud=False`` and ``is_openai_compatible=False``.
65
+ - OllamaEmbedder (localhost HTTP, ~73ms): has NO ``_config`` attribute.
66
+
67
+ Remote embedders:
68
+ - EmbeddingService with ``_config.is_cloud=True`` (Azure, etc.)
69
+ - EmbeddingService with ``_config.is_openai_compatible=True``
70
+ """
71
+ cfg = getattr(embedder, "_config", None)
72
+ if cfg is None:
73
+ return False # OllamaEmbedder — local
74
+ return bool(
75
+ getattr(cfg, "is_cloud", False)
76
+ or getattr(cfg, "is_openai_compatible", False)
77
+ )
78
+
79
+
50
80
  class MemoryEngine:
51
81
  """Main orchestrator for the SuperLocalMemory V3 memory system.
52
82
 
@@ -103,6 +133,11 @@ class MemoryEngine:
103
133
  self._consolidation_engine = None
104
134
  self._maintenance_scheduler = None
105
135
  self._hooks = HookRegistry()
136
+ # Workstream D (3.8.4): single-worker pool reused across store_fast() calls.
137
+ # Lazy-created on first warm-guard attempt; avoids per-call thread churn.
138
+ self._store_fast_embed_pool: object | None = None
139
+ # Lock guards the lazy-init to prevent TOCTOU race on concurrent first calls.
140
+ self._store_fast_embed_pool_lock = threading.Lock()
106
141
 
107
142
  # -- Public properties (Phase 2+ access) --------------------------------
108
143
 
@@ -525,9 +560,6 @@ class MemoryEngine:
525
560
  import re as _re
526
561
  import uuid as _uuid
527
562
  from datetime import datetime, timezone
528
- from superlocalmemory.storage.models import (
529
- AtomicFact, FactType, MemoryRecord,
530
- )
531
563
  from superlocalmemory.core.engine_ingestion import content_passes_admission
532
564
  if not content_passes_admission(content):
533
565
  return []
@@ -568,15 +600,64 @@ class MemoryEngine:
568
600
  r"\b([A-Z][a-z]+(?:\s[A-Z][a-z]+){0,3})\b", fact_text)}
569
601
  | {m.group(1) for m in _re.finditer(r"\b([A-Z]{2,})\b", fact_text)}
570
602
  )
571
- # Queryable admission must never acquire the embedding worker lock.
572
- # On a warm daemon that looked cheap, but on a clean Mode A install the
573
- # background model load owns that lock for up to 180s and turned the
574
- # receipt-first path into a hidden synchronous wait. The canonical
575
- # materializer below runs the complete pipeline and promotes this same
576
- # fact with its embedding, Fisher parameters, entities and graph edges.
577
- # Until then it is deliberately BM25/entity/date recallable.
603
+ # Workstream D (3.8.4) warm-guard synchronous embed.
604
+ #
605
+ # Original contract (3.8.2): queryable admission NEVER acquires the
606
+ # embedding worker lock. On a clean Mode-A install the background model
607
+ # load owns that lock for up to 180s, turning the receipt-first path into
608
+ # a hidden synchronous wait. The canonical materializer promotes this
609
+ # fact with its full pipeline (embedding, Fisher, entities, graph edges).
610
+ #
611
+ # 3.8.4 extension: when the embedder is PROVABLY warm (_available is True)
612
+ # AND is a local embedder (not a remote cloud/OpenAI endpoint), compute the
613
+ # embedding synchronously with a hard 500ms cap. On timeout or any
614
+ # exception, fall through to emb=None — the materializer fills it async.
615
+ # This preserves the 3.8.2 invariant for cold start while eliminating the
616
+ # semantic-channel blind spot on warm daemons (the top UX complaint).
578
617
  emb = None
579
618
  fmean = fvar = None
619
+ _embedder_ref = self._embedder
620
+ if (
621
+ _embedder_ref is not None
622
+ and getattr(_embedder_ref, "_available", None) is True
623
+ and not _is_remote_embedder(_embedder_ref)
624
+ ):
625
+ import concurrent.futures as _cf
626
+ # Lazy-init the pool once per engine instance — avoids per-call
627
+ # thread churn and the associated resource leak from discard-on-exit.
628
+ # Double-checked locking guards against TOCTOU on concurrent first calls.
629
+ if self._store_fast_embed_pool is None:
630
+ with self._store_fast_embed_pool_lock:
631
+ if self._store_fast_embed_pool is None:
632
+ self._store_fast_embed_pool = _cf.ThreadPoolExecutor(
633
+ max_workers=1,
634
+ thread_name_prefix="slm-sg-embed",
635
+ )
636
+ try:
637
+ _timeout_s = int(os.environ.get("SLM_STORE_FAST_EMBED_TIMEOUT_MS", 500)) / 1000.0
638
+ except (ValueError, TypeError):
639
+ _timeout_s = 0.5 # default 500 ms
640
+ try:
641
+ _future = self._store_fast_embed_pool.submit(_embedder_ref.embed, fact_text)
642
+ try:
643
+ emb = _future.result(timeout=_timeout_s)
644
+ if emb:
645
+ fmean, fvar = _embedder_ref.compute_fisher_params(emb)
646
+ except _cf.TimeoutError:
647
+ logger.debug(
648
+ "store_fast: warm-guard embed timed out (>%.0fms) — deferring to materializer",
649
+ _timeout_s * 1000,
650
+ )
651
+ emb = None
652
+ except Exception as _exc:
653
+ logger.debug(
654
+ "store_fast: warm-guard embed failed (%s) — deferring to materializer",
655
+ _exc,
656
+ )
657
+ emb = None
658
+ except Exception as _exc:
659
+ logger.debug("store_fast: warm-guard pool submit failed (%s)", _exc)
660
+ emb = None
580
661
  fact = AtomicFact(
581
662
  fact_id=_uuid.uuid4().hex[:16], memory_id=record.memory_id,
582
663
  profile_id=self._profile_id, content=fact_text,
@@ -32,6 +32,10 @@ import sqlite3
32
32
  import uuid
33
33
  from datetime import datetime, timezone
34
34
  from pathlib import Path
35
+ from typing import TYPE_CHECKING, Union
36
+
37
+ if TYPE_CHECKING:
38
+ from superlocalmemory.storage.database import DatabaseManager
35
39
 
36
40
  logger = logging.getLogger("superlocalmemory.fact_consolidator")
37
41
 
@@ -41,7 +45,7 @@ _MAX_CONSOLIDATED_CHARS = 2000
41
45
 
42
46
 
43
47
  def consolidate_facts(
44
- db_path: str | Path,
48
+ db_or_path: "Union[DatabaseManager, str, Path]",
45
49
  profile_id: str = "default",
46
50
  max_clusters: int = 20,
47
51
  dry_run: bool = False,
@@ -49,6 +53,20 @@ def consolidate_facts(
49
53
  ) -> dict:
50
54
  """Find and consolidate clusters of related facts.
51
55
 
56
+ Concurrency fix (v3.8.4 — implements TODO from Fix-A comment):
57
+ When a DatabaseManager is provided the consolidation now uses per-cluster
58
+ short write transactions instead of one long raw_connection() that held
59
+ the write lock across Ollama / Cloud-LLM calls for every cluster after
60
+ the first. The fix:
61
+
62
+ 1. Discover clusters with a short memory_read() (no write lock).
63
+ 2. For each cluster: fetch fact content (memory_read()), generate the
64
+ summary OUTSIDE any lock (Ollama / Cloud LLM may take 30 s), then
65
+ do the SAVEPOINT write inside a short memory_write() block.
66
+
67
+ SAVEPOINT atomicity per cluster is preserved: _consolidate_cluster still
68
+ uses SAVEPOINT / RELEASE / ROLLBACK TO internally.
69
+
52
70
  Mode behavior:
53
71
  - Mode A: Extractive only (no LLM). Always available.
54
72
  - Mode B: Ollama LLM summarization. Falls back to extractive if Ollama down.
@@ -56,7 +74,10 @@ def consolidate_facts(
56
74
 
57
75
  Returns stats: consolidated, clusters_found, facts_archived, errors.
58
76
  """
59
- stats = {
77
+ from superlocalmemory.storage.database import DatabaseManager
78
+ from superlocalmemory.storage.memory_write import memory_read, memory_write
79
+
80
+ stats: dict = {
60
81
  "clusters_found": 0,
61
82
  "consolidated": 0,
62
83
  "facts_archived": 0,
@@ -71,7 +92,79 @@ def consolidate_facts(
71
92
  mode_str = getattr(mode, 'value', str(mode)).lower()
72
93
  stats["mode"] = mode_str
73
94
 
74
- conn = sqlite3.connect(str(db_path))
95
+ if isinstance(db_or_path, DatabaseManager):
96
+ db_path = db_or_path.db_path
97
+ try:
98
+ # Step 1: discover clusters — short read, no write lock.
99
+ with memory_read(db_path) as rconn:
100
+ rconn.row_factory = sqlite3.Row
101
+ clusters = _find_consolidation_clusters(rconn, profile_id, max_clusters)
102
+ stats["clusters_found"] = len(clusters)
103
+
104
+ for entity_id, entity_name, fact_ids in clusters:
105
+ try:
106
+ # Step 2: load fact content for this cluster (read, no write lock).
107
+ placeholders = ",".join("?" * len(fact_ids))
108
+ with memory_read(db_path) as rconn:
109
+ rconn.row_factory = sqlite3.Row
110
+ facts = rconn.execute(
111
+ f"SELECT fact_id, content, confidence, created_at, "
112
+ f"canonical_entities_json, scope, shared_with "
113
+ f"FROM atomic_facts "
114
+ f"WHERE fact_id IN ({placeholders}) ORDER BY created_at",
115
+ fact_ids,
116
+ ).fetchall()
117
+ facts = [dict(f) for f in facts]
118
+
119
+ if len(facts) < _MIN_CLUSTER_SIZE:
120
+ continue
121
+
122
+ # Step 3: generate summary OUTSIDE any write lock.
123
+ # Ollama (Mode B) or Cloud LLM (Mode C) may take 30 s here.
124
+ summary = _generate_summary(entity_name, facts, config)
125
+ if not summary:
126
+ continue
127
+
128
+ if dry_run:
129
+ stats["consolidated"] += 1
130
+ stats["facts_archived"] += len(fact_ids)
131
+ continue
132
+
133
+ # Step 4: short per-cluster write — hold lock only for SQL.
134
+ with memory_write(db_path) as conn:
135
+ conn.row_factory = sqlite3.Row
136
+ result = _consolidate_cluster(
137
+ conn, profile_id, entity_id, entity_name,
138
+ fact_ids, dry_run=False, config=None,
139
+ _presummary=summary,
140
+ )
141
+ if result:
142
+ stats["consolidated"] += 1
143
+ stats["facts_archived"] += len(fact_ids)
144
+ except Exception as exc:
145
+ logger.warning(
146
+ "Consolidation failed for %s: %s",
147
+ entity_name, exc, exc_info=True,
148
+ )
149
+ stats["errors"] += 1
150
+
151
+ if stats["consolidated"] > 0:
152
+ logger.info(
153
+ "Fact consolidation: %d clusters merged, %d facts archived",
154
+ stats["consolidated"], stats["facts_archived"],
155
+ )
156
+ except Exception as exc:
157
+ logger.error("Fact consolidation failed: %s", exc, exc_info=True)
158
+ stats["errors"] += 1
159
+ stats["error_detail"] = str(exc)
160
+ return stats
161
+
162
+ # Backward-compat: str | Path — open own connection.
163
+ logger.warning(
164
+ "consolidate_facts: passing a db_path is deprecated — pass a "
165
+ "DatabaseManager instead (Fix A backward-compat shim active)"
166
+ )
167
+ conn = sqlite3.connect(str(db_or_path))
75
168
  wal_mode = conn.execute("PRAGMA journal_mode=WAL").fetchone()
76
169
  if wal_mode and wal_mode[0] != "wal":
77
170
  logger.warning("WAL mode not active, got: %s", wal_mode[0])
@@ -79,33 +172,9 @@ def consolidate_facts(
79
172
  conn.row_factory = sqlite3.Row
80
173
 
81
174
  try:
82
- clusters = _find_consolidation_clusters(conn, profile_id, max_clusters)
83
- stats["clusters_found"] = len(clusters)
84
-
85
- for entity_id, entity_name, fact_ids in clusters:
86
- try:
87
- result = _consolidate_cluster(
88
- conn, profile_id, entity_id, entity_name,
89
- fact_ids, dry_run, config,
90
- )
91
- if result:
92
- stats["consolidated"] += 1
93
- stats["facts_archived"] += len(fact_ids)
94
- except Exception as exc:
95
- logger.warning(
96
- "Consolidation failed for %s: %s",
97
- entity_name, exc, exc_info=True,
98
- )
99
- stats["errors"] += 1
100
-
175
+ _run_consolidation(conn, profile_id, max_clusters, dry_run, config, stats)
101
176
  if not dry_run:
102
177
  conn.commit()
103
-
104
- if stats["consolidated"] > 0:
105
- logger.info(
106
- "Fact consolidation: %d clusters merged, %d facts archived",
107
- stats["consolidated"], stats["facts_archived"],
108
- )
109
178
  except Exception as exc:
110
179
  logger.error("Fact consolidation failed: %s", exc, exc_info=True)
111
180
  stats["errors"] += 1
@@ -116,6 +185,41 @@ def consolidate_facts(
116
185
  return stats
117
186
 
118
187
 
188
+ def _run_consolidation(
189
+ conn: sqlite3.Connection,
190
+ profile_id: str,
191
+ max_clusters: int,
192
+ dry_run: bool,
193
+ config: object | None,
194
+ stats: dict,
195
+ ) -> None:
196
+ """Core consolidation logic — connection-agnostic inner function."""
197
+ clusters = _find_consolidation_clusters(conn, profile_id, max_clusters)
198
+ stats["clusters_found"] = len(clusters)
199
+
200
+ for entity_id, entity_name, fact_ids in clusters:
201
+ try:
202
+ result = _consolidate_cluster(
203
+ conn, profile_id, entity_id, entity_name,
204
+ fact_ids, dry_run, config,
205
+ )
206
+ if result:
207
+ stats["consolidated"] += 1
208
+ stats["facts_archived"] += len(fact_ids)
209
+ except Exception as exc:
210
+ logger.warning(
211
+ "Consolidation failed for %s: %s",
212
+ entity_name, exc, exc_info=True,
213
+ )
214
+ stats["errors"] += 1
215
+
216
+ if stats["consolidated"] > 0:
217
+ logger.info(
218
+ "Fact consolidation: %d clusters merged, %d facts archived",
219
+ stats["consolidated"], stats["facts_archived"],
220
+ )
221
+
222
+
119
223
  def _find_consolidation_clusters(
120
224
  conn: sqlite3.Connection,
121
225
  profile_id: str,
@@ -179,15 +283,25 @@ def _consolidate_cluster(
179
283
  fact_ids: list[str],
180
284
  dry_run: bool,
181
285
  config: object | None = None,
286
+ *,
287
+ _presummary: str | None = None,
182
288
  ) -> dict | None:
183
289
  """Merge a cluster of facts into one consolidated fact.
184
290
 
185
291
  All writes are wrapped in a SAVEPOINT for atomicity — if any step fails,
186
292
  the entire cluster consolidation is rolled back.
293
+
294
+ _presummary (v3.8.4): when the caller supplies a pre-computed summary
295
+ (generated OUTSIDE the write lock), skip the _generate_summary() call.
296
+ This is the short-lock path used by the DatabaseManager branch of
297
+ consolidate_facts(). The str | Path backward-compat path still calls
298
+ _generate_summary() inline (legacy behaviour, no regression).
187
299
  """
188
300
  c = conn.cursor()
189
301
 
190
- # Load fact contents including canonical_entities_json
302
+ # Load fact contents including canonical_entities_json.
303
+ # Even when _presummary is provided we still re-read from the DB inside
304
+ # the write lock so the SAVEPOINT has a fresh, authoritative facts list.
191
305
  placeholders = ",".join("?" * len(fact_ids))
192
306
  facts = c.execute(
193
307
  f"SELECT fact_id, content, confidence, created_at, canonical_entities_json, "
@@ -200,7 +314,11 @@ def _consolidate_cluster(
200
314
  if len(facts) < _MIN_CLUSTER_SIZE:
201
315
  return None
202
316
 
203
- summary = _generate_summary(entity_name, facts, config)
317
+ if _presummary is not None:
318
+ summary = _presummary
319
+ else:
320
+ # Legacy path (str | Path caller) — may call Ollama with write lock held.
321
+ summary = _generate_summary(entity_name, facts, config)
204
322
  if not summary:
205
323
  return None
206
324