superlocalmemory 3.4.7 → 3.4.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superlocalmemory",
3
- "version": "3.4.7",
3
+ "version": "3.4.8",
4
4
  "description": "Information-geometric agent memory with mathematical guarantees. 4-channel retrieval, Fisher-Rao similarity, zero-LLM mode, EU AI Act compliant. Works with Claude, Cursor, Windsurf, and 17+ AI tools.",
5
5
  "keywords": [
6
6
  "ai-memory",
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "superlocalmemory"
3
- version = "3.4.7"
3
+ version = "3.4.8"
4
4
  description = "Information-geometric agent memory with mathematical guarantees"
5
5
  readme = "README.md"
6
6
  license = {text = "AGPL-3.0-or-later"}
@@ -60,6 +60,8 @@ def dispatch(args: Namespace) -> None:
60
60
  "serve": cmd_serve,
61
61
  # V3.4.3 ingestion adapters
62
62
  "adapters": cmd_adapters,
63
+ # V3.4.8 external observation ingestion
64
+ "ingest": cmd_ingest,
63
65
  }
64
66
  handler = handlers.get(args.command)
65
67
  if handler:
@@ -144,6 +146,12 @@ def cmd_serve(args: Namespace) -> None:
144
146
  # -- Ingestion Adapters (V3.4.3) ------------------------------------------
145
147
 
146
148
 
149
+ def cmd_ingest(args: Namespace) -> None:
150
+ """Import external observations into SLM learning pipeline."""
151
+ from superlocalmemory.cli.ingest_cmd import cmd_ingest as _ingest
152
+ _ingest(args)
153
+
154
+
147
155
  def cmd_adapters(args: Namespace) -> None:
148
156
  """Manage ingestion adapters (Gmail, Calendar, Transcript).
149
157
 
@@ -16,7 +16,7 @@ This module contains CLIENT functions used by CLI commands:
16
16
  The actual daemon server code is in server/unified_daemon.py.
17
17
 
18
18
  Part of Qualixar | Author: Varun Pratap Bhardwaj
19
- License: Elastic-2.0
19
+ License: AGPL-3.0-or-later
20
20
  """
21
21
 
22
22
  from __future__ import annotations
