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.
- package/CHANGELOG.md +57 -0
- package/README.md +3 -2
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/CLAUDE.md +3 -3
- package/plugin/agents/slm-governance-advisor.md +1 -1
- package/plugin/agents/slm-loop-runner.md +1 -1
- package/plugin/agents/slm-memory-advisor.md +1 -1
- package/plugin/agents/slm-optimize-advisor.md +1 -1
- package/plugin/requirements.txt +1 -1
- package/plugin/skills/slm-cache/SKILL.md +1 -1
- package/plugin/skills/slm-compress/SKILL.md +1 -1
- package/plugin/skills/slm-governance/SKILL.md +1 -1
- package/plugin/skills/slm-graph/SKILL.md +1 -1
- package/plugin/skills/slm-loop/SKILL.md +1 -1
- package/plugin/skills/slm-mesh/SKILL.md +1 -1
- package/plugin/skills/slm-profile/SKILL.md +1 -1
- package/plugin/skills/slm-recall/SKILL.md +1 -1
- package/plugin/skills/slm-remember/SKILL.md +1 -1
- package/plugin/skills/slm-scope/SKILL.md +1 -1
- package/plugin/skills/slm-session/SKILL.md +1 -1
- package/plugin/skills/slm-status/SKILL.md +1 -1
- package/plugin-src/rules/AGENTS.md +1 -1
- package/plugin-src/skills/slm-cache/SKILL.md +1 -1
- package/plugin-src/skills/slm-compress/SKILL.md +1 -1
- package/plugin-src/skills/slm-graph/SKILL.md +1 -1
- package/plugin-src/skills/slm-recall/SKILL.md +1 -1
- package/plugin-src/skills/slm-remember/SKILL.md +1 -1
- package/plugin-src/skills/slm-session/SKILL.md +1 -1
- package/plugin-src/skills/slm-status/SKILL.md +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/access/rbac.py +68 -76
- package/src/superlocalmemory/cli/commands.py +19 -0
- package/src/superlocalmemory/cli/ingest_cmd.py +11 -1
- package/src/superlocalmemory/cli/main.py +30 -0
- package/src/superlocalmemory/cli/pending_store.py +39 -14
- package/src/superlocalmemory/core/backend_orchestrator.py +93 -0
- package/src/superlocalmemory/core/config.py +78 -0
- package/src/superlocalmemory/core/consolidation_engine.py +79 -73
- package/src/superlocalmemory/core/engine.py +92 -11
- package/src/superlocalmemory/core/fact_consolidator.py +148 -30
- package/src/superlocalmemory/core/graph_pruner.py +436 -39
- package/src/superlocalmemory/core/ingestion_command.py +160 -31
- package/src/superlocalmemory/core/maintenance_scheduler.py +61 -1
- package/src/superlocalmemory/core/recall_pipeline.py +3 -0
- package/src/superlocalmemory/core/registry.py +5 -1
- package/src/superlocalmemory/core/remote_mode.py +3 -1
- package/src/superlocalmemory/core/scale_engine.py +41 -18
- package/src/superlocalmemory/core/store_pipeline.py +18 -4
- package/src/superlocalmemory/encoding/entity_resolver.py +18 -11
- package/src/superlocalmemory/hooks/_outcome_common.py +9 -2
- package/src/superlocalmemory/hooks/adapter_base.py +58 -44
- package/src/superlocalmemory/hooks/ide_connector.py +26 -8
- package/src/superlocalmemory/hooks/portable_kit.py +105 -9
- package/src/superlocalmemory/hooks/prewarm_auth.py +21 -2
- package/src/superlocalmemory/infra/auth_middleware.py +3 -1
- package/src/superlocalmemory/infra/cloud_backup.py +26 -27
- package/src/superlocalmemory/infra/event_bus.py +250 -88
- package/src/superlocalmemory/learning/consolidation_cycle.py +33 -16
- package/src/superlocalmemory/learning/entity_compiler.py +148 -132
- package/src/superlocalmemory/learning/memory_merge.py +97 -82
- package/src/superlocalmemory/learning/reward_archive.py +98 -90
- package/src/superlocalmemory/learning/reward_boost.py +40 -30
- package/src/superlocalmemory/mcp/http_transport.py +335 -3
- package/src/superlocalmemory/retrieval/engine.py +7 -1
- package/src/superlocalmemory/retrieval/entity_channel.py +25 -1
- package/src/superlocalmemory/retrieval/reranker.py +98 -15
- package/src/superlocalmemory/retrieval/spreading_activation.py +20 -12
- package/src/superlocalmemory/retrieval/vector_store.py +84 -69
- package/src/superlocalmemory/server/loopback.py +91 -0
- package/src/superlocalmemory/server/origin.py +9 -4
- package/src/superlocalmemory/server/routes/backup.py +6 -2
- package/src/superlocalmemory/server/routes/behavioral.py +6 -12
- package/src/superlocalmemory/server/routes/compliance.py +20 -23
- package/src/superlocalmemory/server/routes/config_api.py +83 -0
- package/src/superlocalmemory/server/routes/helpers.py +24 -13
- package/src/superlocalmemory/server/routes/memories.py +139 -91
- package/src/superlocalmemory/server/routes/mesh.py +7 -2
- package/src/superlocalmemory/server/routes/profiles.py +20 -21
- package/src/superlocalmemory/server/routes/rbac.py +0 -1
- package/src/superlocalmemory/server/routes/tiers.py +42 -30
- package/src/superlocalmemory/server/routes/v3_api.py +67 -77
- package/src/superlocalmemory/server/unified_daemon.py +283 -39
- package/src/superlocalmemory/server/write_identity.py +22 -4
- package/src/superlocalmemory/storage/database.py +109 -19
- package/src/superlocalmemory/storage/deferred_writes.py +153 -0
- package/src/superlocalmemory/storage/embedding_migrator.py +19 -0
- package/src/superlocalmemory/storage/memory_write.py +119 -0
- package/src/superlocalmemory/storage/migration_runner.py +7 -0
- package/src/superlocalmemory/storage/migrations/M028_fact_entity_associations.py +113 -78
- package/src/superlocalmemory/storage/migrations/M031_dead_letter_operations.py +80 -0
- package/src/superlocalmemory/storage/write_lock.py +88 -0
- 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
|
-
#
|
|
572
|
-
#
|
|
573
|
-
#
|
|
574
|
-
#
|
|
575
|
-
#
|
|
576
|
-
#
|
|
577
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|