nexo-brain 7.17.0 → 7.17.2

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.17.0",
3
+ "version": "7.17.2",
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.17.0` is the current packaged-runtime line. Minor release over v7.16.3 - the headless runner pre-emptive guard becomes advisory: it surfaces learnings/schemas to the agent and logs to `guard_checks`, but never returns `blocked=True`. The PreToolUse hook is the authoritative gate at write time. This closes the family of bugs where heuristic path matches in the prompt aborted email-monitor sessions, followup-runner cycles, Deep Sleep synth, and postmortem-consolidation. Also rolls in the directory-path hardening planned for 7.16.4.
21
+ Version `7.17.2` is the current packaged-runtime line. Patch release over v7.17.1 - email-monitor now guards its `/tmp/nexo-*` draft buffers before writing, morning-agent closes interrupted/stale briefing claims deterministically, and Codex managed config migrates from the legacy `codex_hooks` flag to `[features].hooks`.
22
+
23
+ Previously in `7.17.1`: patch release over v7.17.0 - the headless Claude CLI 2.1+ direct-JSON response shape is now handled: when the wrapper `{"result": ...}` is absent and the agent's answer is returned directly, `_extract_claude_telemetry` surfaces the full payload to the caller instead of an empty string. Fixes the daily morning-agent failure with "Morning agent returned invalid JSON output".
24
+
25
+ Previously in `7.17.0`: minor release over v7.16.3 - the headless runner pre-emptive guard becomes advisory: it surfaces learnings/schemas to the agent and logs to `guard_checks`, but never returns `blocked=True`. The PreToolUse hook is the authoritative gate at write time.
22
26
 
23
27
  Previously in `7.16.3`: patch release over v7.16.2 - the headless runner guard opts out of the runtime-core blocking rule because actual writes on those paths are already blocked at the PreToolUse layer.
24
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.17.0",
3
+ "version": "7.17.2",
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",
@@ -94,15 +94,32 @@ def _extract_claude_telemetry(raw_stdout: str, *, requested_output_format: str)
94
94
  "warnings": ["backend did not return parseable JSON telemetry"],
95
95
  }
96
96
 
97
- result_payload = payload.get("result", "")
97
+ # Two shapes can arrive in raw_stdout:
98
+ # (a) Classic Claude CLI wrapper:
99
+ # {"result": "<agent text or stringified JSON>", "usage": {...}, "total_cost_usd": N}
100
+ # (b) Direct agent JSON (Claude CLI 2.1+ with bare_mode + output_format=json
101
+ # + a prompt that requests raw JSON only). The wrapper is dropped and
102
+ # the entire payload IS the agent's answer, e.g. {"subject":..., "body":...}.
103
+ # Pre-7.17.1 only handled (a): payload.get("result", "") returned "" in case (b),
104
+ # which left result.stdout empty for the caller. morning-agent then raised
105
+ # "Morning agent returned invalid JSON output" on every cron tick even though
106
+ # the agent had answered correctly and the answer was already persisted in
107
+ # automation_runs.metadata.raw. The branch below normalises both shapes.
108
+ if "result" in payload:
109
+ result_payload = payload["result"]
110
+ telemetry_payload = payload
111
+ else:
112
+ result_payload = payload
113
+ telemetry_payload = {}
114
+
98
115
  if requested_output_format and requested_output_format.lower() == "json" and not isinstance(result_payload, str):
99
116
  final_stdout = json.dumps(result_payload, ensure_ascii=False)
100
117
  else:
101
118
  final_stdout = result_payload if isinstance(result_payload, str) else json.dumps(result_payload, ensure_ascii=False)
102
119
 
103
- usage = payload.get("usage") or {}
104
- model_usage = payload.get("modelUsage") or {}
105
- explicit_cost = payload.get("total_cost_usd")
120
+ usage = telemetry_payload.get("usage") or {}
121
+ model_usage = telemetry_payload.get("modelUsage") or {}
122
+ explicit_cost = telemetry_payload.get("total_cost_usd")
106
123
  if explicit_cost is None and isinstance(model_usage, dict):
107
124
  explicit_cost = sum(
108
125
  float((item or {}).get("costUSD") or 0.0)
@@ -639,9 +639,10 @@ def _sync_codex_managed_config(
639
639
 
640
640
  features = payload.setdefault("features", {})
641
641
  if isinstance(features, dict):
642
- features["codex_hooks"] = True
642
+ features["hooks"] = True
643
+ features.pop("codex_hooks", None)
643
644
  else:
644
- payload["features"] = {"codex_hooks": True}
645
+ payload["features"] = {"hooks": True}
645
646
 
646
647
  payload["initial_messages"] = [
647
648
  {
@@ -34,6 +34,7 @@ from __future__ import annotations
34
34
  import argparse
35
35
  import json
36
36
  import os
37
+ import signal
37
38
  import subprocess
38
39
  import sys
39
40
  import tempfile
@@ -67,11 +68,12 @@ LOG_FILE = LOG_DIR / "morning-agent.log"
67
68
  STATE_FILE = data_dir() / "morning-agent-state.json"
68
69
  LATEST_BRIEFING_FILE = operations_dir() / "morning-briefing-latest.md"
69
70
  CALLER = "morning_agent"
70
- CLI_TIMEOUT = 1800
71
+ CLI_TIMEOUT = 1500
71
72
  MAX_DUE_ITEMS = 8
72
73
  MAX_ACTIVE_ITEMS = 8
73
74
  MAX_DIARY_ITEMS = 6
74
75
  MORNING_BRIEFING_STALE_HOURS = 12
76
+ _ACTIVE_CLAIM: dict[str, str] = {}
75
77
 
76
78
 
77
79
  def log(message: str) -> None:
@@ -149,6 +151,27 @@ def _briefing_run_is_stale(row: dict) -> bool:
149
151
  return True
150
152
 
151
153
 
154
+ def _mark_stale_morning_briefing_failed(conn, row: dict, *, now: str) -> None:
155
+ conn.execute(
156
+ """
157
+ UPDATE morning_briefing_runs
158
+ SET status = 'failed',
159
+ error = ?,
160
+ finished_at = COALESCE(finished_at, ?),
161
+ updated_at = ?
162
+ WHERE local_date = ? AND recipient = ? AND status = 'in_progress'
163
+ """,
164
+ (
165
+ "stale in_progress reconciled before retry: parent process likely interrupted before completion",
166
+ now,
167
+ now,
168
+ str(row.get("local_date") or ""),
169
+ str(row.get("recipient") or ""),
170
+ ),
171
+ )
172
+ conn.commit()
173
+
174
+
152
175
  def _claim_morning_briefing_send(local_date: str, recipient: str, *, force: bool = False) -> dict:
153
176
  clean_date = str(local_date or "").strip()
154
177
  clean_recipient = str(recipient or "").strip()
@@ -194,7 +217,10 @@ def _claim_morning_briefing_send(local_date: str, recipient: str, *, force: bool
194
217
  (clean_date, clean_recipient),
195
218
  ).fetchone())
196
219
  status = str(row.get("status") or "").strip().lower()
197
- if status == "failed" or (status == "in_progress" and _briefing_run_is_stale(row)):
220
+ stale_retry = status == "in_progress" and _briefing_run_is_stale(row)
221
+ if stale_retry:
222
+ _mark_stale_morning_briefing_failed(conn, row, now=now)
223
+ if status == "failed" or stale_retry:
198
224
  conn.execute(
199
225
  """
200
226
  UPDATE morning_briefing_runs
@@ -210,7 +236,12 @@ def _claim_morning_briefing_send(local_date: str, recipient: str, *, force: bool
210
236
  (now, now, clean_date, clean_recipient),
211
237
  )
212
238
  conn.commit()
213
- return {"ok": True, "acquired": True, "reason": "retry"}
239
+ return {
240
+ "ok": True,
241
+ "acquired": True,
242
+ "reason": "retry_stale" if stale_retry else "retry",
243
+ "previous_run": row,
244
+ }
214
245
  return {"ok": True, "acquired": False, "reason": status or "already claimed", "run": row}
215
246
 
216
247
 
@@ -275,6 +306,38 @@ def _mark_morning_briefing_failed(local_date: str, recipient: str, *, error: str
275
306
  conn.commit()
276
307
 
277
308
 
309
+ def _set_active_claim(local_date: str, recipient: str) -> None:
310
+ _ACTIVE_CLAIM.clear()
311
+ if local_date and recipient:
312
+ _ACTIVE_CLAIM.update({"local_date": str(local_date), "recipient": str(recipient)})
313
+
314
+
315
+ def _clear_active_claim() -> None:
316
+ _ACTIVE_CLAIM.clear()
317
+
318
+
319
+ def _handle_shutdown_signal(signum, _frame) -> None:
320
+ local_date = _ACTIVE_CLAIM.get("local_date", "")
321
+ recipient = _ACTIVE_CLAIM.get("recipient", "")
322
+ signal_name = getattr(signal.Signals(signum), "name", f"SIG{signum}")
323
+ if local_date and recipient:
324
+ try:
325
+ _mark_morning_briefing_failed(
326
+ local_date,
327
+ recipient,
328
+ error=f"interrupted before completion: {signal_name}",
329
+ )
330
+ except Exception as exc:
331
+ log(f"Failed to mark morning briefing interrupted by {signal_name}: {exc}")
332
+ log(f"Morning agent interrupted by {signal_name}.")
333
+ raise SystemExit(128 + int(signum))
334
+
335
+
336
+ def _install_shutdown_signal_handlers() -> None:
337
+ signal.signal(signal.SIGTERM, _handle_shutdown_signal)
338
+ signal.signal(signal.SIGINT, _handle_shutdown_signal)
339
+
340
+
278
341
  def resolve_recipient(profile: dict | None = None, *, explicit_to: str = "") -> str:
279
342
  override = str(explicit_to or "").strip()
280
343
  if override:
@@ -575,6 +638,7 @@ def build_parser() -> argparse.ArgumentParser:
575
638
 
576
639
  def main(argv: list[str] | None = None) -> int:
577
640
  args = build_parser().parse_args(argv)
641
+ _install_shutdown_signal_handlers()
578
642
  contract = get_script_runtime_contract("morning-agent")
579
643
  if not args.dry_run and not contract.get("available", True):
580
644
  log(f"Runtime blocked: {contract.get('blocked_reason') or 'missing prerequisite'}")
@@ -597,8 +661,10 @@ def main(argv: list[str] | None = None) -> int:
597
661
  if not claim.get("acquired"):
598
662
  log(f"Morning briefing already handled today for {recipient}.")
599
663
  return 0
664
+ _set_active_claim(today, recipient)
600
665
  elif args.force and not args.dry_run:
601
666
  _claim_morning_briefing_send(today, recipient, force=True)
667
+ _set_active_claim(today, recipient)
602
668
 
603
669
  try:
604
670
  context = collect_context(profile)
@@ -617,6 +683,7 @@ def main(argv: list[str] | None = None) -> int:
617
683
  log(f"Sending morning briefing to {recipient}...")
618
684
  send_output = send_briefing(recipient=recipient, subject=subject, body=body)
619
685
  _mark_morning_briefing_sent(today, recipient, subject=subject, send_output=send_output)
686
+ _clear_active_claim()
620
687
  save_state({
621
688
  "last_sent_date": today,
622
689
  "last_sent_at": datetime.now().astimezone().isoformat(),
@@ -629,11 +696,13 @@ def main(argv: list[str] | None = None) -> int:
629
696
  except AutomationBackendUnavailableError as exc:
630
697
  if not args.dry_run and recipient:
631
698
  _mark_morning_briefing_failed(today, recipient, error=str(exc))
699
+ _clear_active_claim()
632
700
  log(f"Automation backend unavailable: {exc}")
633
701
  return 1
634
702
  except Exception as exc:
635
703
  if not args.dry_run and recipient:
636
704
  _mark_morning_briefing_failed(today, recipient, error=str(exc))
705
+ _clear_active_claim()
637
706
  log(f"Morning agent failed: {exc}")
638
707
  return 1
639
708
 
@@ -133,13 +133,21 @@ If it is a duplicate: mark `skipped`, keep it SEEN in IMAP, and continue.
133
133
  If the operator is missing from every field, add [[send_reply_target]] to CC.
134
134
  Operator aliases to recognise and prioritise: [[operator_aliases_label]]
135
135
 
136
+ == TEMP BUFFER WRITE SAFETY ==
137
+ Before the first Write/Edit to any `/tmp/nexo-*.txt` reply buffer for a thread, call:
138
+ `nexo_guard_check(files="/tmp/nexo-reply-UID.txt,/tmp/nexo-quote-UID.txt,/tmp/nexo-thread-UID.txt", area="email-monitor")`
139
+
140
+ Replace `UID` with the exact UID or stable suffix you will use for that thread. Use that same suffix consistently for the reply, quote, and full-thread files. If no IMAP UID is available, derive one stable safe suffix from the Message-ID or thread ID; do not use unsuffixed `/tmp/nexo-reply.txt`, `/tmp/nexo-quote.txt`, or `/tmp/nexo-thread.txt` for a new write.
141
+
142
+ This guard call must happen before creating or editing the buffer files. Do not use an allowlist and do not skip this for temporary files.
143
+
136
144
  == KEEP THE FULL RELATED HISTORY ==
137
145
  When replying, the email MUST include the COMPLETE related history below,
138
146
  not just the immediate thread.
139
147
  Mandatory steps before sending:
140
148
  1. Reuse the MERGED TIMELINE from `nexo_email_related(uid)` as the source of truth.
141
149
  2. Sort it chronologically (oldest first).
142
- 3. Concatenate it into `/tmp/nexo-thread-N.txt` with this format for each message:
150
+ 3. Concatenate it into `/tmp/nexo-thread-UID.txt` with this format for each message:
143
151
  -- From: Name <email>
144
152
  -- Date: YYYY-MM-DD HH:MM
145
153
  -- Subject: Re: ...
@@ -147,14 +155,14 @@ Mandatory steps before sending:
147
155
  [message body]
148
156
 
149
157
  (separator between messages: one blank line)
150
- 4. Save the immediate message body (the one you are replying to) into `/tmp/nexo-quote-N.txt`.
158
+ 4. Save the immediate message body (the one you are replying to) into `/tmp/nexo-quote-UID.txt`.
151
159
  5. If there are relevant files in RELATED FILES, reuse those local paths directly.
152
160
  Do NOT lose older attachments just because they were included earlier in the same context.
153
161
  6. Use BOTH: `--quote-file` for the immediate quote + `--thread-file` for the full related history.
154
162
  The bottom of the email must preserve message -> reply -> message -> reply without dropping previous answers.
155
163
 
156
164
  == SEND VIA `nexo-send-reply.py` ==
157
- [[python_executable]] [[send_reply_script]] --to X --cc Y --subject 'Re: Z' --in-reply-to '<msgid>' --references '<refs>' --body-file /tmp/nexo-reply.txt --quote-file /tmp/nexo-quote.txt --quote-from 'Name <email>' --quote-date 'date' --thread-file /tmp/nexo-thread.txt [--attach /path/to/file]
165
+ [[python_executable]] [[send_reply_script]] --to X --cc Y --subject 'Re: Z' --in-reply-to '<msgid>' --references '<refs>' --body-file /tmp/nexo-reply-UID.txt --quote-file /tmp/nexo-quote-UID.txt --quote-from 'Name <email>' --quote-date 'date' --thread-file /tmp/nexo-thread-UID.txt [--attach /path/to/file]
158
166
 
159
167
  == ANTI-LOOP PROTECTION ==
160
168
  Do not reply to auto-replies, [[agent_email_label]] itself, `noreply@`,