superlocalmemory 3.4.10 → 3.4.11
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/README.md +17 -11
- package/docs/skill-evolution.md +77 -10
- package/ide/hooks/tool-event-hook.sh +4 -4
- package/package.json +1 -1
- package/pyproject.toml +3 -2
- package/src/superlocalmemory/cli/commands.py +170 -0
- package/src/superlocalmemory/cli/main.py +21 -0
- package/src/superlocalmemory/cli/setup_wizard.py +54 -11
- package/src/superlocalmemory/core/config.py +35 -0
- package/src/superlocalmemory/core/consolidation_engine.py +128 -0
- package/src/superlocalmemory/core/embedding_worker.py +1 -1
- package/src/superlocalmemory/core/engine.py +12 -0
- package/src/superlocalmemory/core/fact_consolidator.py +425 -0
- package/src/superlocalmemory/core/graph_pruner.py +290 -0
- package/src/superlocalmemory/core/maintenance_scheduler.py +20 -0
- package/src/superlocalmemory/core/recall_pipeline.py +9 -0
- package/src/superlocalmemory/core/tier_manager.py +325 -0
- package/src/superlocalmemory/encoding/entity_resolver.py +6 -5
- package/src/superlocalmemory/evolution/__init__.py +29 -0
- package/src/superlocalmemory/evolution/blind_verifier.py +115 -0
- package/src/superlocalmemory/evolution/evolution_store.py +302 -0
- package/src/superlocalmemory/evolution/mutation_generator.py +181 -0
- package/src/superlocalmemory/evolution/skill_evolver.py +555 -0
- package/src/superlocalmemory/evolution/triggers.py +367 -0
- package/src/superlocalmemory/evolution/types.py +92 -0
- package/src/superlocalmemory/hooks/hook_handlers.py +13 -0
- package/src/superlocalmemory/learning/skill_performance_miner.py +44 -11
- package/src/superlocalmemory/mcp/server.py +4 -0
- package/src/superlocalmemory/mcp/tools_evolution.py +338 -0
- package/src/superlocalmemory/retrieval/engine.py +64 -4
- package/src/superlocalmemory/retrieval/forgetting_filter.py +22 -7
- package/src/superlocalmemory/retrieval/strategy.py +2 -2
- package/src/superlocalmemory/server/routes/behavioral.py +19 -15
- package/src/superlocalmemory/server/routes/evolution.py +213 -0
- package/src/superlocalmemory/server/routes/tiers.py +195 -0
- package/src/superlocalmemory/server/unified_daemon.py +36 -5
- package/src/superlocalmemory/storage/schema_v3411.py +149 -0
- package/src/superlocalmemory/ui/index.html +5 -2
- package/src/superlocalmemory/ui/js/lifecycle.js +83 -0
- package/src/superlocalmemory/ui/js/ng-skills.js +394 -10
- package/src/superlocalmemory.egg-info/PKG-INFO +609 -0
- package/src/superlocalmemory.egg-info/SOURCES.txt +335 -0
- package/src/superlocalmemory.egg-info/dependency_links.txt +1 -0
- package/src/superlocalmemory.egg-info/entry_points.txt +2 -0
- package/src/superlocalmemory.egg-info/requires.txt +55 -0
- package/src/superlocalmemory.egg-info/top_level.txt +1 -0
|
@@ -188,6 +188,27 @@ class ConsolidationEngine:
|
|
|
188
188
|
logger.debug("Skill performance mining (non-fatal): %s", exc)
|
|
189
189
|
results["skill_performance"] = {"error": str(exc)}
|
|
190
190
|
|
|
191
|
+
# Step 11 (v3.4.10): Skill evolution — 3-trigger system.
|
|
192
|
+
# Runs degradation + health check triggers. Post-session
|
|
193
|
+
# trigger runs separately from the Stop hook.
|
|
194
|
+
# Never on recall/remember hot path. Budget: max 3 per cycle.
|
|
195
|
+
try:
|
|
196
|
+
from superlocalmemory.evolution.skill_evolver import SkillEvolver
|
|
197
|
+
evolver = SkillEvolver(self._db.db_path)
|
|
198
|
+
results["skill_evolution"] = evolver.run_consolidation_cycle(profile_id)
|
|
199
|
+
except Exception as exc:
|
|
200
|
+
logger.debug("Skill evolution (non-fatal): %s", exc)
|
|
201
|
+
results["skill_evolution"] = {"error": str(exc)}
|
|
202
|
+
|
|
203
|
+
# Step 12 (v3.4.11): Generate soft prompts for evolved skills.
|
|
204
|
+
# Queries promoted evolutions and creates/updates custom soft
|
|
205
|
+
# prompts so the AI prefers evolved skill variants.
|
|
206
|
+
try:
|
|
207
|
+
results["evolution_soft_prompts"] = self._step12_evolution_soft_prompts(profile_id)
|
|
208
|
+
except Exception as exc:
|
|
209
|
+
logger.debug("Evolution soft prompts (non-fatal): %s", exc)
|
|
210
|
+
results["evolution_soft_prompts"] = {"error": str(exc)}
|
|
211
|
+
|
|
191
212
|
results["success"] = True
|
|
192
213
|
except Exception as exc:
|
|
193
214
|
logger.warning(
|
|
@@ -598,6 +619,113 @@ class ConsolidationEngine:
|
|
|
598
619
|
logger.warning("CCQ step failed (non-fatal): %s", exc)
|
|
599
620
|
return {"error": str(exc)}
|
|
600
621
|
|
|
622
|
+
# ------------------------------------------------------------------
|
|
623
|
+
# Step 12: Evolution Soft Prompts
|
|
624
|
+
# ------------------------------------------------------------------
|
|
625
|
+
|
|
626
|
+
def _step12_evolution_soft_prompts(self, profile_id: str) -> dict[str, Any]:
|
|
627
|
+
"""Generate soft prompts for promoted evolved skills.
|
|
628
|
+
|
|
629
|
+
Queries skill_evolution_log for promoted evolutions and creates
|
|
630
|
+
or updates soft_prompt_templates with category='custom' so the
|
|
631
|
+
AI agent prefers evolved skill variants over originals.
|
|
632
|
+
|
|
633
|
+
Uses 'custom' category because the soft_prompt_templates CHECK
|
|
634
|
+
constraint does not include 'skill_evolution'. The content is
|
|
635
|
+
prefixed with [SKILL_EVOLUTION] for easy filtering.
|
|
636
|
+
"""
|
|
637
|
+
import sqlite3 as _sqlite3
|
|
638
|
+
|
|
639
|
+
db_path = str(self._db.db_path)
|
|
640
|
+
|
|
641
|
+
conn = _sqlite3.connect(db_path, timeout=10)
|
|
642
|
+
conn.row_factory = _sqlite3.Row
|
|
643
|
+
|
|
644
|
+
# Fetch promoted evolutions
|
|
645
|
+
try:
|
|
646
|
+
promoted_rows = conn.execute(
|
|
647
|
+
"SELECT id, skill_name, parent_skill_id, evolution_type, "
|
|
648
|
+
"mutation_summary, created_at "
|
|
649
|
+
"FROM skill_evolution_log "
|
|
650
|
+
"WHERE status = 'promoted' "
|
|
651
|
+
"ORDER BY created_at DESC LIMIT 20",
|
|
652
|
+
).fetchall()
|
|
653
|
+
except _sqlite3.OperationalError:
|
|
654
|
+
# Table may not exist yet
|
|
655
|
+
conn.close()
|
|
656
|
+
return {"created": 0, "message": "skill_evolution_log table not found"}
|
|
657
|
+
|
|
658
|
+
created_count = 0
|
|
659
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
660
|
+
|
|
661
|
+
for row in promoted_rows:
|
|
662
|
+
r = dict(row)
|
|
663
|
+
skill_name = r["skill_name"]
|
|
664
|
+
parent = r.get("parent_skill_id") or skill_name
|
|
665
|
+
evo_type = r["evolution_type"]
|
|
666
|
+
summary = r.get("mutation_summary", "")
|
|
667
|
+
evo_id = r["id"]
|
|
668
|
+
|
|
669
|
+
# Build prompt content
|
|
670
|
+
content = (
|
|
671
|
+
f"[SKILL_EVOLUTION] Evolved skill: '{skill_name}' "
|
|
672
|
+
f"({'replaces' if evo_type == 'fix' else 'extends'} '{parent}' "
|
|
673
|
+
f"via {evo_type}). {summary}. "
|
|
674
|
+
f"Use the evolved version for better results."
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
# Use a deterministic prompt_id based on the evolution record
|
|
678
|
+
prompt_id = f"evo-{evo_id}"
|
|
679
|
+
|
|
680
|
+
# Check if prompt already exists
|
|
681
|
+
existing = conn.execute(
|
|
682
|
+
"SELECT prompt_id FROM soft_prompt_templates WHERE prompt_id = ?",
|
|
683
|
+
(prompt_id,),
|
|
684
|
+
).fetchone()
|
|
685
|
+
|
|
686
|
+
if existing:
|
|
687
|
+
# M-REPLACE: Update existing record instead of INSERT OR REPLACE
|
|
688
|
+
# to avoid silently dropping columns with defaults
|
|
689
|
+
try:
|
|
690
|
+
conn.execute(
|
|
691
|
+
"UPDATE soft_prompt_templates "
|
|
692
|
+
"SET content = ?, updated_at = ? "
|
|
693
|
+
"WHERE prompt_id = ?",
|
|
694
|
+
(content, now, prompt_id),
|
|
695
|
+
)
|
|
696
|
+
except _sqlite3.OperationalError as upd_exc:
|
|
697
|
+
logger.debug("Failed to update soft prompt %s: %s", prompt_id, upd_exc)
|
|
698
|
+
continue
|
|
699
|
+
|
|
700
|
+
try:
|
|
701
|
+
conn.execute(
|
|
702
|
+
"INSERT OR IGNORE INTO soft_prompt_templates "
|
|
703
|
+
"(prompt_id, profile_id, category, content, source_pattern_ids, "
|
|
704
|
+
" confidence, effectiveness, token_count, retention_score, "
|
|
705
|
+
" active, version, created_at, updated_at) "
|
|
706
|
+
"VALUES (?, ?, 'custom', ?, ?, 0.8, 0.5, ?, 1.0, 1, 1, ?, ?)",
|
|
707
|
+
(prompt_id, profile_id, content,
|
|
708
|
+
json.dumps([evo_id]),
|
|
709
|
+
len(content.split()), # Rough token estimate
|
|
710
|
+
now, now),
|
|
711
|
+
)
|
|
712
|
+
if conn.total_changes:
|
|
713
|
+
created_count += 1
|
|
714
|
+
except _sqlite3.IntegrityError:
|
|
715
|
+
# Unique constraint on (profile_id, category) WHERE active=1
|
|
716
|
+
logger.debug(
|
|
717
|
+
"Skipping soft prompt for %s: unique constraint on active custom",
|
|
718
|
+
skill_name,
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
conn.commit()
|
|
722
|
+
conn.close()
|
|
723
|
+
|
|
724
|
+
return {
|
|
725
|
+
"promoted_skills_found": len(promoted_rows),
|
|
726
|
+
"soft_prompts_created": created_count,
|
|
727
|
+
}
|
|
728
|
+
|
|
601
729
|
# ------------------------------------------------------------------
|
|
602
730
|
# Core Memory Block Storage
|
|
603
731
|
# ------------------------------------------------------------------
|
|
@@ -170,7 +170,7 @@ def _worker_main() -> None:
|
|
|
170
170
|
# a fresh worker on next request (existing mechanism in embeddings.py).
|
|
171
171
|
# V3.3.21: Configurable via SLM_EMBED_WORKER_RSS_LIMIT_MB (default 2500MB).
|
|
172
172
|
import resource
|
|
173
|
-
_rss_limit = int(os.environ.get("SLM_EMBED_WORKER_RSS_LIMIT_MB",
|
|
173
|
+
_rss_limit = int(os.environ.get("SLM_EMBED_WORKER_RSS_LIMIT_MB", 4000))
|
|
174
174
|
rss_mb = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024 / 1024
|
|
175
175
|
if rss_mb > _rss_limit:
|
|
176
176
|
sys.exit(0)
|
|
@@ -146,6 +146,13 @@ class MemoryEngine:
|
|
|
146
146
|
except Exception as exc:
|
|
147
147
|
logger.debug("V3.4.10 schema migration: %s", exc)
|
|
148
148
|
|
|
149
|
+
# V3.4.11: Apply "Scale-Ready" schema (pinned_facts, backend_status, fact_consolidations)
|
|
150
|
+
try:
|
|
151
|
+
from superlocalmemory.storage.schema_v3411 import apply_v3411_schema
|
|
152
|
+
apply_v3411_schema(str(self._db.db_path))
|
|
153
|
+
except Exception as exc:
|
|
154
|
+
logger.debug("V3.4.11 schema migration: %s", exc)
|
|
155
|
+
|
|
149
156
|
self._embedder = init_embedder(self._config)
|
|
150
157
|
|
|
151
158
|
if self._caps.llm_fact_extraction:
|
|
@@ -384,6 +391,11 @@ class MemoryEngine:
|
|
|
384
391
|
def close(self) -> None:
|
|
385
392
|
if self._maintenance_scheduler is not None:
|
|
386
393
|
self._maintenance_scheduler.stop()
|
|
394
|
+
if self._db is not None:
|
|
395
|
+
try:
|
|
396
|
+
self._db.close()
|
|
397
|
+
except Exception:
|
|
398
|
+
pass
|
|
387
399
|
self._initialized = False
|
|
388
400
|
|
|
389
401
|
@property
|
|
@@ -0,0 +1,425 @@
|
|
|
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
|
+
"""SuperLocalMemory V3.4.11 "Scale-Ready" — Fact Consolidation Engine.
|
|
6
|
+
|
|
7
|
+
Merges clusters of related facts about the same entity into single
|
|
8
|
+
comprehensive summary facts. Original facts move to 'archived' tier
|
|
9
|
+
but are NEVER deleted — searchable via deep recall.
|
|
10
|
+
|
|
11
|
+
Uses Mode B (Ollama LLM) for summarization, with Mode A (extractive)
|
|
12
|
+
fallback if LLM is unavailable.
|
|
13
|
+
|
|
14
|
+
CRITICAL RULES:
|
|
15
|
+
1. NEVER delete original facts
|
|
16
|
+
2. Original facts → lifecycle='archived' (not deleted)
|
|
17
|
+
3. Consolidated fact links back to originals via fact_consolidations table
|
|
18
|
+
4. Only consolidates facts that are already 'warm' or 'cold' tier
|
|
19
|
+
5. Never touches 'active' or 'pinned' facts
|
|
20
|
+
6. All writes per cluster wrapped in SAVEPOINT for atomicity
|
|
21
|
+
7. Entity ID LIKE patterns use JSON-boundary quoting to prevent
|
|
22
|
+
substring false positives
|
|
23
|
+
|
|
24
|
+
Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import logging
|
|
31
|
+
import sqlite3
|
|
32
|
+
import uuid
|
|
33
|
+
from datetime import datetime, timezone
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger("superlocalmemory.fact_consolidator")
|
|
37
|
+
|
|
38
|
+
_MAX_CLUSTER_SIZE = 10 # Max facts to merge into one
|
|
39
|
+
_MIN_CLUSTER_SIZE = 3 # Need at least 3 related facts to consolidate
|
|
40
|
+
_MAX_CONSOLIDATED_CHARS = 2000
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def consolidate_facts(
|
|
44
|
+
db_path: str | Path,
|
|
45
|
+
profile_id: str = "default",
|
|
46
|
+
max_clusters: int = 20,
|
|
47
|
+
dry_run: bool = False,
|
|
48
|
+
config: object | None = None,
|
|
49
|
+
) -> dict:
|
|
50
|
+
"""Find and consolidate clusters of related facts.
|
|
51
|
+
|
|
52
|
+
Mode behavior:
|
|
53
|
+
- Mode A: Extractive only (no LLM). Always available.
|
|
54
|
+
- Mode B: Ollama LLM summarization. Falls back to extractive if Ollama down.
|
|
55
|
+
- Mode C: Cloud LLM (user's configured provider). Falls back to extractive.
|
|
56
|
+
|
|
57
|
+
Returns stats: consolidated, clusters_found, facts_archived, errors.
|
|
58
|
+
"""
|
|
59
|
+
stats = {
|
|
60
|
+
"clusters_found": 0,
|
|
61
|
+
"consolidated": 0,
|
|
62
|
+
"facts_archived": 0,
|
|
63
|
+
"errors": 0,
|
|
64
|
+
"error_detail": "",
|
|
65
|
+
"mode": "a",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if config:
|
|
69
|
+
mode = getattr(config, 'mode', None)
|
|
70
|
+
if mode:
|
|
71
|
+
mode_str = getattr(mode, 'value', str(mode)).lower()
|
|
72
|
+
stats["mode"] = mode_str
|
|
73
|
+
|
|
74
|
+
conn = sqlite3.connect(str(db_path))
|
|
75
|
+
wal_mode = conn.execute("PRAGMA journal_mode=WAL").fetchone()
|
|
76
|
+
if wal_mode and wal_mode[0] != "wal":
|
|
77
|
+
logger.warning("WAL mode not active, got: %s", wal_mode[0])
|
|
78
|
+
conn.execute("PRAGMA busy_timeout=10000")
|
|
79
|
+
conn.row_factory = sqlite3.Row
|
|
80
|
+
|
|
81
|
+
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
|
+
|
|
101
|
+
if not dry_run:
|
|
102
|
+
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
|
+
except Exception as exc:
|
|
110
|
+
logger.error("Fact consolidation failed: %s", exc, exc_info=True)
|
|
111
|
+
stats["errors"] += 1
|
|
112
|
+
stats["error_detail"] = str(exc)
|
|
113
|
+
finally:
|
|
114
|
+
conn.close()
|
|
115
|
+
|
|
116
|
+
return stats
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _find_consolidation_clusters(
|
|
120
|
+
conn: sqlite3.Connection,
|
|
121
|
+
profile_id: str,
|
|
122
|
+
max_clusters: int,
|
|
123
|
+
) -> list[tuple[str, str, list[str]]]:
|
|
124
|
+
"""Find entities with clusters of warm/cold facts ready for consolidation.
|
|
125
|
+
|
|
126
|
+
Uses JSON-boundary quoting on entity_id to prevent substring false positives.
|
|
127
|
+
Both outer count and inner fact query are scoped to profile_id.
|
|
128
|
+
"""
|
|
129
|
+
c = conn.cursor()
|
|
130
|
+
|
|
131
|
+
# Find entities with many non-active, non-pinned facts
|
|
132
|
+
# Uses '%" entity_id "%' pattern for JSON boundary matching
|
|
133
|
+
entities = c.execute("""
|
|
134
|
+
SELECT ce.entity_id, ce.canonical_name, COUNT(af.fact_id) as fact_count
|
|
135
|
+
FROM canonical_entities ce
|
|
136
|
+
JOIN atomic_facts af
|
|
137
|
+
ON af.canonical_entities_json LIKE '%"' || ce.entity_id || '"%'
|
|
138
|
+
AND af.profile_id = ?
|
|
139
|
+
WHERE ce.profile_id = ?
|
|
140
|
+
AND af.lifecycle IN ('warm', 'cold')
|
|
141
|
+
AND af.fact_id NOT IN (
|
|
142
|
+
SELECT fact_id FROM pinned_facts WHERE profile_id = ?
|
|
143
|
+
)
|
|
144
|
+
GROUP BY ce.entity_id
|
|
145
|
+
HAVING COUNT(af.fact_id) >= ?
|
|
146
|
+
ORDER BY COUNT(af.fact_id) DESC
|
|
147
|
+
LIMIT ?
|
|
148
|
+
""", (profile_id, profile_id, profile_id, _MIN_CLUSTER_SIZE,
|
|
149
|
+
max_clusters)).fetchall()
|
|
150
|
+
|
|
151
|
+
clusters = []
|
|
152
|
+
for entity in entities:
|
|
153
|
+
eid = entity["entity_id"]
|
|
154
|
+
facts = c.execute("""
|
|
155
|
+
SELECT af.fact_id FROM atomic_facts af
|
|
156
|
+
WHERE af.canonical_entities_json LIKE ?
|
|
157
|
+
AND af.profile_id = ?
|
|
158
|
+
AND af.lifecycle IN ('warm', 'cold')
|
|
159
|
+
AND af.fact_id NOT IN (
|
|
160
|
+
SELECT fact_id FROM pinned_facts WHERE profile_id = ?
|
|
161
|
+
)
|
|
162
|
+
ORDER BY af.confidence DESC, af.created_at DESC
|
|
163
|
+
LIMIT ?
|
|
164
|
+
""", (f'%"{eid}"%', profile_id, profile_id,
|
|
165
|
+
_MAX_CLUSTER_SIZE)).fetchall()
|
|
166
|
+
|
|
167
|
+
fact_ids = [f["fact_id"] for f in facts]
|
|
168
|
+
if len(fact_ids) >= _MIN_CLUSTER_SIZE:
|
|
169
|
+
clusters.append((eid, entity["canonical_name"], fact_ids))
|
|
170
|
+
|
|
171
|
+
return clusters
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _consolidate_cluster(
|
|
175
|
+
conn: sqlite3.Connection,
|
|
176
|
+
profile_id: str,
|
|
177
|
+
entity_id: str,
|
|
178
|
+
entity_name: str,
|
|
179
|
+
fact_ids: list[str],
|
|
180
|
+
dry_run: bool,
|
|
181
|
+
config: object | None = None,
|
|
182
|
+
) -> dict | None:
|
|
183
|
+
"""Merge a cluster of facts into one consolidated fact.
|
|
184
|
+
|
|
185
|
+
All writes are wrapped in a SAVEPOINT for atomicity — if any step fails,
|
|
186
|
+
the entire cluster consolidation is rolled back.
|
|
187
|
+
"""
|
|
188
|
+
c = conn.cursor()
|
|
189
|
+
|
|
190
|
+
# Load fact contents including canonical_entities_json
|
|
191
|
+
placeholders = ",".join("?" * len(fact_ids))
|
|
192
|
+
facts = c.execute(
|
|
193
|
+
f"SELECT fact_id, content, confidence, created_at, canonical_entities_json "
|
|
194
|
+
f"FROM atomic_facts "
|
|
195
|
+
f"WHERE fact_id IN ({placeholders}) ORDER BY created_at",
|
|
196
|
+
fact_ids,
|
|
197
|
+
).fetchall()
|
|
198
|
+
|
|
199
|
+
if len(facts) < _MIN_CLUSTER_SIZE:
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
summary = _generate_summary(entity_name, facts, config)
|
|
203
|
+
if not summary:
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
if dry_run:
|
|
207
|
+
return {"entity": entity_name, "facts": len(facts), "summary_len": len(summary)}
|
|
208
|
+
|
|
209
|
+
# Use SAVEPOINT for atomic multi-step write
|
|
210
|
+
savepoint_name = f"consolidate_{uuid.uuid4().hex[:8]}"
|
|
211
|
+
c.execute(f"SAVEPOINT {savepoint_name}")
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
new_fact_id = uuid.uuid4().hex[:16]
|
|
215
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
216
|
+
avg_confidence = sum(f["confidence"] or 0.5 for f in facts) / len(facts)
|
|
217
|
+
|
|
218
|
+
# Collect entities from ALL source facts (already in the SELECT)
|
|
219
|
+
all_entities = set()
|
|
220
|
+
raw_entities = set()
|
|
221
|
+
for f in facts:
|
|
222
|
+
cej = f["canonical_entities_json"]
|
|
223
|
+
if cej:
|
|
224
|
+
try:
|
|
225
|
+
all_entities.update(json.loads(cej))
|
|
226
|
+
except (json.JSONDecodeError, TypeError):
|
|
227
|
+
pass
|
|
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
|
+
))
|
|
242
|
+
|
|
243
|
+
# Record the consolidation
|
|
244
|
+
consolidation_id = uuid.uuid4().hex[:16]
|
|
245
|
+
c.execute("""
|
|
246
|
+
INSERT INTO fact_consolidations
|
|
247
|
+
(consolidation_id, profile_id, consolidated_fact_id,
|
|
248
|
+
source_fact_ids, strategy, created_at)
|
|
249
|
+
VALUES (?, ?, ?, ?, 'entity_cluster', ?)
|
|
250
|
+
""", (consolidation_id, profile_id, new_fact_id,
|
|
251
|
+
json.dumps(fact_ids), now))
|
|
252
|
+
|
|
253
|
+
# Archive the original facts (NEVER delete) — scoped to profile_id
|
|
254
|
+
c.execute(
|
|
255
|
+
f"UPDATE atomic_facts SET lifecycle = 'archived' "
|
|
256
|
+
f"WHERE fact_id IN ({placeholders}) AND profile_id = ?",
|
|
257
|
+
(*fact_ids, profile_id),
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
c.execute(f"RELEASE SAVEPOINT {savepoint_name}")
|
|
261
|
+
|
|
262
|
+
except Exception:
|
|
263
|
+
c.execute(f"ROLLBACK TO SAVEPOINT {savepoint_name}")
|
|
264
|
+
raise
|
|
265
|
+
|
|
266
|
+
logger.info(
|
|
267
|
+
"Consolidated %d facts about '%s' → %s (%d chars)",
|
|
268
|
+
len(facts), entity_name, new_fact_id[:8], len(summary),
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
return {"entity": entity_name, "facts": len(facts), "new_fact_id": new_fact_id}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _generate_summary(
|
|
275
|
+
entity_name: str,
|
|
276
|
+
facts: list,
|
|
277
|
+
config: object | None = None,
|
|
278
|
+
) -> str | None:
|
|
279
|
+
"""Generate a consolidated summary based on the user's configured mode.
|
|
280
|
+
|
|
281
|
+
All modes cap output at _MAX_CONSOLIDATED_CHARS.
|
|
282
|
+
"""
|
|
283
|
+
mode = "a"
|
|
284
|
+
if config:
|
|
285
|
+
m = getattr(config, 'mode', None)
|
|
286
|
+
if m:
|
|
287
|
+
mode = getattr(m, 'value', str(m)).lower()
|
|
288
|
+
|
|
289
|
+
result = None
|
|
290
|
+
|
|
291
|
+
if mode == "a":
|
|
292
|
+
result = _summarize_extractive(entity_name, facts)
|
|
293
|
+
elif mode == "b":
|
|
294
|
+
result = _summarize_with_ollama(entity_name, facts, config)
|
|
295
|
+
if not result:
|
|
296
|
+
result = _summarize_extractive(entity_name, facts)
|
|
297
|
+
elif mode == "c":
|
|
298
|
+
result = _summarize_with_cloud_llm(entity_name, facts, config)
|
|
299
|
+
if not result:
|
|
300
|
+
result = _summarize_with_ollama(entity_name, facts, config)
|
|
301
|
+
if not result:
|
|
302
|
+
result = _summarize_extractive(entity_name, facts)
|
|
303
|
+
else:
|
|
304
|
+
result = _summarize_extractive(entity_name, facts)
|
|
305
|
+
|
|
306
|
+
# Uniform cap across all modes
|
|
307
|
+
if result and len(result) > _MAX_CONSOLIDATED_CHARS:
|
|
308
|
+
result = result[:_MAX_CONSOLIDATED_CHARS - 3] + "..."
|
|
309
|
+
|
|
310
|
+
return result
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _summarize_with_ollama(
|
|
314
|
+
entity_name: str,
|
|
315
|
+
facts: list,
|
|
316
|
+
config: object | None = None,
|
|
317
|
+
) -> str | None:
|
|
318
|
+
"""Mode B: Summarize using local Ollama LLM."""
|
|
319
|
+
try:
|
|
320
|
+
import urllib.request
|
|
321
|
+
|
|
322
|
+
api_base = "http://localhost:11434"
|
|
323
|
+
model = "llama3.2"
|
|
324
|
+
timeout = 30
|
|
325
|
+
|
|
326
|
+
if config and hasattr(config, 'llm'):
|
|
327
|
+
api_base = getattr(config.llm, 'api_base', api_base) or api_base
|
|
328
|
+
model = getattr(config.llm, 'model', model) or model
|
|
329
|
+
timeout = getattr(config.llm, 'timeout', timeout) or timeout
|
|
330
|
+
|
|
331
|
+
fact_texts = "\n".join(f"- {f['content']}" for f in facts[:_MAX_CLUSTER_SIZE])
|
|
332
|
+
prompt = (
|
|
333
|
+
f"Merge these {len(facts)} facts about '{entity_name}' into ONE concise "
|
|
334
|
+
f"summary paragraph. Keep all key information. Maximum 500 words. "
|
|
335
|
+
f"No preamble.\n\nFacts:\n{fact_texts}"
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
payload = json.dumps({
|
|
339
|
+
"model": model,
|
|
340
|
+
"prompt": prompt,
|
|
341
|
+
"stream": False,
|
|
342
|
+
"options": {"num_predict": 600},
|
|
343
|
+
}).encode()
|
|
344
|
+
|
|
345
|
+
req = urllib.request.Request(
|
|
346
|
+
f"{api_base}/api/generate",
|
|
347
|
+
data=payload,
|
|
348
|
+
headers={"Content-Type": "application/json"},
|
|
349
|
+
)
|
|
350
|
+
resp = urllib.request.urlopen(req, timeout=timeout)
|
|
351
|
+
result = json.loads(resp.read().decode())
|
|
352
|
+
text = result.get("response", "").strip()
|
|
353
|
+
return text if text and len(text) > 50 else None
|
|
354
|
+
except Exception as exc:
|
|
355
|
+
logger.warning("Ollama summarization failed: %s", exc)
|
|
356
|
+
return None
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _summarize_with_cloud_llm(
|
|
360
|
+
entity_name: str,
|
|
361
|
+
facts: list,
|
|
362
|
+
config: object | None = None,
|
|
363
|
+
) -> str | None:
|
|
364
|
+
"""Mode C: Summarize using the user's configured cloud LLM provider."""
|
|
365
|
+
if not config or not hasattr(config, 'llm'):
|
|
366
|
+
return None
|
|
367
|
+
|
|
368
|
+
llm_config = config.llm
|
|
369
|
+
provider = getattr(llm_config, 'provider', '')
|
|
370
|
+
if not provider:
|
|
371
|
+
return None
|
|
372
|
+
|
|
373
|
+
try:
|
|
374
|
+
from superlocalmemory.llm.backbone import LLMBackbone
|
|
375
|
+
llm = LLMBackbone(llm_config)
|
|
376
|
+
if not llm.is_available():
|
|
377
|
+
return None
|
|
378
|
+
|
|
379
|
+
fact_texts = "\n".join(f"- {f['content']}" for f in facts[:_MAX_CLUSTER_SIZE])
|
|
380
|
+
prompt = (
|
|
381
|
+
f"Merge these {len(facts)} facts about '{entity_name}' into ONE concise "
|
|
382
|
+
f"summary paragraph. Keep all key information. Maximum 500 words. "
|
|
383
|
+
f"No preamble.\n\nFacts:\n{fact_texts}"
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
response = llm.generate(
|
|
387
|
+
prompt=prompt,
|
|
388
|
+
system="You are a precise fact summarizer. Output only the merged summary.",
|
|
389
|
+
max_tokens=600,
|
|
390
|
+
temperature=0.1,
|
|
391
|
+
)
|
|
392
|
+
text = response.strip() if response else None
|
|
393
|
+
return text if text and len(text) > 50 else None
|
|
394
|
+
except Exception as exc:
|
|
395
|
+
logger.warning("Cloud LLM summarization failed: %s", exc)
|
|
396
|
+
return None
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _summarize_extractive(entity_name: str, facts: list) -> str:
|
|
400
|
+
"""Extractive summary — all sentences from all facts, deduped.
|
|
401
|
+
|
|
402
|
+
Includes ALL sentences from each fact (not just the first one)
|
|
403
|
+
to preserve complete information.
|
|
404
|
+
"""
|
|
405
|
+
header = f"{entity_name}: "
|
|
406
|
+
seen = set()
|
|
407
|
+
sentences = []
|
|
408
|
+
|
|
409
|
+
for f in facts:
|
|
410
|
+
content = f["content"]
|
|
411
|
+
# Split on sentence boundaries and include ALL sentences
|
|
412
|
+
raw_sentences = [s.strip() for s in content.split(". ") if s.strip()]
|
|
413
|
+
for sent in raw_sentences:
|
|
414
|
+
if not sent.endswith("."):
|
|
415
|
+
sent += "."
|
|
416
|
+
normalized = sent.lower()
|
|
417
|
+
if normalized not in seen:
|
|
418
|
+
seen.add(normalized)
|
|
419
|
+
sentences.append(sent)
|
|
420
|
+
|
|
421
|
+
body = " ".join(sentences)
|
|
422
|
+
result = header + body
|
|
423
|
+
if len(result) > _MAX_CONSOLIDATED_CHARS:
|
|
424
|
+
result = result[:_MAX_CONSOLIDATED_CHARS - 3] + "..."
|
|
425
|
+
return result
|