nexo-brain 7.32.0 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/consolidation_prep.py +380 -0
- package/src/db/__init__.py +3 -0
- package/src/db/_memory_v2.py +276 -0
- package/src/db/_schema.py +102 -0
- package/src/hooks/auto_capture.py +60 -24
- package/src/learning_resolver.py +42 -0
- package/src/local_context/api.py +237 -33
- package/src/local_context/db.py +3 -2
- package/src/memory_retrieval.py +96 -7
- package/src/plugins/protocol.py +23 -24
- package/src/pre_answer_router.py +77 -0
- package/src/scripts/nexo-followup-runner.py +110 -8
- package/src/scripts/nexo-postmortem-consolidator.py +44 -1
- package/templates/core-prompts/postmortem-consolidator.md +29 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.
|
|
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.
|
|
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.
|
|
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",
|
|
@@ -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/db/__init__.py
CHANGED
|
@@ -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,
|