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.
Files changed (30) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/README.md +61 -1
  3. package/package.json +1 -1
  4. package/pyproject.toml +26 -1
  5. package/src/superlocalmemory/attribution/signer.py +6 -1
  6. package/src/superlocalmemory/core/config.py +114 -1
  7. package/src/superlocalmemory/core/consolidation_engine.py +595 -0
  8. package/src/superlocalmemory/core/embeddings.py +0 -1
  9. package/src/superlocalmemory/core/engine.py +164 -674
  10. package/src/superlocalmemory/core/engine_wiring.py +474 -0
  11. package/src/superlocalmemory/core/graph_analyzer.py +199 -0
  12. package/src/superlocalmemory/core/recall_pipeline.py +247 -0
  13. package/src/superlocalmemory/core/store_pipeline.py +483 -0
  14. package/src/superlocalmemory/core/worker_pool.py +35 -12
  15. package/src/superlocalmemory/encoding/auto_linker.py +308 -0
  16. package/src/superlocalmemory/encoding/context_generator.py +175 -0
  17. package/src/superlocalmemory/encoding/temporal_validator.py +513 -0
  18. package/src/superlocalmemory/hooks/auto_invoker.py +484 -0
  19. package/src/superlocalmemory/retrieval/channel_registry.py +154 -0
  20. package/src/superlocalmemory/retrieval/engine.py +12 -0
  21. package/src/superlocalmemory/retrieval/semantic_channel.py +87 -3
  22. package/src/superlocalmemory/retrieval/spreading_activation.py +311 -0
  23. package/src/superlocalmemory/retrieval/strategy.py +6 -6
  24. package/src/superlocalmemory/retrieval/vector_store.py +386 -0
  25. package/src/superlocalmemory/server/routes/v3_api.py +576 -0
  26. package/src/superlocalmemory/storage/access_log.py +169 -0
  27. package/src/superlocalmemory/storage/database.py +288 -0
  28. package/src/superlocalmemory/storage/schema.py +10 -0
  29. package/src/superlocalmemory/storage/schema_v32.py +252 -0
  30. 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."
@@ -18,7 +18,6 @@ from __future__ import annotations
18
18
  import json
19
19
  import logging
20
20
  import os
21
- import select
22
21
  import subprocess
23
22
  import sys
24
23
  import threading