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/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/src/auto_update.py +2 -0
- package/src/dashboard/app.py +66 -2
- package/src/dashboard/templates/memory.html +102 -0
- package/src/dashboard/templates/operations.html +43 -8
- package/src/db/__init__.py +82 -0
- package/src/db/_hot_context.py +660 -0
- package/src/db/_learnings.py +20 -13
- package/src/db/_reminders.py +245 -2
- package/src/db/_schema.py +50 -0
- package/src/plugins/protocol.py +59 -0
- package/src/server.py +74 -1
- package/src/tools_hot_context.py +163 -0
- package/src/tools_sessions.py +77 -4
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.
|
|
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)
|
package/src/tools_sessions.py
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
#
|
|
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:
|