nexo-brain 3.1.6 → 3.1.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/src/server.py CHANGED
@@ -16,6 +16,13 @@ from tools_sessions import (
16
16
  handle_session_portable_context,
17
17
  handle_session_export_bundle,
18
18
  )
19
+ from tools_hot_context import (
20
+ handle_recent_context_capture,
21
+ handle_recent_context,
22
+ handle_pre_action_context,
23
+ handle_recent_context_resolve,
24
+ handle_hot_context_list,
25
+ )
19
26
  from user_context import get_context as _get_ctx
20
27
  from tools_coordination import (
21
28
  handle_track, handle_untrack, handle_files,
@@ -200,7 +207,9 @@ mcp = FastMCP(
200
207
  "When you catch something the user missed→`nexo_cognitive_trust(event='proactive_action')`. "
201
208
  "Detect intent, not keywords — works in ALL languages.\n"
202
209
  "- **Delegate:** prefer direct. If needed: `nexo_context_packet(area)` + guard + 'if unsure STOP'\n"
203
- "- **Memory:** `nexo_recall` searches all. Capture: errors→`nexo_learning_add`, prefs, entities, decisions\n"
210
+ "- **Memory:** `nexo_recall` searches all. For fresh 24h continuity use `nexo_pre_action_context(query='...')` before acting and "
211
+ "`nexo_recent_context_capture(...)` / `nexo_recent_context_resolve(...)` for important ongoing threads. "
212
+ "Capture: errors→`nexo_learning_add`, prefs, entities, decisions\n"
204
213
  "- **Change log:** `nexo_task_close` should be the default closure path. If you bypass it, call `nexo_change_log(...)` after production edits. NOT for config dir\n"
205
214
  "- **Diary:** When user signals end of session (any language, any style — 'bye', 'done', 'cierro', etc.), "
206
215
  "write `nexo_session_diary_write(...)` with self_critique BEFORE responding. "
@@ -307,6 +316,70 @@ def nexo_context_packet(area: str, files: str = "") -> str:
307
316
  return handle_context_packet(area, files)
308
317
 
309
318
 
319
+ @mcp.tool
320
+ def nexo_recent_context_capture(
321
+ title: str,
322
+ summary: str = "",
323
+ details: str = "",
324
+ topic: str = "",
325
+ context_key: str = "",
326
+ state: str = "active",
327
+ owner: str = "",
328
+ source_type: str = "",
329
+ source_id: str = "",
330
+ session_id: str = "",
331
+ actor: str = "nexo",
332
+ ttl_hours: int = 24,
333
+ metadata: str = "",
334
+ ) -> str:
335
+ """Capture/update a recent 24h context item and append an event.
336
+
337
+ Use this for important ongoing threads that should stay mentally fresh across sessions/clients.
338
+ """
339
+ return handle_recent_context_capture(
340
+ title, summary, details, topic, context_key, state, owner,
341
+ source_type, source_id, session_id, actor, ttl_hours, metadata,
342
+ )
343
+
344
+
345
+ @mcp.tool
346
+ def nexo_recent_context(query: str = "", context_key: str = "", hours: int = 24, limit: int = 8) -> str:
347
+ """Read recent hot context and continuity events from the last N hours."""
348
+ return handle_recent_context(query, context_key, hours, limit)
349
+
350
+
351
+ @mcp.tool
352
+ def nexo_pre_action_context(query: str = "", context_key: str = "", session_id: str = "", hours: int = 24, limit: int = 8) -> str:
353
+ """Build the 24h recent-context bundle that should be reviewed before acting.
354
+
355
+ Especially useful for emails, orchestrators, and any work where the same topic may reappear hours later.
356
+ """
357
+ return handle_pre_action_context(query, context_key, session_id, hours, limit)
358
+
359
+
360
+ @mcp.tool
361
+ def nexo_recent_context_resolve(
362
+ context_key: str = "",
363
+ topic: str = "",
364
+ resolution: str = "",
365
+ actor: str = "nexo",
366
+ session_id: str = "",
367
+ source_type: str = "",
368
+ source_id: str = "",
369
+ ttl_hours: int = 24,
370
+ ) -> str:
371
+ """Resolve a recent hot-context item and append a resolution event."""
372
+ return handle_recent_context_resolve(
373
+ context_key, topic, resolution, actor, session_id, source_type, source_id, ttl_hours
374
+ )
375
+
376
+
377
+ @mcp.tool
378
+ def nexo_hot_context_list(hours: int = 24, limit: int = 10, state: str = "") -> str:
379
+ """List hot-context items currently alive in the recent continuity window."""
380
+ return handle_hot_context_list(hours, limit, state)
381
+
382
+
310
383
  @mcp.tool
311
384
  def nexo_smart_startup() -> str:
312
385
  """Pre-load relevant cognitive memories based on pending followups, due reminders, and last session topics.
@@ -0,0 +1,163 @@
1
+ """Tools for NEXO hot context / recent 24h memory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from db import (
8
+ derive_context_key,
9
+ capture_context_event,
10
+ get_hot_context,
11
+ build_pre_action_context,
12
+ format_pre_action_context_bundle,
13
+ resolve_hot_context,
14
+ search_hot_context,
15
+ )
16
+
17
+
18
+ def _parse_metadata(metadata: str = "") -> dict:
19
+ if not metadata or not metadata.strip():
20
+ return {}
21
+ try:
22
+ parsed = json.loads(metadata)
23
+ except Exception:
24
+ return {"raw": metadata.strip()}
25
+ return parsed if isinstance(parsed, dict) else {"value": parsed}
26
+
27
+
28
+ def handle_recent_context_capture(
29
+ title: str,
30
+ summary: str = "",
31
+ details: str = "",
32
+ topic: str = "",
33
+ context_key: str = "",
34
+ state: str = "active",
35
+ owner: str = "",
36
+ source_type: str = "",
37
+ source_id: str = "",
38
+ session_id: str = "",
39
+ actor: str = "nexo",
40
+ ttl_hours: int = 24,
41
+ metadata: str = "",
42
+ ) -> str:
43
+ """Capture/update a recent context item and append an event."""
44
+ clean_title = (title or "").strip()
45
+ if not clean_title and not summary.strip():
46
+ return "ERROR: title or summary is required."
47
+ resolved_key = derive_context_key(
48
+ context_key=context_key,
49
+ topic=topic,
50
+ title=clean_title or summary,
51
+ source_type=source_type,
52
+ source_id=source_id,
53
+ )
54
+ result = capture_context_event(
55
+ event_type="context_capture",
56
+ title=clean_title or summary,
57
+ summary=(summary or clean_title)[:600],
58
+ body=details[:1600] if details else "",
59
+ context_key=resolved_key,
60
+ topic=topic,
61
+ context_title=clean_title or summary or resolved_key,
62
+ context_summary=summary or details or clean_title,
63
+ context_type="topic",
64
+ state=state or "active",
65
+ owner=owner or "",
66
+ actor=actor or "nexo",
67
+ source_type=source_type or "",
68
+ source_id=source_id or "",
69
+ session_id=session_id or "",
70
+ metadata=_parse_metadata(metadata),
71
+ ttl_hours=ttl_hours,
72
+ )
73
+ event = result.get("event") or {}
74
+ return (
75
+ f"Recent context captured: {result.get('context_key') or resolved_key}\n"
76
+ f"Title: {clean_title or summary}\n"
77
+ f"State: {(result.get('context') or {}).get('state', state or 'active')}\n"
78
+ f"Event: {event.get('event_type', 'context_capture')}"
79
+ )
80
+
81
+
82
+ def handle_recent_context(query: str = "", context_key: str = "", hours: int = 24, limit: int = 8) -> str:
83
+ """Search hot context items and show their recent continuity."""
84
+ if context_key.strip():
85
+ item = get_hot_context(context_key.strip(), include_events=True, limit=max(4, int(limit or 8)))
86
+ if not item:
87
+ return f"No hot context found for {context_key.strip()}."
88
+ bundle = {
89
+ "query": query.strip(),
90
+ "context_key": context_key.strip(),
91
+ "hours": hours,
92
+ "contexts": [item],
93
+ "events": item.get("events") or [],
94
+ "reminders": [],
95
+ "followups": [],
96
+ "has_matches": True,
97
+ }
98
+ return format_pre_action_context_bundle(bundle)
99
+
100
+ bundle = build_pre_action_context(query=query, context_key="", session_id="", hours=hours, limit=limit)
101
+ return format_pre_action_context_bundle(bundle)
102
+
103
+
104
+ def handle_pre_action_context(query: str = "", context_key: str = "", session_id: str = "", hours: int = 24, limit: int = 8) -> str:
105
+ """Build the recent 24h bundle that agents/scripts should consult before acting."""
106
+ bundle = build_pre_action_context(
107
+ query=query,
108
+ context_key=context_key,
109
+ session_id=session_id,
110
+ hours=hours,
111
+ limit=limit,
112
+ )
113
+ return format_pre_action_context_bundle(bundle)
114
+
115
+
116
+ def handle_recent_context_resolve(
117
+ context_key: str = "",
118
+ topic: str = "",
119
+ resolution: str = "",
120
+ actor: str = "nexo",
121
+ session_id: str = "",
122
+ source_type: str = "",
123
+ source_id: str = "",
124
+ ttl_hours: int = 24,
125
+ ) -> str:
126
+ """Resolve a hot-context item and append a resolution event."""
127
+ resolved_key = derive_context_key(
128
+ context_key=context_key,
129
+ topic=topic,
130
+ title=topic,
131
+ source_type=source_type,
132
+ source_id=source_id,
133
+ )
134
+ if not resolved_key:
135
+ return "ERROR: context_key or topic is required."
136
+ result = resolve_hot_context(
137
+ context_key=resolved_key,
138
+ resolution=resolution or "Context resolved.",
139
+ actor=actor or "nexo",
140
+ session_id=session_id or "",
141
+ source_type=source_type or "",
142
+ source_id=source_id or "",
143
+ ttl_hours=ttl_hours,
144
+ )
145
+ if "error" in result:
146
+ return f"ERROR: {result['error']}"
147
+ return f"Hot context resolved: {resolved_key}"
148
+
149
+
150
+ def handle_hot_context_list(hours: int = 24, limit: int = 10, state: str = "") -> str:
151
+ """List active hot context items without the full event bundle."""
152
+ rows = search_hot_context("", hours=hours, limit=limit, state=state)
153
+ if not rows:
154
+ return "No hot context items."
155
+ lines = [f"HOT CONTEXT ({len(rows)}):"]
156
+ for item in rows:
157
+ summary = (item.get("summary") or "").strip()
158
+ suffix = f" — {summary[:120]}" if summary else ""
159
+ lines.append(
160
+ f"- {item.get('context_key')}: [{item.get('state')}] {item.get('title')} "
161
+ f"(last_event={item.get('last_event_at')}){suffix}"
162
+ )
163
+ return "\n".join(lines)
@@ -14,7 +14,8 @@ from db import (
14
14
  get_inbox, get_pending_questions, now_epoch,
15
15
  SESSION_STALE_SECONDS, check_session_has_diary,
16
16
  save_checkpoint, read_checkpoint, increment_compaction_count,
17
- get_db,
17
+ get_db, build_pre_action_context, format_pre_action_context_bundle,
18
+ capture_context_event,
18
19
  )
19
20
 
20
21
  # ── Session Keepalive ────────────────────────────────────────────────
@@ -131,6 +132,19 @@ def _session_portability_bundle(sid: str = "") -> dict:
131
132
  (session_id,),
132
133
  ).fetchall()
133
134
  ]