@@ -0,0 +1,261 @@
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
+ """CLI handler for `slm ingest` — import external observations into SLM.
6
+
7
+ Supported sources:
8
+ --source ecc Import ECC (Everything Claude Code) session summaries
9
+ --source jsonl Import generic JSONL observations
10
+
11
+ Each imported record becomes a tool_event in the behavioral learning pipeline,
12
+ so the AssertionMiner can learn from them.
13
+
14
+ Part of Qualixar | Author: Varun Pratap Bhardwaj
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import logging
21
+ import sqlite3
22
+ import sys
23
+ from argparse import Namespace
24
+ from datetime import datetime, timezone
25
+ from pathlib import Path
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ MEMORY_DIR = Path.home() / ".superlocalmemory"
30
+ MEMORY_DB = MEMORY_DIR / "memory.db"
31
+
32
+
33
+ def cmd_ingest(args: Namespace) -> None:
34
+ """Ingest external observations into SLM tool_events table."""
35
+ source = getattr(args, "source", "ecc")
36
+ file_path = getattr(args, "file", "")
37
+ use_json = getattr(args, "json", False)
38
+ dry_run = getattr(args, "dry_run", False)
39
+
40
+ if source == "ecc":
41
+ result = _ingest_ecc(file_path, dry_run=dry_run)
42
+ elif source == "jsonl":
43
+ if not file_path:
44
+ _error("--file required for jsonl source", use_json)
45
+ return
46
+ result = _ingest_jsonl(file_path, dry_run=dry_run)
47
+ else:
48
+ _error(f"Unknown source: {source}", use_json)
49
+ return
50
+
51
+ if use_json:
52
+ from superlocalmemory.cli.json_output import json_print
53
+ json_print("ingest", data=result, next_actions=[
54
+ {"command": "slm consolidate --cognitive", "description": "Run consolidation to mine assertions"},
55
+ ])
56
+ return
57
+
58
+ if result.get("error"):
59
+ print(f"Error: {result['error']}")
60
+ sys.exit(1)
61
+
62
+ print(f"Ingested: {result['ingested']} events from {source}")
63
+ if result.get("skipped"):
64
+ print(f"Skipped: {result['skipped']} (duplicates/invalid)")
65
+ if dry_run:
66
+ print("(dry run — no data written)")
67
+
68
+
69
+ def _error(msg: str, use_json: bool) -> None:
70
+ if use_json:
71
+ from superlocalmemory.cli.json_output import json_print
72
+ json_print("ingest", error={"code": "INGEST_ERROR", "message": msg})
73
+ else:
74
+ print(f"Error: {msg}")
75
+ sys.exit(1)
76
+
77
+
78
+ def _ingest_ecc(file_path: str, *, dry_run: bool = False) -> dict:
79
+ """Ingest ECC session summaries from transcript JSONL files.
80
+
81
+ Scans the Claude projects directory for session JSONL files and
82
+ extracts tool usage patterns from them.
83
+ """
84
+ result = {"source": "ecc", "ingested": 0, "skipped": 0, "dry_run": dry_run}
85
+
86
+ # Find ECC session files
87
+ if file_path:
88
+ files = [Path(file_path)]
89
+ else:
90
+ # Auto-discover: scan Claude project session files
91
+ claude_dir = Path.home() / ".claude" / "projects"
92
+ if not claude_dir.exists():
93
+ result["error"] = f"Claude projects dir not found: {claude_dir}"
94
+ return result
95
+ files = sorted(claude_dir.rglob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True)
96
+ # Limit to recent files (last 20)
97
+ files = files[:20]
98
+
99
+ if not files:
100
+ result["error"] = "No session files found"
101
+ return result
102
+
103
+ result["files_scanned"] = len(files)
104
+ events = []
105
+
106
+ for fpath in files:
107
+ try:
108
+ with open(fpath) as f:
109
+ for line in f:
110
+ line = line.strip()
111
+ if not line:
112
+ continue
113
+ try:
114
+ record = json.loads(line)
115
+ except json.JSONDecodeError:
116
+ result["skipped"] += 1
117
+ continue
118
+
119
+ # Extract tool usage from ECC session records
120
+ extracted = _extract_tool_events_from_record(record)
121
+ events.extend(extracted)
122
+ except (OSError, PermissionError):
123
+ result["skipped"] += 1
124
+ continue
125
+
126
+ if dry_run:
127
+ result["ingested"] = len(events)
128
+ result["sample"] = events[:5]
129
+ return result
130
+
131
+ # Write to tool_events table
132
+ if events:
133
+ ingested = _write_tool_events(events)
134
+ result["ingested"] = ingested
135
+ else:
136
+ result["ingested"] = 0
137
+
138
+ return result
139
+
140
+
141
+ def _extract_tool_events_from_record(record: dict) -> list[dict]:
142
+ """Extract tool events from a single ECC/Claude session JSONL record."""
143
+ events = []
144
+
145
+ # Handle ECC summary format
146
+ if "type" in record:
147
+ rtype = record.get("type", "")
148
+
149
+ # Tool use records
150
+ if rtype == "assistant" and "content" in record:
151
+ content = record.get("content", [])
152
+ if isinstance(content, list):
153
+ for block in content:
154
+ if isinstance(block, dict) and block.get("type") == "tool_use":
155
+ tool_name = block.get("name", "unknown")
156
+ events.append({
157
+ "tool_name": tool_name,
158
+ "event_type": "complete",
159
+ "session_id": record.get("session_id", "ecc_import"),
160
+ "created_at": record.get("timestamp", datetime.now(timezone.utc).isoformat()),
161
+ })
162
+
163
+ # Also extract from tool_use type directly
164
+ if isinstance(content, list):
165
+ for block in content:
166
+ if isinstance(block, dict) and block.get("type") == "tool_result":
167
+ tool_name = block.get("tool_use_id", "unknown")
168
+ is_error = block.get("is_error", False)
169
+ events.append({
170
+ "tool_name": tool_name,
171
+ "event_type": "error" if is_error else "complete",
172
+ "session_id": record.get("session_id", "ecc_import"),
173
+ "created_at": record.get("timestamp", datetime.now(timezone.utc).isoformat()),
174
+ })
175
+
176
+ # Handle direct tool event format (from hook output)
177
+ if "tool_name" in record and "event_type" in record:
178
+ events.append({
179
+ "tool_name": record["tool_name"],
180
+ "event_type": record.get("event_type", "complete"),
181
+ "session_id": record.get("session_id", "ecc_import"),
182
+ "created_at": record.get("created_at", datetime.now(timezone.utc).isoformat()),
183
+ })
184
+
185
+ return events
186
+
187
+
188
+ def _ingest_jsonl(file_path: str, *, dry_run: bool = False) -> dict:
189
+ """Ingest generic JSONL file with tool event records."""
190
+ result = {"source": "jsonl", "ingested": 0, "skipped": 0, "dry_run": dry_run}
191
+
192
+ fpath = Path(file_path)
193
+ if not fpath.exists():
194
+ result["error"] = f"File not found: {file_path}"
195
+ return result
196
+
197
+ events = []
198
+ with open(fpath) as f:
199
+ for line in f:
200
+ line = line.strip()
201
+ if not line:
202
+ continue
203
+ try:
204
+ record = json.loads(line)
205
+ except json.JSONDecodeError:
206
+ result["skipped"] += 1
207
+ continue
208
+
209
+ if "tool_name" not in record:
210
+ result["skipped"] += 1
211
+ continue
212
+
213
+ events.append({
214
+ "tool_name": record["tool_name"],
215
+ "event_type": record.get("event_type", "complete"),
216
+ "session_id": record.get("session_id", "jsonl_import"),
217
+ "created_at": record.get("created_at", datetime.now(timezone.utc).isoformat()),
218
+ })
219
+
220
+ if dry_run:
221
+ result["ingested"] = len(events)
222
+ return result
223
+
224
+ if events:
225
+ result["ingested"] = _write_tool_events(events)
226
+
227
+ return result
228
+
229
+
230
+ def _write_tool_events(events: list[dict]) -> int:
231
+ """Write tool events to SLM's memory.db tool_events table."""
232
+ db_path = MEMORY_DB
233
+ if not db_path.exists():
234
+ return 0
235
+
236
+ conn = sqlite3.connect(str(db_path), timeout=10)
237
+ count = 0
238
+
239
+ try:
240
+ for ev in events:
241
+ try:
242
+ conn.execute(
243
+ "INSERT INTO tool_events "
244
+ "(session_id, profile_id, project_path, tool_name, event_type, "
245
+ " input_summary, output_summary, duration_ms, metadata, created_at) "
246
+ "VALUES (?, 'default', '', ?, ?, '', '', 0, '{}', ?)",
247
+ (
248
+ ev.get("session_id", "import"),
249
+ ev["tool_name"],
250
+ ev.get("event_type", "complete"),
251
+ ev.get("created_at", datetime.now(timezone.utc).isoformat()),
252
+ ),
253
+ )
254
+ count += 1
255
+ except sqlite3.Error:
256
+ continue
257
+ conn.commit()
258
+ finally:
259
+ conn.close()
260
+
261
+ return count
@@ -282,6 +282,26 @@ def main() -> None:
282
282
  help="Subcommand: list, enable, disable, start, stop, status [name]",
