nexo-brain 7.31.13 → 7.33.0

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.31.13",
3
+ "version": "7.33.0",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/README.md CHANGED
@@ -18,7 +18,7 @@
18
18
 
19
19
  [Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
20
20
 
21
- Version `7.31.13` is the current packaged-runtime line. Patch release over v7.31.12 - the offline wheel bundle no longer hard-pins onnxruntime, so cross-platform/offline installs (including older Linux) resolve a compatible native wheel instead of failing. Version `7.31.11` was a patch release over v7.31.10 - MCP lifecycle robustness + guardrail precision.
21
+ Version `7.33.0` is the current packaged-runtime line. Minor release - Cognitive OS Ola 1 (phase 2): memory search now finds by MEANING (observation embeddings + FTS/vector fusion), the KG/causal graph is read at answer time (kg_neighbors pre-answer source), local files are recalled via FTS5, nightly learning consolidation no longer times out, correction capture is reliable (soft), and the followup runner uses an atomic lock. Builds on v7.32.0 (causal-graph populate + workflow reaper + the 7.31.14 critical fixes).
22
22
 
23
23
  Previously in `7.31.9`: patch release over v7.31.8 - UI release closeout now has to prove the original reported symptom was reopened with observable evidence before claiming the release is ready.
24
24
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.31.13",
3
+ "version": "7.33.0",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
6
6
  "homepage": "https://nexo-brain.com",
@@ -182,6 +182,43 @@ def auto_close_open_protocol_tasks(conn, sid: str, task: str = "") -> list[str]:
182
182
  return closed
183
183
 
184
184
 
185
+ def auto_close_abandoned_workflow_runs(conn, sid: str) -> dict:
186
+ """Reap durable workflow_runs / workflow_goals abandoned by a stale session.
187
+
188
+ auto_close only reaped protocol_tasks; a session that opened a durable
189
+ workflow_run / workflow_goal and never closed it left a zombie 'running'
190
+ row forever, polluting the resume surface (M10 gap). Move non-terminal ones
191
+ to a terminal state when their owning session is reaped. closed_at/updated_at
192
+ use datetime('now') to match the workflow tables' timestamp format.
193
+ """
194
+ note = "auto-close: stale session ended without explicit workflow close"
195
+ runs = conn.execute(
196
+ "SELECT run_id FROM workflow_runs "
197
+ "WHERE session_id = ? AND status IN ('open','running','blocked','waiting_approval')",
198
+ (sid,),
199
+ ).fetchall()
200
+ for row in runs:
201
+ conn.execute(
202
+ "UPDATE workflow_runs SET status='cancelled', next_action=?, "
203
+ "closed_at=datetime('now'), updated_at=datetime('now') "
204
+ "WHERE run_id=? AND status IN ('open','running','blocked','waiting_approval')",
205
+ (note, row["run_id"]),
206
+ )
207
+ goals = conn.execute(
208
+ "SELECT goal_id FROM workflow_goals "
209
+ "WHERE session_id = ? AND status IN ('active','blocked')",
210
+ (sid,),
211
+ ).fetchall()
212
+ for row in goals:
213
+ conn.execute(
214
+ "UPDATE workflow_goals SET status='abandoned', blocker_reason=?, "
215
+ "closed_at=datetime('now'), updated_at=datetime('now') "
216
+ "WHERE goal_id=? AND status IN ('active','blocked')",
217
+ (note, row["goal_id"]),
218
+ )
219
+ return {"runs": len(runs), "goals": len(goals)}
220
+
221
+
185
222
  def main():
186
223
  init_db()
187
224
  conn = get_db()
@@ -197,6 +234,7 @@ def main():
197
234
  draft = get_diary_draft(sid)
198
235
  closed_tasks = auto_close_open_protocol_tasks(conn, sid, task=session.get("task", ""))
199
236
  closed_task_ids.extend(closed_tasks)
237
+ auto_close_abandoned_workflow_runs(conn, sid)
200
238
 
201
239
  if draft:
202
240
  promote_draft_to_diary(sid, draft, task=session.get("task", ""))
@@ -784,8 +784,19 @@ CO_ACTIVATION_MIN_STRENGTH = 0.1
784
784
 
785
785
 
786
786
  def _canonical_co_id(store: str, mid: int) -> int:
787
- """Create a canonical hash ID for co-activation tracking."""
788
- return hash(f"{store}:{mid}") % (2**31)
787
+ """Create a canonical, PROCESS-STABLE hash ID for co-activation tracking.
788
+
789
+ MUST be deterministic across processes. Python's builtin hash() is salted
790
+ per process (PYTHONHASHSEED), so co-activation links written in one MCP
791
+ process never matched the same memory's id in the next — fragmenting the
792
+ associative graph (observed ~6x distinct ids per memory) and silently
793
+ degrading spreading activation to within-a-single-process-lifetime. blake2b
794
+ is stable across processes and runs.
795
+ """
796
+ import hashlib
797
+
798
+ digest = hashlib.blake2b(f"{store}:{mid}".encode("utf-8"), digest_size=8).digest()
799
+ return int.from_bytes(digest, "big") % (2**31)
789
800
 
790
801
 
791
802
  def record_co_activation(memory_ids: list[tuple[str, int]]):
@@ -0,0 +1,380 @@
1
+ from __future__ import annotations
2
+
3
+ """Read-only consolidation brief builder for the nightly postmortem.
4
+
5
+ Why this module exists
6
+ ----------------------
7
+ The nightly postmortem consolidator hands the LLM a tiny diary slice, but the
8
+ prompt's "do not duplicate / detect contradiction" steps used to make the
9
+ headless model pull the ENTIRE learnings corpus into its own context (via
10
+ nexo_learning_list / nexo_learning_search / reading MEMORY.md). At hundreds of
11
+ learnings the working context blows up and the timeout wrapper SIGKILLs the
12
+ session (exit 124).
13
+
14
+ The fix: precompute ALL corpus-wide MECHANICAL work here, in the consolidator
15
+ SCRIPT process, and feed the LLM only a small, hard-capped JSON brief. The LLM
16
+ keeps the SEMANTIC judgment it is uniquely good at (is this self-critique worth
17
+ a permanent rule? which precomputed contradiction is real and how to phrase the
18
+ canonical rule?) and loses every task that requires scanning the whole corpus.
19
+
20
+ This module is READ-ONLY by construction: it performs SELECT-only queries on its
21
+ own short-lived sqlite connection (mirrors apply_findings connection style) and
22
+ NEVER commits, inserts, updates, or deletes. The only single source of truth for
23
+ similarity / contradiction / dedup math remains learning_resolver — this module
24
+ depends only on its PUBLIC surface.
25
+ """
26
+
27
+ import json
28
+ import os
29
+ import sqlite3
30
+ from typing import Any
31
+
32
+ import learning_resolver
33
+
34
+ try: # paths is available in the runtime; keep import defensive for odd installs
35
+ import paths as _paths
36
+ except Exception: # pragma: no cover - defensive
37
+ _paths = None
38
+
39
+
40
+ # Read learnings in bounded batches so even a 5k-row corpus stays O(n) and the
41
+ # helper itself never holds the whole textual corpus in a single prompt — it only
42
+ # emits the capped brief below.
43
+ _CHUNK = 200
44
+
45
+ # A learning is "weak" (stale candidate) when its weight is low, OR it lacks both
46
+ # reasoning and prevention (no rationale to act on), OR it claims a file scope but
47
+ # was never reinforced by a guard hit. Mirrors apply_findings weak-learning logic;
48
+ # copied here as small local predicates rather than importing apply_findings (to
49
+ # avoid that module's _DynamicPath side effects).
50
+ _WEAK_WEIGHT = 1.0
51
+
52
+
53
+ def _resolve_db_path() -> str:
54
+ for env_key in ("NEXO_TEST_DB", "NEXO_DB"):
55
+ value = str(os.environ.get(env_key, "") or "").strip()
56
+ if value:
57
+ return value
58
+ if _paths is not None:
59
+ try:
60
+ return str(_paths.resolve_db_path())
61
+ except Exception:
62
+ pass
63
+ return ""
64
+
65
+
66
+ def _open_conn() -> sqlite3.Connection | None:
67
+ db_path = _resolve_db_path()
68
+ if not db_path or not os.path.isfile(db_path):
69
+ return None
70
+ try:
71
+ conn = sqlite3.connect(db_path, timeout=30)
72
+ conn.row_factory = sqlite3.Row
73
+ return conn
74
+ except Exception:
75
+ return None
76
+
77
+
78
+ def _table_columns(conn: sqlite3.Connection, table: str) -> set[str]:
79
+ try:
80
+ return {str(row[1]) for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
81
+ except Exception:
82
+ return set()
83
+
84
+
85
+ def _preview(text: str, limit: int = 160) -> str:
86
+ clean = " ".join(str(text or "").split())
87
+ if len(clean) > limit:
88
+ return clean[: limit - 1].rstrip() + "…"
89
+ return clean
90
+
91
+
92
+ def _slugify(text: str) -> str:
93
+ normalized = learning_resolver._normalize_text(text)
94
+ tokens = [tok for tok in normalized.replace("/", " ").split() if tok]
95
+ return "-".join(tokens[:8])[:80] or "topic"
96
+
97
+
98
+ def _critique_text(diary: dict[str, Any]) -> str:
99
+ parts = [
100
+ str(diary.get("self_critique") or ""),
101
+ str(diary.get("summary") or ""),
102
+ ]
103
+ return " ".join(part for part in parts if part).strip()
104
+
105
+
106
+ def _is_weak(row: dict[str, Any], columns: set[str]) -> str:
107
+ """Return a non-empty weakness reason if the learning looks stale/weak."""
108
+ if "weight" in columns:
109
+ try:
110
+ weight = float(row.get("weight") if row.get("weight") is not None else 0.5)
111
+ except Exception:
112
+ weight = 0.5
113
+ if weight < _WEAK_WEIGHT:
114
+ return f"low_weight ({round(weight, 2)})"
115
+ reasoning = str(row.get("reasoning") or "").strip()
116
+ prevention = str(row.get("prevention") or "").strip()
117
+ if not reasoning and not prevention:
118
+ return "no_reasoning_or_prevention"
119
+ if "applies_to" in columns and "guard_hits" in columns:
120
+ applies = str(row.get("applies_to") or "").strip()
121
+ try:
122
+ guard_hits = int(row.get("guard_hits") or 0)
123
+ except Exception:
124
+ guard_hits = 0
125
+ if applies and guard_hits == 0:
126
+ return "scoped_never_guard_hit"
127
+ return ""
128
+
129
+
130
+ def _iter_active_learnings(conn: sqlite3.Connection, columns: set[str]):
131
+ """Yield active learnings dicts in bounded LIMIT/OFFSET batches."""
132
+ status_filter = " WHERE COALESCE(status, 'active') = 'active'" if "status" in columns else ""
133
+ order_by = "updated_at DESC, id DESC" if "updated_at" in columns else "id DESC"
134
+ offset = 0
135
+ while True:
136
+ try:
137
+ rows = conn.execute(
138
+ f"SELECT * FROM learnings{status_filter} ORDER BY {order_by} LIMIT ? OFFSET ?",
139
+ (_CHUNK, offset),
140
+ ).fetchall()
141
+ except Exception:
142
+ return
143
+ if not rows:
144
+ return
145
+ for row in rows:
146
+ yield dict(row)
147
+ if len(rows) < _CHUNK:
148
+ return
149
+ offset += _CHUNK
150
+
151
+
152
+ def build_consolidation_brief(
153
+ diaries_with_critique: list[dict],
154
+ *,
155
+ conn: sqlite3.Connection | None = None,
156
+ max_chars: int = 6000,
157
+ max_shortlist: int = 25,
158
+ max_contradictions: int = 15,
159
+ max_stale: int = 15,
160
+ ) -> dict:
161
+ """Build a small, hard-capped JSON brief from today's critiques + the corpus.
162
+
163
+ READ-ONLY: opens its own short-lived connection (unless one is supplied),
164
+ performs only SELECT queries, and never commits. The brief is the ONLY thing
165
+ handed to the LLM, so the model never lists the whole corpus.
166
+ """
167
+
168
+ own_conn = conn is None
169
+ if own_conn:
170
+ conn = _open_conn()
171
+
172
+ brief: dict[str, Any] = {
173
+ "corpus_size": 0,
174
+ "today_topics": [],
175
+ "shortlist": [],
176
+ "contradiction_pairs": [],
177
+ "supersession_stubs": [],
178
+ "stale_candidates": [],
179
+ "preference_key_dupes": [],
180
+ "truncated": False,
181
+ }
182
+
183
+ # Build today's topics regardless of corpus availability.
184
+ today_topics: list[dict[str, Any]] = []
185
+ for diary in diaries_with_critique or []:
186
+ text = _critique_text(diary)
187
+ if not text:
188
+ continue
189
+ title = _preview(diary.get("summary") or diary.get("self_critique") or "", 120)
190
+ today_topics.append(
191
+ {
192
+ "slug": _slugify(diary.get("summary") or diary.get("self_critique") or ""),
193
+ "title": title,
194
+ "_text": text,
195
+ "_tokens": set(learning_resolver._tokenize(text)),
196
+ "_applies": str(diary.get("domain") or ""),
197
+ "has_existing_coverage": False,
198
+ "covering_ids": [],
199
+ }
200
+ )
201
+
202
+ if conn is None:
203
+ # No corpus available (fresh install / missing DB). Emit topics only.
204
+ brief["today_topics"] = [
205
+ {
206
+ "slug": t["slug"],
207
+ "title": t["title"],
208
+ "has_existing_coverage": False,
209
+ "covering_ids": [],
210
+ }
211
+ for t in today_topics
212
+ ]
213
+ return brief
214
+
215
+ try:
216
+ columns = _table_columns(conn, "learnings")
217
+ if not columns:
218
+ brief["today_topics"] = [
219
+ {
220
+ "slug": t["slug"],
221
+ "title": t["title"],
222
+ "has_existing_coverage": False,
223
+ "covering_ids": [],
224
+ }
225
+ for t in today_topics
226
+ ]
227
+ return brief
228
+
229
+ corpus_size = 0
230
+ shortlist: list[dict[str, Any]] = []
231
+ contradiction_pairs: list[dict[str, Any]] = []
232
+ stale_candidates: list[dict[str, Any]] = []
233
+ key_buckets: dict[str, list[int]] = {}
234
+ seen_shortlist_ids: set[int] = set()
235
+
236
+ for row in _iter_active_learnings(conn, columns):
237
+ corpus_size += 1
238
+ row_id = int(row.get("id") or 0)
239
+ row_title = str(row.get("title") or "")
240
+ row_content = str(row.get("content") or "")
241
+ row_applies = str(row.get("applies_to") or "")
242
+ row_text = f"{row_title} {row_content}".strip()
243
+
244
+ # (5) preference-key dedup — collapse colliding normalized keys.
245
+ key = learning_resolver.normalized_key(row_title, row_applies)
246
+ if key:
247
+ key_buckets.setdefault(key, []).append(row_id)
248
+
249
+ # (4) stale shortlist — weak/low-weight/never-guard-hit actives.
250
+ if len(stale_candidates) < max_stale:
251
+ weakness = _is_weak(row, columns)
252
+ if weakness:
253
+ stale_candidates.append(
254
+ {"id": row_id, "title": _preview(row_title, 120), "weakness": weakness}
255
+ )
256
+
257
+ # Relevance vs today's topics drives shortlist + coverage + contradiction.
258
+ relevant_to: list[dict[str, Any]] = []
259
+ for topic in today_topics:
260
+ related = bool(topic["_tokens"] & set(learning_resolver._tokenize(row_text)))
261
+ scoped = bool(
262
+ topic["_applies"]
263
+ and row_applies
264
+ and learning_resolver.applies_overlap(row_applies, topic["_applies"])
265
+ )
266
+ if not (related or scoped):
267
+ continue
268
+ sim = learning_resolver.candidate_similarity(topic["_text"], row_text)
269
+ if sim >= 0.55 or scoped:
270
+ relevant_to.append(topic)
271
+ if row_id:
272
+ topic["has_existing_coverage"] = True
273
+ # Cap example covering ids so a topic covered by hundreds of
274
+ # rules cannot balloon the brief; the boolean flag is what
275
+ # the LLM acts on.
276
+ if row_id not in topic["covering_ids"] and len(topic["covering_ids"]) < 10:
277
+ topic["covering_ids"].append(row_id)
278
+
279
+ # (6) contradiction pairs vs today-topics.
280
+ if len(contradiction_pairs) < max_contradictions and learning_resolver.looks_contradictory(
281
+ row_text, topic["_text"]
282
+ ):
283
+ contradiction_pairs.append(
284
+ {
285
+ "existing_id": row_id,
286
+ "existing_title": _preview(row_title, 120),
287
+ "with": "today_topic",
288
+ "snippet_a": _preview(row_text, 160),
289
+ "snippet_b": _preview(topic["_text"], 160),
290
+ "similarity": round(float(sim), 4),
291
+ }
292
+ )
293
+
294
+ if relevant_to and len(shortlist) < max_shortlist and row_id not in seen_shortlist_ids:
295
+ seen_shortlist_ids.add(row_id)
296
+ shortlist.append(
297
+ {
298
+ "id": row_id,
299
+ "title": _preview(row_title, 120),
300
+ "category": str(row.get("category") or ""),
301
+ "applies_to": row_applies,
302
+ "content_preview": _preview(row_content, 160),
303
+ }
304
+ )
305
+
306
+ # (5) preference-key dupes — only keys with 2+ colliding ids. Cap both the
307
+ # number of dupe groups and the ids listed per group so a pathological
308
+ # corpus (hundreds of identical-title rules) cannot balloon the brief.
309
+ preference_key_dupes = []
310
+ for key, ids in key_buckets.items():
311
+ if len(ids) <= 1:
312
+ continue
313
+ preference_key_dupes.append({"key": key, "ids": ids[:10], "total": len(ids)})
314
+ if len(preference_key_dupes) >= max_stale:
315
+ break
316
+
317
+ # (3) supersession stubs — today-topics that already have higher-authority
318
+ # coverage are candidates to be replaced by a canonical rule.
319
+ supersession_stubs: list[dict[str, Any]] = []
320
+ for topic in today_topics:
321
+ for old_id in topic["covering_ids"][:1]:
322
+ supersession_stubs.append(
323
+ {
324
+ "old_id": old_id,
325
+ "old_title": next(
326
+ (s["title"] for s in shortlist if s["id"] == old_id),
327
+ "",
328
+ ),
329
+ "reason": f"today topic '{topic['slug']}' may replace existing rule #{old_id}",
330
+ }
331
+ )
332
+
333
+ brief["corpus_size"] = corpus_size
334
+ brief["today_topics"] = [
335
+ {
336
+ "slug": t["slug"],
337
+ "title": t["title"],
338
+ "has_existing_coverage": bool(t["has_existing_coverage"]),
339
+ "covering_ids": list(t["covering_ids"]),
340
+ }
341
+ for t in today_topics
342
+ ]
343
+ brief["shortlist"] = shortlist
344
+ brief["contradiction_pairs"] = contradiction_pairs
345
+ brief["supersession_stubs"] = supersession_stubs
346
+ brief["stale_candidates"] = stale_candidates
347
+ brief["preference_key_dupes"] = preference_key_dupes
348
+ finally:
349
+ if own_conn:
350
+ try:
351
+ conn.close()
352
+ except Exception:
353
+ pass
354
+
355
+ # Enforce max_chars: drop lowest-priority items until the serialized brief is
356
+ # under budget. Stale candidates and supersession stubs are the first to go,
357
+ # then contradiction pairs (least relevant first), then shortlist tail.
358
+ def _size() -> int:
359
+ return len(json.dumps(brief, ensure_ascii=False))
360
+
361
+ if _size() > max_chars:
362
+ brief["truncated"] = True
363
+ trim_order = ("preference_key_dupes", "supersession_stubs", "stale_candidates")
364
+ for field in trim_order:
365
+ while brief[field] and _size() > max_chars:
366
+ brief[field].pop()
367
+ while len(brief["contradiction_pairs"]) > 1 and _size() > max_chars:
368
+ brief["contradiction_pairs"].pop()
369
+ while len(brief["shortlist"]) > 1 and _size() > max_chars:
370
+ brief["shortlist"].pop()
371
+ # Last resort: trim contradiction/shortlist to empty-ish.
372
+ while brief["contradiction_pairs"] and _size() > max_chars:
373
+ brief["contradiction_pairs"].pop()
374
+ while brief["shortlist"] and _size() > max_chars:
375
+ brief["shortlist"].pop()
376
+
377
+ return brief
378
+
379
+
380
+ __all__ = ["build_consolidation_brief"]
package/src/crons/sync.py CHANGED
@@ -727,13 +727,18 @@ def install_plist(label: str, plist: dict, plist_path: Path, dry_run: bool):
727
727
  log(f" DRY-RUN: would install {plist_path.name}")
728
728
  return
729
729
 
730
- with open(plist_path, "wb") as f:
731
- plistlib.dump(plist, f)
732
-
730
+ # Ephemeral/test runtimes (temp NEXO_HOME or HOME, e.g. a pytest run) must
731
+ # NOT touch the operator's real ~/Library/LaunchAgents. The guard is checked
732
+ # BEFORE writing the plist file: otherwise a test run rewrites the real
733
+ # plists with temp-dir ProgramArguments, and one reboot/reload silently
734
+ # kills the whole consolidation cron fleet (cron-fleet-drift incident).
733
735
  if not launchctl_side_effects_allowed():
734
- log(f" Installed but skipped launchctl in ephemeral runtime: {plist_path.name}")
736
+ log(f" Skipped plist write in ephemeral runtime: {plist_path.name}")
735
737
  return
736
738
 
739
+ with open(plist_path, "wb") as f:
740
+ plistlib.dump(plist, f)
741
+
737
742
  result = reload_launchagent_plist(plist_path, label=label)
738
743
  if result.get("action") == "skipped-ephemeral-runtime":
739
744
  log(f" Installed but skipped launchctl in ephemeral runtime: {plist_path.name}")
@@ -751,8 +756,8 @@ def unload_plist(plist_path: Path, dry_run: bool):
751
756
  return
752
757
 
753
758
  if not launchctl_side_effects_allowed():
754
- plist_path.unlink(missing_ok=True)
755
- log(f" Removed without launchctl in ephemeral runtime: {plist_path.name}")
759
+ # Ephemeral/test runtime: never delete the operator's real plists.
760
+ log(f" Skipped plist removal in ephemeral runtime: {plist_path.name}")
756
761
  return
757
762
 
758
763
  result = unload_launchagent_plist(plist_path)
@@ -830,7 +835,9 @@ def sync(dry_run: bool = False):
830
835
  return
831
836
 
832
837
  LOG_DIR.mkdir(parents=True, exist_ok=True)
833
- LAUNCH_AGENTS_DIR.mkdir(parents=True, exist_ok=True)
838
+ # In an ephemeral/test runtime, do not even create the real LaunchAgents dir.
839
+ if launchctl_side_effects_allowed():
840
+ LAUNCH_AGENTS_DIR.mkdir(parents=True, exist_ok=True)
834
841
 
835
842
  manifest_crons = load_manifest()
836
843
  manifest_ids = {c["id"] for c in manifest_crons}
@@ -106,6 +106,9 @@ from db._memory_v2 import (
106
106
  list_memory_observations,
107
107
  search_memory_observations_fts,
108
108
  backfill_memory_observations,
109
+ backfill_observation_embeddings,
110
+ vector_scan_observations,
111
+ get_memory_observations_by_uids,
109
112
  memory_observation_health,
110
113
  maintain_memory_observations,
111
114
  memory_observation_stats,