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.
|
|
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.
|
|
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.
|
|
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",
|
package/src/agent_runner.py
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
104
|
-
model_usage =
|
|
105
|
-
explicit_cost =
|
|
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)
|
package/src/client_sync.py
CHANGED
|
@@ -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["
|
|
642
|
+
features["hooks"] = True
|
|
643
|
+
features.pop("codex_hooks", None)
|
|
643
644
|
else:
|
|
644
|
-
payload["features"] = {"
|
|
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 =
|
|
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
|
-
|
|
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 {
|
|
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-
|
|
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-
|
|
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@`,
|