283
283
  )
284
284
 
285
+ # V3.4.8: External observation ingestion
286
+ ingest_p = sub.add_parser(
287
+ "ingest",
288
+ help="Import external observations (ECC, JSONL) into SLM learning",
289
+ )
290
+ ingest_p.add_argument(
291
+ "--source", default="ecc",
292
+ choices=["ecc", "jsonl"],
293
+ help="Source type: ecc (Claude Code sessions), jsonl (generic)",
294
+ )
295
+ ingest_p.add_argument(
296
+ "--file", default="",
297
+ help="Specific file to ingest (auto-discovers if not set)",
298
+ )
299
+ ingest_p.add_argument(
300
+ "--dry-run", action="store_true", default=False,
301
+ help="Preview without writing",
302
+ )
303
+ ingest_p.add_argument("--json", action="store_true", help="Output structured JSON")
304
+
285
305
  args = parser.parse_args()
286
306
 
287
307
  if not args.command:
@@ -151,7 +151,7 @@ class ConsolidationEngine:
151
151
  except ImportError:
152
152
  class CrossProjectAggregator:
153
153
  def __init__(self, db): pass
154
- def aggregate(self, *a, **kw): return []
154
+ def get_preferences(self, *a, **kw): return {}
155
155
  try:
156
156
  from superlocalmemory.parameterization.workflow_miner import WorkflowMiner
