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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +5 -1
- package/package.json +1 -1
- package/src/local_context/api.py +24 -6
- package/src/scripts/deep-sleep/collect.py +125 -0
- package/src/transcript_utils.py +29 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.30.
|
|
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.
|
|
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.
|
|
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",
|
package/src/local_context/api.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
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=
|
|
3157
|
-
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),
|
package/src/transcript_utils.py
CHANGED
|
@@ -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
|
|
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
|
|