superlocalmemory 3.2.1 → 3.2.2
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 +23 -1
- package/README.md +61 -1
- package/package.json +1 -1
- package/pyproject.toml +26 -1
- package/src/superlocalmemory/attribution/signer.py +6 -1
- package/src/superlocalmemory/core/config.py +114 -1
- package/src/superlocalmemory/core/consolidation_engine.py +595 -0
- package/src/superlocalmemory/core/embeddings.py +0 -1
- package/src/superlocalmemory/core/engine.py +164 -674
- package/src/superlocalmemory/core/engine_wiring.py +474 -0
- package/src/superlocalmemory/core/graph_analyzer.py +199 -0
- package/src/superlocalmemory/core/recall_pipeline.py +247 -0
- package/src/superlocalmemory/core/store_pipeline.py +483 -0
- package/src/superlocalmemory/core/worker_pool.py +35 -12
- package/src/superlocalmemory/encoding/auto_linker.py +308 -0
- package/src/superlocalmemory/encoding/context_generator.py +175 -0
- package/src/superlocalmemory/encoding/temporal_validator.py +513 -0
- package/src/superlocalmemory/hooks/auto_invoker.py +484 -0
- package/src/superlocalmemory/retrieval/channel_registry.py +154 -0
- package/src/superlocalmemory/retrieval/engine.py +12 -0
- package/src/superlocalmemory/retrieval/semantic_channel.py +87 -3
- package/src/superlocalmemory/retrieval/spreading_activation.py +311 -0
- package/src/superlocalmemory/retrieval/strategy.py +6 -6
- package/src/superlocalmemory/retrieval/vector_store.py +386 -0
- package/src/superlocalmemory/server/routes/v3_api.py +576 -0
- package/src/superlocalmemory/storage/access_log.py +169 -0
- package/src/superlocalmemory/storage/database.py +288 -0
- package/src/superlocalmemory/storage/schema.py +10 -0
- package/src/superlocalmemory/storage/schema_v32.py +252 -0
- package/src/superlocalmemory/storage/v2_migrator.py +24 -2
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
# Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
|
|
2
|
+
# Licensed under the MIT License - see LICENSE file
|
|
3
|
+
# Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
|
|
4
|
+
|
|
5
|
+
"""Sleep-time consolidation engine (Phase 5).
|
|
6
|
+
|
|
7
|
+
Letta-inspired 6-step consolidation cycle:
|
|
8
|
+
1. Compress — deduplicate near-identical facts
|
|
9
|
+
2. Compile Core Memory blocks — rules (Mode A) or LLM (Mode B/C)
|
|
10
|
+
3. Promote — move frequently accessed facts up lifecycle
|
|
11
|
+
4. Decay — reduce weights on unused association edges
|
|
12
|
+
5. Recompute graph — PageRank + communities
|
|
13
|
+
6. Derive associations — link new summary facts
|
|
14
|
+
|
|
15
|
+
Guarantees:
|
|
16
|
+
- Idempotent: running twice produces identical state (L18)
|
|
17
|
+
- Never deletes facts (Rule 17)
|
|
18
|
+
- No import of core/engine.py (Rule 06)
|
|
19
|
+
- Silent errors (Rule 19)
|
|
20
|
+
- Parameterized SQL (Rule 11)
|
|
21
|
+
- Feature-flagged via enabled=False (Rule 12)
|
|
22
|
+
|
|
23
|
+
Part of Qualixar | Author: Varun Pratap Bhardwaj
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import logging
|
|
30
|
+
from datetime import datetime, timezone
|
|
31
|
+
from typing import TYPE_CHECKING, Any
|
|
32
|
+
|
|
33
|
+
if TYPE_CHECKING:
|
|
34
|
+
from superlocalmemory.core.config import ConsolidationConfig, SLMConfig
|
|
35
|
+
from superlocalmemory.core.graph_analyzer import GraphAnalyzer
|
|
36
|
+
from superlocalmemory.core.summarizer import Summarizer
|
|
37
|
+
from superlocalmemory.encoding.auto_linker import AutoLinker
|
|
38
|
+
from superlocalmemory.encoding.temporal_validator import TemporalValidator
|
|
39
|
+
from superlocalmemory.learning.behavioral import (
|
|
40
|
+
BehavioralPatternStore,
|
|
41
|
+
BehavioralTracker,
|
|
42
|
+
)
|
|
43
|
+
from superlocalmemory.storage.database import DatabaseManager
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger(__name__)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ConsolidationEngine:
|
|
49
|
+
"""Sleep-time memory consolidation with 6-step cycle.
|
|
50
|
+
|
|
51
|
+
The biological metaphor: during sleep, the brain replays recent
|
|
52
|
+
experiences, compresses them into long-term memory, strengthens
|
|
53
|
+
important connections, and prunes weak ones. SLM does the same.
|
|
54
|
+
|
|
55
|
+
Consolidation is IDEMPOTENT: running twice produces identical state (L18).
|
|
56
|
+
This is guaranteed because:
|
|
57
|
+
- Block compilation uses INSERT OR REPLACE on UNIQUE(profile_id, block_type)
|
|
58
|
+
- Promotion checks current state before updating
|
|
59
|
+
- PageRank is deterministic given the same graph
|
|
60
|
+
- Edge decay is monotonic (weight only decreases)
|
|
61
|
+
|
|
62
|
+
Never overwrites or deletes facts (Rule 17).
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
db: DatabaseManager,
|
|
68
|
+
config: ConsolidationConfig,
|
|
69
|
+
summarizer: Summarizer | None = None,
|
|
70
|
+
behavioral_store: BehavioralPatternStore | BehavioralTracker | None = None,
|
|
71
|
+
auto_linker: AutoLinker | None = None,
|
|
72
|
+
graph_analyzer: GraphAnalyzer | None = None,
|
|
73
|
+
temporal_validator: TemporalValidator | None = None,
|
|
74
|
+
slm_config: SLMConfig | None = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
self._db = db
|
|
77
|
+
self._config = config
|
|
78
|
+
self._summarizer = summarizer
|
|
79
|
+
self._behavioral = behavioral_store
|
|
80
|
+
self._auto_linker = auto_linker
|
|
81
|
+
self._graph_analyzer = graph_analyzer
|
|
82
|
+
self._temporal_validator = temporal_validator
|
|
83
|
+
self._slm_config = slm_config
|
|
84
|
+
self._mode = slm_config.mode.value if slm_config else "a"
|
|
85
|
+
self._store_count: int = 0 # For step-count trigger (L7)
|
|
86
|
+
|
|
87
|
+
# ------------------------------------------------------------------
|
|
88
|
+
# Public API
|
|
89
|
+
# ------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
def consolidate(
|
|
92
|
+
self, profile_id: str, lightweight: bool = False,
|
|
93
|
+
) -> dict[str, Any]:
|
|
94
|
+
"""Execute the consolidation cycle.
|
|
95
|
+
|
|
96
|
+
Full cycle (session end, manual, scheduled):
|
|
97
|
+
Steps 1-6 (compress, compile, promote, decay, recompute, derive)
|
|
98
|
+
|
|
99
|
+
Lightweight cycle (step-count trigger every 50 stores):
|
|
100
|
+
Steps 2 + 4 only (refresh blocks + decay edges)
|
|
101
|
+
|
|
102
|
+
Returns dict with step results for dashboard display.
|
|
103
|
+
"""
|
|
104
|
+
results: dict[str, Any] = {
|
|
105
|
+
"profile_id": profile_id,
|
|
106
|
+
"lightweight": lightweight,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
if lightweight:
|
|
111
|
+
results["blocks"] = self._step2_compile_blocks(profile_id)
|
|
112
|
+
results["decayed"] = self._step4_decay_edges(profile_id)
|
|
113
|
+
else:
|
|
114
|
+
results["compressed"] = self._step1_compress(profile_id)
|
|
115
|
+
results["blocks"] = self._step2_compile_blocks(profile_id)
|
|
116
|
+
results["promoted"] = self._step3_promote(profile_id)
|
|
117
|
+
results["decayed"] = self._step4_decay_edges(profile_id)
|
|
118
|
+
results["graph_stats"] = self._step5_recompute_graph(profile_id)
|
|
119
|
+
results["new_associations"] = self._step6_derive_associations(
|
|
120
|
+
profile_id,
|
|
121
|
+
)
|
|
122
|
+
results["success"] = True
|
|
123
|
+
except Exception as exc:
|
|
124
|
+
logger.warning(
|
|
125
|
+
"Consolidation failed (non-fatal) for profile %s: %s",
|
|
126
|
+
profile_id, exc,
|
|
127
|
+
)
|
|
128
|
+
results["success"] = False
|
|
129
|
+
results["error"] = str(exc)
|
|
130
|
+
|
|
131
|
+
return results
|
|
132
|
+
|
|
133
|
+
def increment_store_count(self, profile_id: str) -> bool:
|
|
134
|
+
"""Called after each store() in store_pipeline.py.
|
|
135
|
+
|
|
136
|
+
Increments internal counter. When counter hits step_count_trigger
|
|
137
|
+
(default 50), runs lightweight consolidation.
|
|
138
|
+
|
|
139
|
+
Returns True if lightweight consolidation was triggered.
|
|
140
|
+
"""
|
|
141
|
+
if not self._config.enabled:
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
self._store_count += 1
|
|
145
|
+
if self._store_count >= self._config.step_count_trigger:
|
|
146
|
+
self._store_count = 0
|
|
147
|
+
self.consolidate(profile_id, lightweight=True)
|
|
148
|
+
return True
|
|
149
|
+
return False
|
|
150
|
+
|
|
151
|
+
def get_core_memory(self, profile_id: str) -> dict[str, str]:
|
|
152
|
+
"""Load all Core Memory blocks for a profile.
|
|
153
|
+
|
|
154
|
+
Returns dict of {block_type: content}.
|
|
155
|
+
Called at session_init to inject into context.
|
|
156
|
+
"""
|
|
157
|
+
rows = self._db.get_core_blocks(profile_id)
|
|
158
|
+
return {r["block_type"]: r["content"] for r in rows}
|
|
159
|
+
|
|
160
|
+
def get_core_memory_blocks(self, profile_id: str) -> list[dict]:
|
|
161
|
+
"""Load all Core Memory blocks with full metadata. For API."""
|
|
162
|
+
return self._db.get_core_blocks(profile_id)
|
|
163
|
+
|
|
164
|
+
# ------------------------------------------------------------------
|
|
165
|
+
# Step 1: Compress — deduplicate near-identical facts
|
|
166
|
+
# ------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
def _step1_compress(self, profile_id: str) -> dict[str, Any]:
|
|
169
|
+
"""Deduplicate near-identical facts by archiving originals.
|
|
170
|
+
|
|
171
|
+
Never deletes facts (Rule 17). Sets lifecycle to 'archived'.
|
|
172
|
+
In Mode A, compression is a no-op (no VectorStore for similarity).
|
|
173
|
+
"""
|
|
174
|
+
# Mode A: heuristic compression is a stub — requires VectorStore
|
|
175
|
+
# for similarity search which is optional. Return zero counts.
|
|
176
|
+
return {
|
|
177
|
+
"clusters_found": 0,
|
|
178
|
+
"facts_compressed": 0,
|
|
179
|
+
"summaries_created": 0,
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
# ------------------------------------------------------------------
|
|
183
|
+
# Step 2: Compile Core Memory Blocks
|
|
184
|
+
# ------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
def _step2_compile_blocks(self, profile_id: str) -> dict[str, Any]:
|
|
187
|
+
"""Compile Core Memory blocks based on mode.
|
|
188
|
+
|
|
189
|
+
Mode A: rules-based (no LLM) (L3 fix)
|
|
190
|
+
Mode B/C: LLM-assisted summarization
|
|
191
|
+
"""
|
|
192
|
+
if self._mode == "a":
|
|
193
|
+
return self.compile_core_blocks_mode_a(profile_id)
|
|
194
|
+
return self._compile_core_blocks_llm(profile_id)
|
|
195
|
+
|
|
196
|
+
def compile_core_blocks_mode_a(self, profile_id: str) -> dict[str, Any]:
|
|
197
|
+
"""Mode A: populate Core Memory blocks without LLM (L3 fix).
|
|
198
|
+
|
|
199
|
+
Rules-based compilation:
|
|
200
|
+
- user_profile: top-5 semantic/opinion facts by access_count
|
|
201
|
+
- project_context: top-5 episodic facts by recency
|
|
202
|
+
- behavioral_patterns: top-5 patterns by confidence
|
|
203
|
+
- active_decisions: facts with signal_type='decision', min access
|
|
204
|
+
- learned_preferences: opinion facts with confidence >= threshold
|
|
205
|
+
"""
|
|
206
|
+
blocks_compiled = 0
|
|
207
|
+
block_limit = self._config.block_char_limit
|
|
208
|
+
|
|
209
|
+
# 1. user_profile: top semantic/opinion facts by access
|
|
210
|
+
user_facts = self._get_top_facts(
|
|
211
|
+
profile_id,
|
|
212
|
+
fact_types=["semantic", "opinion"],
|
|
213
|
+
sort_by="access_count",
|
|
214
|
+
limit=5,
|
|
215
|
+
)
|
|
216
|
+
self._store_core_block(
|
|
217
|
+
profile_id, "user_profile",
|
|
218
|
+
self._facts_to_content(user_facts, block_limit),
|
|
219
|
+
[f["fact_id"] for f in user_facts],
|
|
220
|
+
)
|
|
221
|
+
blocks_compiled += 1
|
|
222
|
+
|
|
223
|
+
# 2. project_context: top episodic facts by recency
|
|
224
|
+
project_facts = self._get_top_facts(
|
|
225
|
+
profile_id,
|
|
226
|
+
fact_types=["episodic"],
|
|
227
|
+
sort_by="recency",
|
|
228
|
+
limit=5,
|
|
229
|
+
)
|
|
230
|
+
self._store_core_block(
|
|
231
|
+
profile_id, "project_context",
|
|
232
|
+
self._facts_to_content(project_facts, block_limit),
|
|
233
|
+
[f["fact_id"] for f in project_facts],
|
|
234
|
+
)
|
|
235
|
+
blocks_compiled += 1
|
|
236
|
+
|
|
237
|
+
# 3. behavioral_patterns: from behavioral store
|
|
238
|
+
pattern_content = self._compile_behavioral_block(
|
|
239
|
+
profile_id, block_limit,
|
|
240
|
+
)
|
|
241
|
+
self._store_core_block(
|
|
242
|
+
profile_id, "behavioral_patterns",
|
|
243
|
+
pattern_content,
|
|
244
|
+
[],
|
|
245
|
+
)
|
|
246
|
+
blocks_compiled += 1
|
|
247
|
+
|
|
248
|
+
# 4. active_decisions: signal_type='decision' with min access
|
|
249
|
+
# CRITICAL: uses signal_type NOT fact_type (HIGH-2 fix)
|
|
250
|
+
decision_facts = self._db.execute(
|
|
251
|
+
"SELECT f.fact_id, f.content FROM atomic_facts f "
|
|
252
|
+
"LEFT JOIN fact_access_log a ON f.fact_id = a.fact_id "
|
|
253
|
+
"WHERE f.profile_id = ? AND f.signal_type = 'decision' "
|
|
254
|
+
"AND f.lifecycle = 'active' "
|
|
255
|
+
"GROUP BY f.fact_id "
|
|
256
|
+
"HAVING COUNT(a.log_id) >= ? "
|
|
257
|
+
"ORDER BY COUNT(a.log_id) DESC LIMIT 5",
|
|
258
|
+
(profile_id, self._config.promotion_min_access),
|
|
259
|
+
)
|
|
260
|
+
self._store_core_block(
|
|
261
|
+
profile_id, "active_decisions",
|
|
262
|
+
self._rows_to_content(decision_facts, block_limit),
|
|
263
|
+
[dict(f)["fact_id"] for f in (decision_facts or [])],
|
|
264
|
+
)
|
|
265
|
+
blocks_compiled += 1
|
|
266
|
+
|
|
267
|
+
# 5. learned_preferences: opinion facts with high confidence
|
|
268
|
+
pref_facts = self._db.execute(
|
|
269
|
+
"SELECT fact_id, content FROM atomic_facts "
|
|
270
|
+
"WHERE profile_id = ? AND fact_type = 'opinion' "
|
|
271
|
+
"AND confidence >= ? AND lifecycle = 'active' "
|
|
272
|
+
"ORDER BY confidence DESC LIMIT 5",
|
|
273
|
+
(profile_id, self._config.promotion_min_trust),
|
|
274
|
+
)
|
|
275
|
+
self._store_core_block(
|
|
276
|
+
profile_id, "learned_preferences",
|
|
277
|
+
self._rows_to_content(pref_facts, block_limit),
|
|
278
|
+
[dict(f)["fact_id"] for f in (pref_facts or [])],
|
|
279
|
+
)
|
|
280
|
+
blocks_compiled += 1
|
|
281
|
+
|
|
282
|
+
return {"blocks_compiled": blocks_compiled, "mode": "rules"}
|
|
283
|
+
|
|
284
|
+
def _compile_core_blocks_llm(self, profile_id: str) -> dict[str, Any]:
|
|
285
|
+
"""Mode B/C: LLM-assisted Core Memory block compilation.
|
|
286
|
+
|
|
287
|
+
Falls back to Mode A rules if LLM fails (Rule 19).
|
|
288
|
+
"""
|
|
289
|
+
if self._summarizer is None:
|
|
290
|
+
return self.compile_core_blocks_mode_a(profile_id)
|
|
291
|
+
|
|
292
|
+
try:
|
|
293
|
+
blocks_compiled = 0
|
|
294
|
+
|
|
295
|
+
for block_type, fact_types in [
|
|
296
|
+
("user_profile", ["semantic", "opinion"]),
|
|
297
|
+
("project_context", ["episodic"]),
|
|
298
|
+
]:
|
|
299
|
+
facts = self._get_top_facts(
|
|
300
|
+
profile_id, fact_types=fact_types,
|
|
301
|
+
sort_by="access_count", limit=8,
|
|
302
|
+
)
|
|
303
|
+
if facts:
|
|
304
|
+
fact_dicts = [
|
|
305
|
+
{"content": f.get("content", "")} for f in facts
|
|
306
|
+
]
|
|
307
|
+
summary = self._summarizer.summarize_cluster(fact_dicts)
|
|
308
|
+
self._store_core_block(
|
|
309
|
+
profile_id, block_type,
|
|
310
|
+
summary[:self._config.block_char_limit],
|
|
311
|
+
[f["fact_id"] for f in facts],
|
|
312
|
+
compiled_by="llm",
|
|
313
|
+
)
|
|
314
|
+
blocks_compiled += 1
|
|
315
|
+
|
|
316
|
+
# Behavioral, decisions, preferences still rules-based
|
|
317
|
+
mode_a_result = self.compile_core_blocks_mode_a(profile_id)
|
|
318
|
+
blocks_compiled += mode_a_result.get("blocks_compiled", 0)
|
|
319
|
+
|
|
320
|
+
return {"blocks_compiled": blocks_compiled, "mode": "llm"}
|
|
321
|
+
except Exception:
|
|
322
|
+
# Fallback to Mode A (Rule 19)
|
|
323
|
+
return self.compile_core_blocks_mode_a(profile_id)
|
|
324
|
+
|
|
325
|
+
# ------------------------------------------------------------------
|
|
326
|
+
# Step 3: Auto-Promote
|
|
327
|
+
# ------------------------------------------------------------------
|
|
328
|
+
|
|
329
|
+
def _step3_promote(self, profile_id: str) -> dict[str, Any]:
|
|
330
|
+
"""Promote frequently accessed facts to higher lifecycle state.
|
|
331
|
+
|
|
332
|
+
Checks temporal validity (L12 fix) and trust threshold.
|
|
333
|
+
Never overwrites fact content (Rule 17).
|
|
334
|
+
"""
|
|
335
|
+
candidates = self._db.execute(
|
|
336
|
+
"SELECT f.fact_id, f.lifecycle, f.confidence, "
|
|
337
|
+
" COUNT(a.log_id) as access_count "
|
|
338
|
+
"FROM atomic_facts f "
|
|
339
|
+
"LEFT JOIN fact_access_log a ON f.fact_id = a.fact_id "
|
|
340
|
+
"WHERE f.profile_id = ? AND f.lifecycle = 'active' "
|
|
341
|
+
"GROUP BY f.fact_id "
|
|
342
|
+
"HAVING COUNT(a.log_id) >= ?",
|
|
343
|
+
(profile_id, self._config.promotion_min_access),
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
promoted = 0
|
|
347
|
+
for row in (candidates or []):
|
|
348
|
+
d = dict(row)
|
|
349
|
+
fact_id = d["fact_id"]
|
|
350
|
+
|
|
351
|
+
# Temporal validity check (L12 fix)
|
|
352
|
+
if not self._is_temporally_valid(fact_id, profile_id):
|
|
353
|
+
continue
|
|
354
|
+
|
|
355
|
+
# Trust check
|
|
356
|
+
if d.get("confidence", 0) < self._config.promotion_min_trust:
|
|
357
|
+
continue
|
|
358
|
+
|
|
359
|
+
# Promote: active -> warm (lifecycle transition)
|
|
360
|
+
self._db.execute(
|
|
361
|
+
"UPDATE atomic_facts SET lifecycle = 'warm' "
|
|
362
|
+
"WHERE fact_id = ? AND lifecycle = 'active'",
|
|
363
|
+
(fact_id,),
|
|
364
|
+
)
|
|
365
|
+
promoted += 1
|
|
366
|
+
|
|
367
|
+
return {
|
|
368
|
+
"candidates": len(candidates or []),
|
|
369
|
+
"promoted": promoted,
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
def _is_temporally_valid(
|
|
373
|
+
self, fact_id: str, profile_id: str,
|
|
374
|
+
) -> bool:
|
|
375
|
+
"""Check if fact has not been temporally invalidated (L12).
|
|
376
|
+
|
|
377
|
+
Returns True if valid, False if expired.
|
|
378
|
+
"""
|
|
379
|
+
# Use TemporalValidator.is_temporally_valid() if available (P5-BC4)
|
|
380
|
+
if self._temporal_validator is not None:
|
|
381
|
+
return self._temporal_validator.is_temporally_valid(
|
|
382
|
+
fact_id, profile_id,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
# Fallback: direct SQL check
|
|
386
|
+
rows = self._db.execute(
|
|
387
|
+
"SELECT valid_until FROM fact_temporal_validity "
|
|
388
|
+
"WHERE fact_id = ? AND profile_id = ?",
|
|
389
|
+
(fact_id, profile_id),
|
|
390
|
+
)
|
|
391
|
+
if not rows:
|
|
392
|
+
return True # No temporal record = valid
|
|
393
|
+
valid_until = dict(rows[0]).get("valid_until")
|
|
394
|
+
if valid_until is None:
|
|
395
|
+
return True # Open-ended validity
|
|
396
|
+
try:
|
|
397
|
+
expiry = datetime.fromisoformat(valid_until)
|
|
398
|
+
now = datetime.now(timezone.utc)
|
|
399
|
+
# Handle naive datetimes
|
|
400
|
+
if expiry.tzinfo is None:
|
|
401
|
+
return expiry > now.replace(tzinfo=None)
|
|
402
|
+
return expiry > now
|
|
403
|
+
except (ValueError, TypeError):
|
|
404
|
+
return True # Parse failure = assume valid
|
|
405
|
+
|
|
406
|
+
# ------------------------------------------------------------------
|
|
407
|
+
# Step 4: Decay Edges
|
|
408
|
+
# ------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
def _step4_decay_edges(self, profile_id: str) -> dict[str, int]:
|
|
411
|
+
"""Decay unused association edges. Delegates to AutoLinker."""
|
|
412
|
+
if self._auto_linker is None:
|
|
413
|
+
return {"decayed": 0}
|
|
414
|
+
try:
|
|
415
|
+
decayed = self._auto_linker.decay_unused(
|
|
416
|
+
profile_id, days_threshold=self._config.decay_days_threshold,
|
|
417
|
+
)
|
|
418
|
+
return {"decayed": decayed}
|
|
419
|
+
except Exception as exc:
|
|
420
|
+
logger.warning("Edge decay failed: %s", exc)
|
|
421
|
+
return {"decayed": 0}
|
|
422
|
+
|
|
423
|
+
# ------------------------------------------------------------------
|
|
424
|
+
# Step 5: Recompute Graph
|
|
425
|
+
# ------------------------------------------------------------------
|
|
426
|
+
|
|
427
|
+
def _step5_recompute_graph(
|
|
428
|
+
self, profile_id: str,
|
|
429
|
+
) -> dict[str, Any]:
|
|
430
|
+
"""Recompute PageRank + communities. Delegates to GraphAnalyzer."""
|
|
431
|
+
if self._graph_analyzer is None:
|
|
432
|
+
return {"node_count": 0, "community_count": 0}
|
|
433
|
+
try:
|
|
434
|
+
return self._graph_analyzer.compute_and_store(profile_id)
|
|
435
|
+
except Exception as exc:
|
|
436
|
+
logger.warning("Graph recompute failed: %s", exc)
|
|
437
|
+
return {"node_count": 0, "community_count": 0}
|
|
438
|
+
|
|
439
|
+
# ------------------------------------------------------------------
|
|
440
|
+
# Step 6: Derive Associations
|
|
441
|
+
# ------------------------------------------------------------------
|
|
442
|
+
|
|
443
|
+
def _step6_derive_associations(
|
|
444
|
+
self, profile_id: str,
|
|
445
|
+
) -> dict[str, int]:
|
|
446
|
+
"""Derive new associations from recently created summary facts."""
|
|
447
|
+
summaries = self._db.execute(
|
|
448
|
+
"SELECT fact_id FROM atomic_facts "
|
|
449
|
+
"WHERE profile_id = ? AND fact_type = 'semantic' "
|
|
450
|
+
"AND lifecycle = 'active' "
|
|
451
|
+
"AND created_at > datetime('now', '-1 hour')",
|
|
452
|
+
(profile_id,),
|
|
453
|
+
)
|
|
454
|
+
linked = 0
|
|
455
|
+
if self._auto_linker and summaries:
|
|
456
|
+
for row in summaries:
|
|
457
|
+
fact = self._db.get_fact(dict(row)["fact_id"])
|
|
458
|
+
if fact:
|
|
459
|
+
try:
|
|
460
|
+
ids = self._auto_linker.link_new_fact(fact, profile_id)
|
|
461
|
+
linked += len(ids)
|
|
462
|
+
except Exception:
|
|
463
|
+
pass
|
|
464
|
+
return {"summary_facts_linked": linked}
|
|
465
|
+
|
|
466
|
+
# ------------------------------------------------------------------
|
|
467
|
+
# Core Memory Block Storage
|
|
468
|
+
# ------------------------------------------------------------------
|
|
469
|
+
|
|
470
|
+
def _store_core_block(
|
|
471
|
+
self,
|
|
472
|
+
profile_id: str,
|
|
473
|
+
block_type: str,
|
|
474
|
+
content: str,
|
|
475
|
+
source_fact_ids: list[str],
|
|
476
|
+
compiled_by: str = "rules",
|
|
477
|
+
) -> None:
|
|
478
|
+
"""Store or update a Core Memory block.
|
|
479
|
+
|
|
480
|
+
Uses INSERT OR REPLACE on UNIQUE(profile_id, block_type).
|
|
481
|
+
Guarantees idempotency (L18).
|
|
482
|
+
"""
|
|
483
|
+
from superlocalmemory.storage.models import _new_id
|
|
484
|
+
|
|
485
|
+
# Get existing version for increment
|
|
486
|
+
existing = self._db.get_core_block(profile_id, block_type)
|
|
487
|
+
version = (existing["version"] + 1) if existing else 1
|
|
488
|
+
|
|
489
|
+
self._db.store_core_block(
|
|
490
|
+
block_id=_new_id(),
|
|
491
|
+
profile_id=profile_id,
|
|
492
|
+
block_type=block_type,
|
|
493
|
+
content=content,
|
|
494
|
+
source_fact_ids=json.dumps(source_fact_ids),
|
|
495
|
+
char_count=len(content),
|
|
496
|
+
version=version,
|
|
497
|
+
compiled_by=compiled_by,
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
# ------------------------------------------------------------------
|
|
501
|
+
# Helper Methods
|
|
502
|
+
# ------------------------------------------------------------------
|
|
503
|
+
|
|
504
|
+
def _get_top_facts(
|
|
505
|
+
self,
|
|
506
|
+
profile_id: str,
|
|
507
|
+
fact_types: list[str],
|
|
508
|
+
sort_by: str = "access_count",
|
|
509
|
+
limit: int = 5,
|
|
510
|
+
) -> list[dict]:
|
|
511
|
+
"""Get top facts by type and sort criteria."""
|
|
512
|
+
type_placeholders = ",".join("?" * len(fact_types))
|
|
513
|
+
|
|
514
|
+
if sort_by == "recency":
|
|
515
|
+
query = (
|
|
516
|
+
f"SELECT fact_id, content, fact_type FROM atomic_facts "
|
|
517
|
+
f"WHERE profile_id = ? AND fact_type IN ({type_placeholders}) "
|
|
518
|
+
f"AND lifecycle = 'active' "
|
|
519
|
+
f"ORDER BY created_at DESC LIMIT ?"
|
|
520
|
+
)
|
|
521
|
+
else:
|
|
522
|
+
query = (
|
|
523
|
+
f"SELECT f.fact_id, f.content, f.fact_type, "
|
|
524
|
+
f" COUNT(a.log_id) as access_count "
|
|
525
|
+
f"FROM atomic_facts f "
|
|
526
|
+
f"LEFT JOIN fact_access_log a ON f.fact_id = a.fact_id "
|
|
527
|
+
f"WHERE f.profile_id = ? AND f.fact_type IN ({type_placeholders}) "
|
|
528
|
+
f"AND f.lifecycle = 'active' "
|
|
529
|
+
f"GROUP BY f.fact_id "
|
|
530
|
+
f"ORDER BY access_count DESC LIMIT ?"
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
params: list[Any] = [profile_id] + fact_types + [limit]
|
|
534
|
+
rows = self._db.execute(query, tuple(params))
|
|
535
|
+
return [dict(r) for r in (rows or [])]
|
|
536
|
+
|
|
537
|
+
def _facts_to_content(
|
|
538
|
+
self, facts: list[dict], char_limit: int,
|
|
539
|
+
) -> str:
|
|
540
|
+
"""Join fact contents with separators, capped at char_limit."""
|
|
541
|
+
parts = [f.get("content", "") for f in facts if f.get("content")]
|
|
542
|
+
joined = "\n---\n".join(parts)
|
|
543
|
+
return joined[:char_limit] if joined else "No data available."
|
|
544
|
+
|
|
545
|
+
def _rows_to_content(
|
|
546
|
+
self, rows: list | None, char_limit: int,
|
|
547
|
+
) -> str:
|
|
548
|
+
"""Convert DB rows to content string."""
|
|
549
|
+
if not rows:
|
|
550
|
+
return "No data available."
|
|
551
|
+
parts = [
|
|
552
|
+
dict(r).get("content", "") for r in rows if dict(r).get("content")
|
|
553
|
+
]
|
|
554
|
+
joined = "\n---\n".join(parts)
|
|
555
|
+
return joined[:char_limit] if joined else "No data available."
|
|
556
|
+
|
|
557
|
+
def _compile_behavioral_block(
|
|
558
|
+
self, profile_id: str, char_limit: int,
|
|
559
|
+
) -> str:
|
|
560
|
+
"""Compile behavioral patterns into a block content string."""
|
|
561
|
+
if self._behavioral is None:
|
|
562
|
+
return "No behavioral patterns detected yet."
|
|
563
|
+
|
|
564
|
+
try:
|
|
565
|
+
from superlocalmemory.learning.behavioral import BehavioralTracker
|
|
566
|
+
|
|
567
|
+
if isinstance(self._behavioral, BehavioralTracker):
|
|
568
|
+
patterns = self._db.execute(
|
|
569
|
+
"SELECT pattern_type, pattern_key, confidence "
|
|
570
|
+
"FROM behavioral_patterns "
|
|
571
|
+
"WHERE profile_id = ? ORDER BY confidence DESC LIMIT 5",
|
|
572
|
+
(profile_id,),
|
|
573
|
+
)
|
|
574
|
+
else:
|
|
575
|
+
patterns = self._behavioral.get_patterns(
|
|
576
|
+
profile_id, limit=5,
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
if not patterns:
|
|
580
|
+
return "No behavioral patterns detected yet."
|
|
581
|
+
|
|
582
|
+
parts: list[str] = []
|
|
583
|
+
for p in patterns:
|
|
584
|
+
d = dict(p)
|
|
585
|
+
ptype = d.get("pattern_type", "")
|
|
586
|
+
pkey = d.get("pattern_key", "")
|
|
587
|
+
conf = d.get("confidence", 0)
|
|
588
|
+
parts.append(
|
|
589
|
+
f"{ptype}: {pkey} (confidence: {conf:.2f})"
|
|
590
|
+
)
|
|
591
|
+
|
|
592
|
+
content = "\n---\n".join(parts)
|
|
593
|
+
return content[:char_limit]
|
|
594
|
+
except Exception:
|
|
595
|
+
return "No behavioral patterns detected yet."
|