157
157
  except ImportError:
@@ -169,7 +169,7 @@ class ConsolidationEngine:
169
169
  wf_miner = WorkflowMiner(self._db)
170
170
  extractor = PatternExtractor(self._db, beh_store, cross_proj, wf_miner, p_config)
171
171
  generator = SoftPromptGenerator(p_config)
172
- injector = PromptInjector(self._db)
172
+ injector = PromptInjector(self._db, generator, p_config)
173
173
  lifecycle = PromptLifecycleManager(self._db, p_config)
174
174
  hook = AutoParameterizeHook(extractor, generator, injector, lifecycle, p_config)
175
175
  sp_result = hook.on_consolidation_complete(profile_id)
@@ -15,7 +15,7 @@ entity resolution ran but results were silently discarded.
15
15
  d) LLM disambiguation (Mode B/C only)
16
16
 
17
17
  Part of Qualixar | Author: Varun Pratap Bhardwaj
18
- License: Elastic-2.0
18
+ License: AGPL-3.0-or-later
19
19
  """
20
20
 
21
21
  from __future__ import annotations
@@ -112,20 +112,59 @@ def jaro_winkler(s1: str, s2: str, prefix_weight: float = 0.1) -> float:
112
112
  return jaro + prefix * prefix_weight * (1.0 - jaro)
113
113
 
114
114
 
115
+ _COMMON_WORDS = frozenset({
116
+ "april", "may", "june", "march", "august", "phase", "test", "gap",
117
+ "dashboard", "remaining", "session", "results", "tools", "projects",
118
+ "prompts", "integration", "cli", "engagement", "mode", "error",
119
+ "step", "fix", "build", "check", "run", "start", "stop", "config",
120
+ "status", "version", "query", "data", "file", "path", "node", "edge",
121
+ "table", "index", "schema", "model", "type", "class", "function",
122
+ "module", "package", "import", "export", "default", "pattern",
123
+ "memory", "profile", "context", "pipeline", "worker", "daemon",
124
+ "server", "client", "route", "endpoint", "handler", "hook",
125
+ })
126
+
127
+
115
128
  def _guess_entity_type(name: str) -> str:
116
- """Heuristic entity type classification from name string."""
129
+ """Heuristic entity type classification from name string.
130
+
131
+ v3.4.8: Fixed false-positive "person" classification. Single capitalized
132
+ common words (April, Phase, Dashboard) are concepts, not people.
133
+ Only classify as "person" when it looks like a real human name.
134
+ """
117
135
  if any(m in name for m in _ORG_MARKERS):
118
136
  return "organization"
119
137
  if any(m in name for m in _PLACE_MARKERS):
120
138
  return "place"
121
139
  if any(m in name for m in _EVENT_MARKERS):
122
140
  return "event"
123
- # Two capitalized words = likely a person name
141
+
142
+ # Filter out common words that aren't people
143
+ if name.lower() in _COMMON_WORDS:
144
+ return "concept"
145
+
146
+ # Two capitalized words = likely a person name (e.g. "Varun Bhardwaj")
124
147
  if re.match(r"^[A-Z][a-z]+ [A-Z][a-z]+$", name):
125
- return "person"
126
- # Single capitalized word = likely a person first name
148
+ # But not if either word is a common term
149
+ parts = name.lower().split()
150
+ if not any(p in _COMMON_WORDS for p in parts):
151
+ return "person"
152
+
153
+ # Single short capitalized word with no digits or dots = concept, not person
154
+ # "person" should only be assigned for real names, not generic terms
127
155
  if re.match(r"^[A-Z][a-z]+$", name):
156
+ if name.lower() in _COMMON_WORDS:
157
+ return "concept"
158
+ # Only classify as person if it's a plausible first name
159
+ # (short word not in common terms — still a heuristic)
160
+ if len(name) <= 3:
161
+ return "concept"
128
162
  return "person"
163
+
164
+ # Contains dots/slashes/hyphens = likely a technical term
165
+ if re.search(r"[./\-_]", name):
166
+ return "concept"
167
+
129
168
  return "concept"
130
169
 
131
170
 
@@ -28,6 +28,8 @@ logger = logging.getLogger(__name__)
28
28
  MIN_EVIDENCE = 3 # Minimum events to create an assertion
29
29
  MAX_ASSERTIONS_PER_RUN = 20 # Cap assertions per mining cycle
30
30
  REINFORCEMENT_NUDGE = 0.15 # Bayesian confidence increase
31
+ PROMOTION_MIN_PROJECTS = 2 # Minimum projects for cross-project promotion
32
+ PROMOTION_MIN_CONFIDENCE = 0.8 # Minimum avg confidence for promotion
31
33
 
32
34
 
33
35
  class AssertionMiner:
@@ -74,6 +76,11 @@ class AssertionMiner:
74
76
  results["created"] += s4.get("created", 0)
75
77
  results["reinforced"] += s4.get("reinforced", 0)
76
78
 
79
+ # Strategy 5: Cross-project assertion promotion
80
+ s5 = self._promote_cross_project(conn, profile_id)
81
+ results["strategies"]["cross_project"] = s5
82
+ results["created"] += s5.get("promoted", 0)
83
+
77
84
  conn.commit()
78
85
  except Exception as exc:
79
86
  logger.warning("Assertion mining failed: %s", exc)
@@ -263,6 +270,92 @@ class AssertionMiner:
263
270
 
264
271
  return result
265
272
 
273
+ # ------------------------------------------------------------------
274
+ # Strategy 5: Cross-project assertion promotion
275
+ # ------------------------------------------------------------------
276
+
277
+ def _promote_cross_project(
278
+ self, conn: sqlite3.Connection, profile_id: str,
279
+ ) -> dict:
280
+ """Promote assertions that appear in 2+ projects to global scope.
281
+
282
+ When the same trigger+action pattern is observed across multiple
283
+ project_paths with avg confidence >= 0.8, create a global assertion
284
+ (project_path='') so it applies everywhere.
285
+ """
286
+ result = {"promoted": 0, "candidates": 0}
287
+
288
+ # Find assertions grouped by trigger+action across projects
289
+ rows = conn.execute(
290
+ "SELECT trigger_condition, action, category, "
291
+ "COUNT(DISTINCT project_path) AS project_count, "
292
+ "AVG(confidence) AS avg_confidence, "
293
+ "SUM(evidence_count) AS total_evidence "
294
+ "FROM behavioral_assertions "
295
+ "WHERE profile_id = ? AND project_path != '' "
296
+ "GROUP BY trigger_condition, action "
297
+ "HAVING COUNT(DISTINCT project_path) >= ?",
298
+ (profile_id, PROMOTION_MIN_PROJECTS),
299
+ ).fetchall()
300
+
301
+ result["candidates"] = len(rows)
302
+
303
+ for row in rows:
304
+ avg_conf = row["avg_confidence"]
305
+ if avg_conf < PROMOTION_MIN_CONFIDENCE:
306
+ continue
307
+
308
+ trigger = row["trigger_condition"]
309
+ action = row["action"]
310
+ category = row["category"]
311
+ total_ev = row["total_evidence"]
312
+ project_count = row["project_count"]
313
+
314
+ # Check if global assertion already exists
315
+ global_id = hashlib.sha256(
316
+ f"{profile_id}:{trigger}:{action}".encode()
317
+ ).hexdigest()[:16]
318
+
319
+ existing = conn.execute(
320
+ "SELECT id FROM behavioral_assertions "
321
+ "WHERE id = ? AND project_path = ''",
322
+ (global_id,),
323
+ ).fetchone()
324
+
325
+ if existing:
326
+ # Reinforce existing global assertion
327
+ now = datetime.now(timezone.utc).isoformat()
328
+ conn.execute(
329
+ "UPDATE behavioral_assertions SET "
330
+ "confidence = MIN(0.95, confidence + ?), "
331
+ "evidence_count = ?, "
332
+ "reinforcement_count = reinforcement_count + 1, "
333
+ "last_reinforced_at = ?, updated_at = ? "
334
+ "WHERE id = ?",
335
+ (REINFORCEMENT_NUDGE, total_ev, now, now, global_id),
336
+ )
337
+ else:
338
+ # Create new global assertion from cross-project evidence
339
+ now = datetime.now(timezone.utc).isoformat()
340
+ promoted_conf = min(0.9, avg_conf)
341
+ conn.execute(
342
+ "INSERT INTO behavioral_assertions "
343
+ "(id, profile_id, project_path, trigger_condition, action, "
344
+ " category, confidence, evidence_count, source, "
345
+ " created_at, updated_at) "
346
+ "VALUES (?, ?, '', ?, ?, ?, ?, ?, 'cross_project', ?, ?)",
347
+ (global_id, profile_id, trigger, action,
348
+ category, round(promoted_conf, 4), total_ev, now, now),
349
+ )
350
+ result["promoted"] += 1
351
+ logger.info(
352
+ "Promoted assertion to global: '%s' → '%s' "
353
+ "(from %d projects, avg_conf=%.2f)",
354
+ trigger, action, project_count, avg_conf,
355
+ )
356
+
357
+ return result
358
+
266
359
  # ------------------------------------------------------------------
267
360
  # Upsert logic
268
361
  # ------------------------------------------------------------------
@@ -1,7 +1,6 @@
1
- #!/usr/bin/env python3
2
- # SPDX-License-Identifier: Elastic-2.0
3
1
  # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
4
- # Part of Qualixar | Author: Varun Pratap Bhardwaj (qualixar.com | varunpratap.com)
2
+ # Licensed under AGPL-3.0-or-later - see LICENSE file
3
+ # Part of SuperLocalMemory V3 | https://qualixar.com | https://varunpratap.com
5
4
  """