135
+ recent_query = " | ".join(
136
+ part for part in [
137
+ str(session_row["task"] or "").strip(),
138
+ str((checkpoint or {}).get("current_goal") or "").strip(),
139
+ str((draft or {}).get("last_context_hint") or "").strip(),
140
+ ] if part
141
+ )
142
+ recent_context = build_pre_action_context(
143
+ query=recent_query,
144
+ session_id=session_id,
145
+ hours=24,
146
+ limit=4,
147
+ ) if recent_query else {"has_matches": False}
134
148
  return {
135
149
  "ok": True,
136
150
  "generated_at": datetime.now(timezone.utc).isoformat(),
@@ -146,6 +160,7 @@ def _session_portability_bundle(sid: str = "") -> dict:
146
160
  "checkpoint": dict(checkpoint) if checkpoint else {},
147
161
  "latest_diary": dict(diary) if diary else {},
148
162
  "diary_draft": dict(draft) if draft else {},
163
+ "recent_context": recent_context,
149
164
  "open_protocol_tasks": protocol_tasks,
150
165
  "open_workflow_goals": workflow_goals,
151
166
  "open_workflow_runs": workflow_runs,
@@ -199,6 +214,9 @@ def handle_session_portable_context(sid: str = "") -> str:
199
214
  f"- Context hint: {draft.get('last_context_hint') or '(none)'}",
200
215
  ]
