nexo-brain 7.30.14 → 7.30.15

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.14",
3
+ "version": "7.30.15",
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,9 @@
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.14` is the current packaged-runtime line. Patch release over v7.30.13 - support tickets, provider capability discovery, task-close rearming, internal audit followups, and the memory-observation watchdog are aligned for Desktop-managed agents.
21
+ Version `7.30.15` is the current packaged-runtime line. Patch release over v7.30.14 - Email NEXO reply lifecycle closure now handles affirmative actionable replies and prevents already-closed processed threads from reopening.
22
+
23
+ Previously in `7.30.14`: patch release over v7.30.13 - support tickets, provider capability discovery, task-close rearming, internal audit followups, and the memory-observation watchdog are aligned for Desktop-managed agents.
22
24
 
23
25
  Previously in `7.30.13`: patch release over v7.30.12 - Email NEXO monitor prompts now require a full IMAP body fetch before replying, and managed email defaults use the MXroute `Sent` folder instead of `INBOX.Sent`.
24
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.30.14",
3
+ "version": "7.30.15",
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",
@@ -109,6 +109,8 @@ CREATE INDEX IF NOT EXISTS idx_ee_email ON email_events(email_id);
109
109
  CREATE INDEX IF NOT EXISTS idx_ee_event ON email_events(event);
110
110
  CREATE INDEX IF NOT EXISTS idx_ee_ts ON email_events(timestamp);
111
111
  """
112
+ ACTION_CLOSURE_EVENTS = ("action_done", "resolution")
113
+ SENT_REPLY_EVENTS = ("action_done", "resolution", "replied")
112
114
  EMAIL_LOOP_GUARD_SQL = """
113
115
  CREATE TABLE IF NOT EXISTS email_loop_guards (
114
116
  thread_key TEXT PRIMARY KEY,
@@ -902,20 +904,74 @@ def _has_active_processing_within(conn, email_id, *, hours=ZOMBIE_TIMEOUT_HOURS)
902
904
 
903
905
 
904
906
  def _has_action_done_after(conn, email_id, reference_ts):
907
+ return _has_email_event_after(conn, email_id, ACTION_CLOSURE_EVENTS, reference_ts)
908
+
909
+
910
+ def _has_email_event_after(conn, email_id, events, reference_ts):
911
+ if not email_id or not reference_ts:
912
+ return False
913
+ event_list = tuple(events or ())
914
+ if not event_list:
915
+ return False
916
+ placeholders = ",".join("?" for _ in event_list)
905
917
  row = conn.execute(
906
- """
918
+ f"""
919
+ SELECT 1
920
+ FROM email_events
921
+ WHERE email_id = ?
922
+ AND event IN ({placeholders})
923
+ AND datetime(replace(timestamp, 'T', ' ')) > datetime(replace(?, 'T', ' '))
924
+ LIMIT 1
925
+ """,
926
+ (email_id, *event_list, reference_ts),
927
+ ).fetchone()
928
+ return bool(row)
929
+
930
+
931
+ def _has_sent_reply_event(conn, email_id):
932
+ if not email_id:
933
+ return False
934
+ placeholders = ",".join("?" for _ in SENT_REPLY_EVENTS)
935
+ row = conn.execute(
936
+ f"""
907
937
  SELECT 1
908
938
  FROM email_events
909
939
  WHERE email_id = ?
910
- AND event IN ('action_done', 'resolution')
911
- AND timestamp > ?
940
+ AND event IN ({placeholders})
912
941
  LIMIT 1
913
942
  """,
914
- (email_id, reference_ts),
943
+ (email_id, *SENT_REPLY_EVENTS),
915
944
  ).fetchone()
916
945
  return bool(row)
917
946
 
918
947
 
948
+ def _mark_processing_email_processed(conn, email_id, *, completed_at):
949
+ cols = _email_table_columns(conn)
950
+ updates = [
951
+ "status = 'processed'",
952
+ "started_at = NULL",
953
+ ]
954
+ params = []
955
+ if "completed_at" in cols:
956
+ updates.append(
957
+ "completed_at = CASE WHEN completed_at IS NULL OR trim(completed_at) = '' THEN ? ELSE completed_at END"
958
+ )
959
+ params.append(completed_at)
960
+ if "error" in cols:
961
+ updates.append("error = NULL")
962
+ params.append(email_id)
963
+ cur = conn.execute(
964
+ f"""
965
+ UPDATE emails
966
+ SET {', '.join(updates)}
967
+ WHERE message_id = ?
968
+ AND status = 'processing'
969
+ """,
970
+ tuple(params),
971
+ )
972
+ return cur.rowcount > 0
973
+
974
+
919
975
  def scan_debt(db_path=EMAIL_DB_PATH, *, max_items=5):
920
976
  conn = sqlite3.connect(db_path)
921
977
  conn.row_factory = sqlite3.Row
@@ -998,7 +1054,12 @@ def scan_debt(db_path=EMAIL_DB_PATH, *, max_items=5):
998
1054
  """
999
1055
  ).fetchall()
1000
1056
  recovered = []
1057
+ sent_reconciled = []
1001
1058
  for row in stuck_rows:
1059
+ if _has_sent_reply_event(conn, row["message_id"]):
1060
+ if _mark_processing_email_processed(conn, row["message_id"], completed_at=now_label):
1061
+ sent_reconciled.append(row)
1062
+ continue
1002
1063
  conn.execute(
1003
1064
  """
1004
1065
  UPDATE emails
@@ -1053,6 +1114,11 @@ def scan_debt(db_path=EMAIL_DB_PATH, *, max_items=5):
1053
1114
  if recovered:
1054
1115
  lines.append("")
1055
1116
  lines.append(f"Auto-recovery applied: {len(recovered)} processing-stuck email(s) were reset to pending.")
1117
+ if sent_reconciled:
1118
+ lines.append("")
1119
+ lines.append(
1120
+ f"Reconciled {len(sent_reconciled)} processing email(s) with already-sent reply events; no re-open applied."
1121
+ )
1056
1122
  total_reconciled = len(live_reconciled) + len(finished_reconciled)
1057
1123
  if total_reconciled:
1058
1124
  lines.append(f"Reconciled {total_reconciled} email(s) with inconsistent lifecycle state.")
@@ -1665,7 +1731,7 @@ def _recover_unreplied_processed(config, hours=24):
1665
1731
  AND NOT EXISTS (
1666
1732
  SELECT 1 FROM email_events ev
1667
1733
  WHERE ev.email_id = e.message_id
1668
- AND ev.event IN ('replied', 'resolution')
1734
+ AND ev.event IN ('replied', 'resolution', 'action_done')
1669
1735
  )
1670
1736
  ORDER BY e.rowid DESC
1671
1737
  LIMIT 20
@@ -270,7 +270,7 @@ def _has_open_action(conn, email_id):
270
270
  return not latest_done or latest_done < latest_open
271
271
 
272
272
 
273
- def record_reply_lifecycle(in_reply_to, references, body_text, *, subject="", to="", cc="", message_id="", db_path=EMAIL_DB_PATH):
273
+ def record_reply_lifecycle(in_reply_to, references, body_text, *, subject="", to="", cc="", message_id="", db_path=EMAIL_DB_PATH, classify_override=""):
274
274
  try:
275
275
  conn = sqlite3.connect(db_path)
276
276
  conn.row_factory = sqlite3.Row
@@ -280,7 +280,7 @@ def record_reply_lifecycle(in_reply_to, references, body_text, *, subject="", to
280
280
  conn.close()
281
281
  return None
282
282
 
283
- event = classify_reply_event(body_text)
283
+ event = (classify_override or "").strip() or classify_reply_event(body_text)
284
284
  detail = normalize_reply_text(body_text)[:200]
285
285
  meta = json.dumps(
286
286
  {
@@ -476,6 +476,18 @@ def build_parser():
476
476
  parser.add_argument("--quote-date", default="")
477
477
  parser.add_argument("--thread-file", default="", help="Full thread history file (all previous messages)")
478
478
  parser.add_argument("--attach", "--attachment", dest="attach", action="append", default=[], help="File to attach (can repeat)")
479
+ parser.add_argument(
480
+ "--classify-as",
481
+ dest="classify_as",
482
+ default="",
483
+ choices=["", "resolution", "ack", "commitment", "replied", "debt_flagged"],
484
+ help=(
485
+ "Force the reply lifecycle event instead of heuristic auto-classification. "
486
+ "Use when the reply is substantive but opens with a short affirmation that the "
487
+ "text heuristic would mis-read as 'ack' (escalation -> debt_flagged). "
488
+ "Empty (default) keeps automatic classification."
489
+ ),
490
+ )
479
491
  return parser
480
492
 
481
493
 
@@ -536,6 +548,7 @@ def main(argv=None):
536
548
  to=args.to,
537
549
  cc=args.cc,
538
550
  message_id=msg_id,
551
+ classify_override=(args.classify_as or "").strip(),
539
552
  )
540
553
  try:
541
554
  record_sent_email(
@@ -40,6 +40,9 @@ You MUST register read-side lifecycle events, not send-side events:
40
40
  - When you open/analyze a new email seriously, add an `opened` event for that `message_id`.
41
41
  - When you change `emails.status` to `processing`, also add a `processing` event.
42
42
  - Do NOT register `ack` / `commitment` / `resolution` manually when replying: `nexo-send-reply.py` already does that.
43
+ - When the reply closes or answers a real actionable instruction, pass `--classify-as resolution`.
44
+ This is mandatory when the body starts with a short affirmation (`sí`, `de acuerdo`, `perfecto`, etc.) but then contains substantive instructions or completed work.
45
+ - Use `--classify-as ack` only for a pure receipt/started-working acknowledgement, and `--classify-as commitment` only for a real future commitment.
43
46
  - Use sqlite3 or local python3+sqlite3; tracking is best-effort, append-only, and never deletes historical entries.
44
47
 
45
48
  == WHEN THERE IS DEBT BUT NO UNREAD EMAIL ==
@@ -169,7 +172,9 @@ Mandatory steps before sending:
169
172
  The bottom of the email must preserve message -> reply -> message -> reply without dropping previous answers.
170
173
 
171
174
  == SEND VIA `nexo-send-reply.py` ==
172
- [[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]
175
+ [[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 --classify-as resolution [--attach /path/to/file]
176
+
177
+ Use a different `--classify-as` value only when the lifecycle is truly different (`ack`, `commitment`, `replied`, `debt_flagged`).
173
178
 
174
179
  == ANTI-LOOP PROTECTION ==
175
180
  Do not reply to auto-replies, [[agent_email_label]] itself, `noreply@`,