6
5
  EngagementTracker -- Local-only engagement metrics for V3 learning.
7
6
 
@@ -186,7 +186,8 @@ class PromptInjector:
186
186
  )
187
187
  max_version = 0
188
188
  if version_rows:
189
- max_version = version_rows[0].get("max_version", 0) or 0
189
+ row = version_rows[0]
190
+ max_version = (dict(row) if hasattr(row, "keys") else {"max_version": row[0]}).get("max_version", 0) or 0
190
191
  new_version = max_version + 1
191
192
 
192
193
  # Insert new prompt
@@ -226,11 +226,15 @@ async def get_soft_prompts():
226
226
  conn = _sqlite3.connect(str(MEMORY_DIR / "memory.db"))
227
227
  conn.row_factory = _sqlite3.Row
228
228
  rows = conn.execute(
229
- "SELECT * FROM soft_prompt_templates WHERE active = 1 "
230
- "ORDER BY category"
229
+ "SELECT prompt_id, category, content, confidence, effectiveness, "
230
+ "token_count, active, version, created_at "
231
+ "FROM soft_prompt_templates WHERE active = 1 ORDER BY category"
231
232
  ).fetchall()
232
233
  conn.close()
233
- return {"prompts": [dict(r) for r in rows], "count": len(rows)}
234
+ return {"prompts": [dict(zip(
235
+ ["prompt_id", "category", "content", "confidence", "effectiveness",
236
+ "token_count", "active", "version", "created_at"], r
237
+ )) for r in rows], "count": len(rows)}
234
238
  except Exception as e:
235
239
  logger.debug("get_soft_prompts error: %s", e)
236
240
  return {"prompts": [], "count": 0, "error": str(e)}
@@ -143,12 +143,29 @@ async def learning_status():
143
143
  "signals": signal_count,
144
144
  }
145
145
 
146
- # Engagement
146
+ # Engagement — v3.4.8: Fixed method name (was get_engagement_stats, actual is get_stats)
147
147
  engagement = _get_engagement()
148
148
  if engagement:
149
149
  try:
150
- result["engagement"] = engagement.get_engagement_stats()
151
- except Exception:
150
+ stats = engagement.get_stats(active_profile)
151
+ health = engagement.get_health(active_profile)
152
+ active_days = stats.get("active_days", 0)
153
+ total_events = stats.get("total_events", 0)
154
+ memories_per_day = (
155
+ round(total_events / active_days, 1) if active_days > 0 else 0
156
+ )
157
+ result["engagement"] = {
158
+ "health_status": health.upper(),
159
+ "days_active": active_days,
160
+ "memories_per_day": memories_per_day,
161
+ "total_events": total_events,
162
+ "recall_count": stats.get("recall_count", 0),
163
+ "store_count": stats.get("store_count", 0),
164
+ "session_count": stats.get("session_count", 0),
165
+ "engagement_score": stats.get("engagement_score", 0),
166
+ }
167
+ except Exception as exc:
168
+ logger.debug("engagement stats: %s", exc)
152
169
  result["engagement"] = None
153
170
  else:
154
171
  result["engagement"] = None