nexo-brain 7.32.0 → 7.34.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.32.0",
3
+ "version": "7.34.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.32.0` is the current packaged-runtime line. Minor release - Cognitive OS Ola 1: the causal/provenance graph now populates from every evidence-backed task close (the connect-the-dots substrate that was previously empty), and abandoned durable workflows are reaped so the cross-session resume surface stays clean. Bundles the 7.31.14 critical fixes (cron-fleet drift guard, deterministic spreading-activation id, real guard_context evidence, auto error->learning capture, fulfillable confidence-check gate, approval-paused workflow resume).
21
+ Version `7.34.0` is the current packaged-runtime line. Minor release - Cognitive OS Ola 2: a working-memory `resolution_cache` fast-path avoids re-resolving what was just resolved (never-stale, fail-closed: content-snapshot + global watermark + 15-min TTP, repo-map for code), a later action that reveals a prior self-error auto-captures a learning + prevention, the associative graph (Personalized PageRank) connects the dots multi-hop over the KG at answer time (anti-hub, fail-open, per-process cache), Deep Sleep gains a nightly phase that safely merges duplicate learnings (reversible, zero hard-delete, fail-closed backup, daily cap), and a reproducible memory-recall eval bank (recall@k/MRR) lands with a baseline. Builds on v7.33.0 (semantic recall + graph-at-answer + reliability).
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.32.0",
3
+ "version": "7.34.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"]
@@ -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,
@@ -165,7 +168,7 @@ from db._entities import (
165
168
  # Episodic memory
166
169
  from db._episodic import (
167
170
  cleanup_old_changes, change_log_retention_days, change_log_retention_policy,
168
- log_change, search_changes, update_change_commit, auto_resolve_followups,
171
+ log_change, search_changes, get_change_watermark, update_change_commit, auto_resolve_followups,
169
172
  cleanup_old_decisions, log_decision, update_decision_outcome,
170
173
  get_memory_review_queue, find_decisions_by_context_ref, search_decisions,
171
174
  cleanup_old_diaries, write_session_diary,
@@ -194,6 +197,7 @@ from db._protocol import (
194
197
  VALID_TASK_TYPES,
195
198
  VALID_CLOSE_OUTCOMES,
196
199
  create_protocol_task, get_protocol_task, close_protocol_task,
200
+ list_recent_closed_tasks,
197
201
  set_protocol_task_guard_acknowledged,
198
202
  create_protocol_debt, resolve_protocol_debts, list_protocol_debts,
199
203
  record_session_correction_requirement, list_session_correction_requirements,
@@ -93,6 +93,38 @@ def search_changes(query: str = '', files: str = '', days: int = 30) -> list[dic
93
93
  return [dict(r) for r in rows]
94
94
 
95
95
 
96
+ def get_change_watermark(sid: str | None = None) -> int:
97
+ """Cheap monotonic integer that rises whenever a relevant mutation lands.
98
+
99
+ Used by the resolution cache (working memory) as the "nothing changed"
100
+ invalidation signal — Francisco's third rule. ``change_log`` is the ledger
101
+ where the PostToolUse hook records every code/config/state mutation, so
102
+ ``MAX(id)`` is a one-SELECT, monotonic, append-only proxy for "did anything
103
+ change since I cached this answer?". If the watermark advanced, the cache
104
+ is invalidated by conservatism (prefer recomputing over serving stale).
105
+
106
+ ``sid`` optionally narrows the watermark to a single session's mutations.
107
+ Returns 0 when the ledger is empty or unavailable (which a fresh cache
108
+ entry will also store, so an empty ledger never spuriously invalidates).
109
+ """
110
+ try:
111
+ conn = get_db()
112
+ if sid:
113
+ row = conn.execute(
114
+ "SELECT MAX(id) FROM change_log WHERE session_id = ?", (str(sid),)
115
+ ).fetchone()
116
+ else:
117
+ row = conn.execute("SELECT MAX(id) FROM change_log").fetchone()
118
+ except Exception:
119
+ return 0
120
+ if not row or row[0] is None:
121
+ return 0
122
+ try:
123
+ return int(row[0])
124
+ except (TypeError, ValueError):
125
+ return 0
126
+
127
+
96
128
  def auto_resolve_followups(change: dict) -> list[str]:
97
129
  """Cross-reference a change_log entry with open followups. Auto-completes matches.
98
130