201
216
  )
217
+ recent_context = bundle.get("recent_context") or {}
218
+ if recent_context.get("has_matches"):
219
+ lines.extend(["", format_pre_action_context_bundle(recent_context, compact=True)])
202
220
 
203
221
  protocol_tasks = bundle.get("open_protocol_tasks") or []
204
222
  if protocol_tasks:
@@ -382,7 +400,7 @@ def handle_heartbeat(sid: str, task: str, context_hint: str = '') -> str:
382
400
  Args:
383
401
  sid: Session ID
384
402
  task: Current task description
385
- context_hint: Optional — stored for diary draft context, not processed.
403
+ context_hint: Optional — stored for diary draft context and used for recent 24h continuity lookup.
386
404
  """
387
405
  from db import get_db
388
406
  update_session(sid, task)
@@ -404,6 +422,21 @@ def handle_heartbeat(sid: str, task: str, context_hint: str = '') -> str:
404
422
  age = _format_age(q["created_epoch"])
405
423
  parts.append(f" {q['qid']} de {q['from_sid']} ({age}): {q['question']}")
406
424
 
425
+ recent_query = (context_hint or task or "").strip()
426
+ if recent_query:
427
+ try:
428
+ bundle = build_pre_action_context(
429
+ query=recent_query,
430
+ session_id=sid,
431
+ hours=24,
432
+ limit=4,
433
+ )
434
+ if bundle.get("has_matches"):
435
+ parts.append("")
436
+ parts.append(format_pre_action_context_bundle(bundle, compact=True))
437
+ except Exception:
438
+ pass
439
+
407
440
  # Incremental diary draft — accumulate every heartbeat, full UPSERT every 5
408
441
  _hb_count = 0 # Hoisted for Layer 3 DIARY_OVERDUE signal
409
442
  try:
@@ -463,6 +496,28 @@ def handle_heartbeat(sid: str, task: str, context_hint: str = '') -> str:
463
496
  except Exception:
464
497
  pass # Checkpoint update is best-effort
465
498
 
499
+ try:
500
+ capture_context_event(
501
+ event_type="heartbeat",
502
+ title=task[:160],
503
+ summary=(context_hint or task)[:600],
504
+ body=context_hint[:1600] if context_hint else "",
505
+ context_key=f"session:{sid}",
506
+ context_title=task[:160],
507
+ context_summary=(context_hint or task)[:600],
508
+ context_type="session_topic",
509
+ state="active",
510
+ owner="session",
511
+ actor=sid,
512
+ source_type="heartbeat",
513
+ source_id=sid,
514
+ session_id=sid,
515
+ metadata={"task": task[:160]},
516
+ ttl_hours=24,
517
+ )
518
+ except Exception:
519
+ pass
520
+
466
521
  # ── Layer 3: DIARY_OVERDUE signal based on heartbeat count + time ──
467
522
  conn = get_db()
468
523
  row = conn.execute("SELECT started_epoch FROM sessions WHERE sid = ?", (sid,)).fetchone()
@@ -555,7 +610,17 @@ def handle_context_packet(area: str, files: str = "") -> str:
555
610
  except Exception:
556
611
  pass
557
612
 
558
- # 5. Cognitive memories for this area
613
+ # 5. Recent hot context in the last 24h
614
+ try:
615
+ hot_bundle = build_pre_action_context(query=area, hours=24, limit=4)
616
+ if hot_bundle.get("has_matches"):
617
+ parts.append("## RECENT HOT CONTEXT (24H)")
618
+ parts.append(format_pre_action_context_bundle(hot_bundle, compact=True))
619
+ parts.append("")
620
+ except Exception:
621
+ pass
622
+
623
+ # 6. Cognitive memories for this area
559
624
  try:
560
625
  import cognitive
561
626
  results = cognitive.search(
@@ -573,7 +638,7 @@ def handle_context_packet(area: str, files: str = "") -> str:
573
638
  except Exception:
574
639
  pass
575
640
 
576
- # 6. Data flow tracing requirement (mandatory for all subagents)
641
+ # 7. Data flow tracing requirement (mandatory for all subagents)
577
642
  parts.append("## MANDATORY RULE: DATA FLOW TRACING")
578
643
  parts.append("BEFORE modifying any file or data, answer these 3 questions:")
579
644
  parts.append(" 1. WHO PRODUCES this data? (which function/cron/endpoint generates it)")
@@ -717,6 +782,14 @@ def handle_smart_startup_query() -> str:
717
782
  lines.append("")
718
783
  lines.append(cognitive.format_results(results))
719
784
 
785
+ try:
786
+ hot_bundle = build_pre_action_context(query=composite_query, hours=24, limit=4)
787
+ if hot_bundle.get("has_matches"):
788
+ lines.append("")
789
+ lines.append(format_pre_action_context_bundle(hot_bundle, compact=True))
790
+ except Exception:
791
+ pass
792
+
720
793
  # Session tone from Deep Sleep (emotional intelligence layer)
721
794
  tone = _load_session_tone()
722
795
  if tone: