nexo-brain 7.30.6 → 7.30.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.30.6",
3
+ "version": "7.30.8",
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,11 @@
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.30.6` is the current packaged-runtime line. Patch release over v7.30.5 - Deep Sleep now rotates its operational artifacts and logs automatically, keeping historical installs bounded without touching local-context memory.
21
+ Version `7.30.8` is the current packaged-runtime line. Patch release over v7.30.7 - Deep Sleep now folds parallel Codex sub-agents into their parent thread and Local Context stops the `entity_facts` cartesian blow-up that created runaway sidecar databases.
22
+
23
+ Previously in `7.30.7`: patch release over v7.30.6 - the Deep Sleep retention update is republished with the required release smoke contract so final closeout, npm, GitHub, and runtime verification stay aligned.
24
+
25
+ Previously in `7.30.6`: patch release over v7.30.5 - Deep Sleep now rotates its operational artifacts and logs automatically, keeping historical installs bounded without touching local-context memory.
22
26
 
23
27
  Previously in `7.30.4`: patch release over v7.30.3 - local runtime update post-sync now gives bounded Memory Fabric repair enough time to finish, and headless automations now treat `nexo_stop` as a terminal close so followup/deep-sleep runners do not reopen no-op protocol loops.
24
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.30.6",
3
+ "version": "7.30.8",
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",
@@ -55,6 +55,10 @@ ENTITY_DOSSIER_MAX_ASSETS = int(os.environ.get("NEXO_ENTITY_DOSSIER_MAX_ASSETS",
55
55
  ENTITY_DOSSIER_MAX_CHUNKS = int(os.environ.get("NEXO_ENTITY_DOSSIER_MAX_CHUNKS", "1200") or "1200")
56
56
  ENTITY_DOSSIER_MAX_FACTS = int(os.environ.get("NEXO_ENTITY_DOSSIER_MAX_FACTS", "3000") or "3000")
57
57
  ENTITY_FACT_MIN_CONFIDENCE = float(os.environ.get("NEXO_ENTITY_FACT_MIN_CONFIDENCE", "0.45") or "0.45")
58
+ # Hard ceilings to stop the entity_facts cartesian blow-up (chunks × entities × candidates).
59
+ # Without these a single document could emit thousands of facts; 258k assets produced 337M rows / 255 GB.
60
+ ENTITY_FACTS_MAX_PER_ASSET = int(os.environ.get("NEXO_ENTITY_FACTS_MAX_PER_ASSET", "200") or "200")
61
+ ENTITY_FACT_MAX_VALUE_LEN = int(os.environ.get("NEXO_ENTITY_FACT_MAX_VALUE_LEN", "240") or "240")
58
62
  ENTITY_FACTS_LLM_ENABLED = os.environ.get("NEXO_ENTITY_FACTS_LLM_ENABLED", "1").strip().lower() not in {"0", "false", "no", "off"}
59
63
  LOCAL_PRESENCE_MODEL_SPEC = "qwen3-0.6b-q4-local-presence"
60
64
  FOREGROUND_GOVERNOR_ENABLED = os.environ.get("NEXO_LOCAL_INDEX_FOREGROUND_GOVERNOR", "1").strip().lower() not in {"0", "false", "no", "off"}
@@ -3133,28 +3137,42 @@ def _replace_entity_facts(conn, asset_id: str) -> int:
3133
3137
  ).fetchall()
3134
3138
  inserted = 0
3135
3139
  for chunk in chunks:
3140
+ if inserted >= ENTITY_FACTS_MAX_PER_ASSET:
3141
+ break
3136
3142
  text = str(chunk["text"] or "")
3137
3143
  if not text or contains_secret(text):
3138
3144
  continue
3139
3145
  candidates = _fact_candidate_lines(text)
3140
3146
  if not candidates:
3141
3147
  candidates = [("mencion", sentence.strip(), 0.48) for sentence in re.split(r"(?<=[.!?])\s+", text) if sentence.strip()][:4]
3148
+ chunk_id = str(chunk["chunk_id"] or "")
3142
3149
  for entity in entities_by_id.values():
3150
+ if inserted >= ENTITY_FACTS_MAX_PER_ASSET:
3151
+ break
3143
3152
  aliases = sorted(alias for alias in entity["aliases"] if alias)
3144
- direct = _chunk_mentions_entity(text, aliases)
3153
+ # Only attribute a chunk's facts to entities actually mentioned in THAT chunk.
3154
+ # Previously every candidate was attached to every entity in the asset (a
3155
+ # chunks × entities × candidates cartesian product) which produced 337M junk
3156
+ # rows / 255 GB. Gating on mention is both the size fix and the correctness fix.
3157
+ if not _chunk_mentions_entity(text, aliases):
3158
+ continue
3145
3159
  for predicate, value, base_confidence in candidates:
3146
- predicate = _strip_entity_aliases_from_predicate(predicate, aliases)
3147
- confidence = base_confidence if direct else min(base_confidence, 0.56)
3148
- if confidence < ENTITY_FACT_MIN_CONFIDENCE:
3160
+ if inserted >= ENTITY_FACTS_MAX_PER_ASSET:
3161
+ break
3162
+ # Drop paragraph-as-fact noise: real facts carry short values.
3163
+ if len(value) > ENTITY_FACT_MAX_VALUE_LEN:
3149
3164
  continue
3165
+ if base_confidence < ENTITY_FACT_MIN_CONFIDENCE:
3166
+ continue
3167
+ predicate = _strip_entity_aliases_from_predicate(predicate, aliases)
3150
3168
  if _insert_entity_fact(
3151
3169
  conn,
3152
3170
  entity_id=entity["entity_id"],
3153
3171
  predicate=predicate,
3154
3172
  value=value,
3155
3173
  source_asset_id=asset_id,
3156
- source_chunk_id=str(chunk["chunk_id"] or ""),
3157
- confidence=confidence,
3174
+ source_chunk_id=chunk_id,
3175
+ confidence=base_confidence,
3158
3176
  ):
3159
3177
  inserted += 1
3160
3178
  return inserted
@@ -155,6 +155,117 @@ def collect_transcripts_since(since_iso: str, until_iso: str = "") -> list[dict]
155
155
  return _transcripts.collect_transcripts_since(since_iso, until_iso)
156
156
 
157
157
 
158
+ # ── Fold parallel sub-agent threads into their parent ──────────────────────
159
+
160
+
161
+ def _is_subagent(session: dict) -> bool:
162
+ """True when a session was spawned as a sub-agent thread of another session."""
163
+ if str(session.get("thread_source", "")).strip().lower() == "subagent":
164
+ return True
165
+ if str(session.get("parent_thread_id", "") or "").strip():
166
+ return True
167
+ source = session.get("source")
168
+ return isinstance(source, dict) and "subagent" in source
169
+
170
+
171
+ def _root_thread_key(session: dict, by_uid: dict[str, dict]) -> str:
172
+ """Resolve the top-of-tree thread for a session, following parent links.
173
+
174
+ Sub-agent rollouts carry ``parent_thread_id``; we walk up until we reach a
175
+ session with no parent (the real top-level thread). When the parent is not
176
+ part of this batch we still group siblings under the parent id so several
177
+ explorers spawned by the same (absent) parent collapse together. The walk is
178
+ bounded so a malformed/cyclic chain can never loop forever.
179
+ """
180
+ cur = session
181
+ for _ in range(16):
182
+ parent = str(cur.get("parent_thread_id", "") or "").strip()
183
+ if not parent:
184
+ break
185
+ nxt = by_uid.get(parent)
186
+ if nxt is None or nxt is cur:
187
+ return parent
188
+ cur = nxt
189
+ return str(cur.get("session_uid", "") or cur.get("session_file", ""))
190
+
191
+
192
+ def dedupe_sessions(sessions: list[dict]) -> tuple[list[dict], list[dict]]:
193
+ """Fold parallel sub-agent threads into their parent so each real thread is
194
+ analyzed and counted once instead of once per spawned explorer.
195
+
196
+ Sessions are grouped by their root thread (see :func:`_root_thread_key`).
197
+ Within a group the actual parent session is kept as the canonical thread
198
+ (falling back to a non-sub-agent member, then the earliest one); the folded
199
+ sub-agent transcripts are appended to the canonical session — so no content
200
+ is lost — and their ids/nicknames are recorded on the kept session
201
+ (``folded_subagents``) and in the returned report.
202
+
203
+ Returns ``(kept_sessions, dedupe_report)``. Distinct top-level threads are
204
+ never merged.
205
+ """
206
+ by_uid: dict[str, dict] = {}
207
+ for session in sessions:
208
+ uid = str(session.get("session_uid", "") or "").strip()
209
+ if uid:
210
+ by_uid.setdefault(uid, session)
211
+
212
+ groups: dict[str, list[dict]] = {}
213
+ order: list[str] = []
214
+ for session in sessions:
215
+ key = _root_thread_key(session, by_uid)
216
+ if key not in groups:
217
+ groups[key] = []
218
+ order.append(key)
219
+ groups[key].append(session)
220
+
221
+ kept: list[dict] = []
222
+ report: list[dict] = []
223
+ for key in order:
224
+ members = groups[key]
225
+ if len(members) == 1:
226
+ kept.append(members[0])
227
+ continue
228
+ representative = next(
229
+ (m for m in members if str(m.get("session_uid", "") or "") == key), None
230
+ )
231
+ if representative is None:
232
+ representative = next((m for m in members if not _is_subagent(m)), None)
233
+ if representative is None:
234
+ representative = min(members, key=lambda m: str(m.get("modified", "")))
235
+
236
+ folded = [m for m in members if m is not representative]
237
+ rep_messages = representative.setdefault("messages", [])
238
+ rep_tools = representative.setdefault("tool_uses", [])
239
+ for child in folded:
240
+ label = child.get("agent_nickname") or child["session_file"]
241
+ role = child.get("agent_role") or "subagent"
242
+ rep_messages.append({
243
+ "role": "user",
244
+ "index": 0,
245
+ "text": f"──── folded sub-agent thread: {label} ({role}) — {child['session_file']} ────",
246
+ })
247
+ rep_messages.extend(child.get("messages") or [])
248
+ rep_tools.extend(child.get("tool_uses") or [])
249
+ representative["message_count"] = len(rep_messages)
250
+ representative["tool_use_count"] = len(rep_tools)
251
+ representative["folded_subagents"] = [
252
+ {
253
+ "session_file": m["session_file"],
254
+ "agent_nickname": m.get("agent_nickname", ""),
255
+ "agent_role": m.get("agent_role", ""),
256
+ }
257
+ for m in folded
258
+ ]
259
+ kept.append(representative)
260
+ report.append({
261
+ "root_thread": key,
262
+ "kept": representative["session_file"],
263
+ "folded": [m["session_file"] for m in folded],
264
+ "count": len(members),
265
+ })
266
+ return kept, report
267
+
268
+
158
269
  # ── Database queries ──────────────────────────────────────────────────────
159
270
 
160
271
 
@@ -818,6 +929,17 @@ def main():
818
929
  sessions = collect_transcripts_since(fallback_since)
819
930
  print(f" Found {len(sessions)} sessions")
820
931
 
932
+ # Fold parallel sub-agent rollouts into their parent thread so a single
933
+ # logical thread is not analyzed (and counted) once per spawned explorer,
934
+ # which otherwise inflates the finding count.
935
+ sessions, dedupe_report = dedupe_sessions(sessions)
936
+ folded_total = sum(len(item["folded"]) for item in dedupe_report)
937
+ if folded_total:
938
+ print(
939
+ f" Folded {folded_total} sub-agent session(s) into "
940
+ f"{len(dedupe_report)} parent thread(s); {len(sessions)} unique threads remain"
941
+ )
942
+
821
943
  if not sessions:
822
944
  print(f"[collect] No new sessions found. Writing minimal context file.")
823
945
  output_file = DEEP_SLEEP_DIR / f"{run_id}-context.txt"
@@ -959,9 +1081,12 @@ def main():
959
1081
  "source": s.get("source", ""),
960
1082
  "session_path": s.get("session_path", ""),
961
1083
  "session_txt_file": session_txt_map.get(s["session_file"], ""),
1084
+ "folded_subagents": s.get("folded_subagents", []),
962
1085
  }
963
1086
  for s in sessions
964
1087
  ],
1088
+ "sessions_folded": folded_total,
1089
+ "dedupe_report": dedupe_report,
965
1090
  "total_messages": sum(s["message_count"] for s in sessions),
966
1091
  "total_tool_uses": sum(s["tool_use_count"] for s in sessions),
967
1092
  "followups_active": len(followups),
@@ -208,6 +208,9 @@ def extract_claude_session(jsonl_path: Path, *, min_user_messages: int = MIN_USE
208
208
  "messages": messages,
209
209
  "tool_uses": tool_uses,
210
210
  "source": "claude_projects",
211
+ "session_uid": jsonl_path.stem,
212
+ "thread_source": "user",
213
+ "parent_thread_id": "",
211
214
  }
212
215
 
213
216
 
@@ -216,6 +219,7 @@ def extract_codex_session(jsonl_path: Path, *, min_user_messages: int = MIN_USER
216
219
  tool_uses = []
217
220
  user_msg_count = 0
218
221
  session_meta: dict = {}
222
+ spawn_meta: dict = {}
219
223
 
220
224
  try:
221
225
  with open(jsonl_path, "r") as f:
@@ -232,7 +236,16 @@ def extract_codex_session(jsonl_path: Path, *, min_user_messages: int = MIN_USER
232
236
  data = payload.get("payload", {})
233
237
 
234
238
  if item_type == "session_meta" and isinstance(data, dict):
235
- session_meta = data
239
+ # A sub-agent rollout embeds two session_meta records: its
240
+ # own first, then the parent it forked from. Keep the FIRST
241
+ # as this thread's identity (last-wins would mislabel the
242
+ # sub-agent as its parent) and remember whichever record
243
+ # carries the sub-agent spawn linkage.
244
+ if not session_meta:
245
+ session_meta = data
246
+ src = data.get("source")
247
+ if not spawn_meta and isinstance(src, dict) and isinstance(src.get("subagent"), dict):
248
+ spawn_meta = data
236
249
  continue
237
250
 
238
251
  if item_type == "event_msg" and isinstance(data, dict) and data.get("type") == "user_message":
@@ -280,6 +293,17 @@ def extract_codex_session(jsonl_path: Path, *, min_user_messages: int = MIN_USER
280
293
  if user_msg_count < _min_user_messages(min_user_messages):
281
294
  return None
282
295
 
296
+ spawn_source = (spawn_meta or session_meta).get("source")
297
+ thread_spawn: dict = {}
298
+ if isinstance(spawn_source, dict) and isinstance(spawn_source.get("subagent"), dict):
299
+ thread_spawn = spawn_source["subagent"].get("thread_spawn") or {}
300
+ parent_thread_id = str(
301
+ thread_spawn.get("parent_thread_id", "")
302
+ or session_meta.get("forked_from_id", "")
303
+ or ""
304
+ )
305
+ is_subagent = bool(thread_spawn) or str(session_meta.get("thread_source", "")).lower() == "subagent"
306
+
283
307
  return {
284
308
  "client": "codex",
285
309
  "session_file": _session_identifier("codex", jsonl_path.name),
@@ -294,6 +318,10 @@ def extract_codex_session(jsonl_path: Path, *, min_user_messages: int = MIN_USER
294
318
  "cwd": session_meta.get("cwd", ""),
295
319
  "originator": session_meta.get("originator", ""),
296
320
  "session_uid": session_meta.get("id", ""),
321
+ "thread_source": "subagent" if is_subagent else (session_meta.get("thread_source", "") or "user"),
322
+ "parent_thread_id": parent_thread_id,
323
+ "agent_nickname": str(session_meta.get("agent_nickname", "") or thread_spawn.get("agent_nickname", "") or ""),
324
+ "agent_role": str(session_meta.get("agent_role", "") or thread_spawn.get("agent_role", "") or ""),
297
325
  }
298
326
 
299
327