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.
@@ -1,9 +1,17 @@
1
1
  from __future__ import annotations
2
2
  """NEXO DB — Learnings module."""
3
+ import importlib
3
4
  import re, time
4
- from db._core import get_db, now_epoch
5
+ import sys
5
6
  from db._fts import fts_upsert, fts_search
6
7
 
8
+
9
+ def _core():
10
+ module = sys.modules.get("db._core")
11
+ if module is None:
12
+ module = importlib.import_module("db._core")
13
+ return module
14
+
7
15
  # ── Learnings ──────────────────────────────────────────────────────
8
16
 
9
17
  def create_learning(
@@ -19,8 +27,8 @@ def create_learning(
19
27
  last_reviewed_at: float | None = None,
20
28
  ) -> dict:
21
29
  """Create a new learning entry with optional reasoning."""
22
- conn = get_db()
23
- now = now_epoch()
30
+ conn = _core().get_db()
31
+ now = _core().now_epoch()
24
32
  cursor = conn.execute(
25
33
  "INSERT INTO learnings "
26
34
  "(category, title, content, reasoning, prevention, applies_to, supersedes_id, status, review_due_at, last_reviewed_at, created_at, updated_at) "
@@ -39,7 +47,7 @@ def create_learning(
39
47
 
40
48
  def update_learning(id: int, **kwargs) -> dict:
41
49
  """Update any fields of a learning: category, title, content, reasoning."""
42
- conn = get_db()
50
+ conn = _core().get_db()
43
51
  row = conn.execute("SELECT * FROM learnings WHERE id = ?", (id,)).fetchone()
44
52
  if not row:
45
53
  return {"error": f"Learning {id} not found"}
@@ -50,7 +58,7 @@ def update_learning(id: int, **kwargs) -> dict:
50
58
  updates = {k: v for k, v in kwargs.items() if k in allowed}
51
59
  if not updates:
52
60
  return dict(row)
53
- updates["updated_at"] = now_epoch()
61
+ updates["updated_at"] = _core().now_epoch()
54
62
  set_clause = ", ".join(f"{k} = ?" for k in updates)
55
63
  values = list(updates.values()) + [id]
56
64
  conn.execute(f"UPDATE learnings SET {set_clause} WHERE id = ?", values)
@@ -63,7 +71,7 @@ def update_learning(id: int, **kwargs) -> dict:
63
71
 
64
72
  def supersede_learning(old_id: int, new_id: int, note: str = '') -> dict:
65
73
  """Mark an older learning as superseded by a newer canonical learning."""
66
- conn = get_db()
74
+ conn = _core().get_db()
67
75
  old_row = conn.execute("SELECT * FROM learnings WHERE id = ?", (old_id,)).fetchone()
68
76
  new_row = conn.execute("SELECT * FROM learnings WHERE id = ?", (new_id,)).fetchone()
69
77
  if not old_row:
@@ -74,7 +82,7 @@ def supersede_learning(old_id: int, new_id: int, note: str = '') -> dict:
74
82
  old_reasoning = str(old_row["reasoning"] or "")
75
83
  suffix = note.strip() if note.strip() else f"Superseded by learning #{new_id}."
76
84
  combined_reasoning = f"{old_reasoning}\n{suffix}".strip() if old_reasoning else suffix
77
- updated_at = now_epoch()
85
+ updated_at = _core().now_epoch()
78
86
  conn.execute(
79
87
  "UPDATE learnings SET status = 'superseded', updated_at = ?, reasoning = ? WHERE id = ?",
80
88
  (updated_at, combined_reasoning, old_id),
@@ -90,7 +98,7 @@ def supersede_learning(old_id: int, new_id: int, note: str = '') -> dict:
90
98
 
91
99
  def delete_learning(id: int) -> bool:
92
100
  """Delete a learning entry."""
93
- conn = get_db()
101
+ conn = _core().get_db()
94
102
  result = conn.execute("DELETE FROM learnings WHERE id = ?", (id,))
95
103
  conn.execute("DELETE FROM unified_search WHERE source = 'learning' AND source_id = ?", (str(id),))
96
104
  conn.commit()
@@ -103,7 +111,7 @@ def search_learnings(query: str, category: str = None) -> list[dict]:
103
111
  # Try FTS5 first
104
112
  fts_results = fts_search(query, source_filter="learning", limit=30)
105
113
  if fts_results:
106
- conn = get_db()
114
+ conn = _core().get_db()
107
115
  ids = [int(r['source_id']) for r in fts_results]
108
116
  placeholders = ','.join('?' * len(ids))
109
117
  rows = conn.execute(
@@ -116,7 +124,7 @@ def search_learnings(query: str, category: str = None) -> list[dict]:
116
124
  return filtered
117
125
 
118
126
  # Fallback to LIKE
119
- conn = get_db()
127
+ conn = _core().get_db()
120
128
  words = query.strip().split()
121
129
  if not words:
122
130
  return []
@@ -139,7 +147,7 @@ def search_learnings(query: str, category: str = None) -> list[dict]:
139
147
 
140
148
  def list_learnings(category: str = None) -> list[dict]:
141
149
  """List all learnings, optionally filtered by category."""
142
- conn = get_db()
150
+ conn = _core().get_db()
143
151
  if category:
144
152
  rows = conn.execute(
145
153
  "SELECT * FROM learnings WHERE category = ? ORDER BY updated_at DESC",
@@ -176,7 +184,7 @@ def find_similar_learnings(new_id: int, title: str, content: str, category: str)
176
184
  keywords_new = set(extract_keywords(f"{title} {content}"))
177
185
  if not keywords_new:
178
186
  return []
179
- conn = get_db()
187
+ conn = _core().get_db()
180
188
  rows = conn.execute(
181
189
  "SELECT id, title, content FROM learnings WHERE category = ? AND id != ?",
182
190
  (category, new_id)
@@ -193,4 +201,3 @@ def find_similar_learnings(new_id: int, title: str, content: str, category: str)
193
201
  results.append((row['id'], round(similarity, 2)))
194
202
  results.sort(key=lambda x: x[1], reverse=True)
195
203
  return results[:5]
196
-
@@ -9,6 +9,7 @@ from typing import Any
9
9
 
10
10
  from db._core import get_db, now_epoch
11
11
  from db._fts import fts_upsert
12
+ from db._hot_context import capture_context_event
12
13
 
13
14
  ACTIVE_EXCLUDED_STATUSES = {"DELETED", "archived", "blocked", "waiting"}
14
15
  READ_TOKEN_TTL_SECONDS = 30 * 60
@@ -190,6 +191,19 @@ def _active_status_where(column_name: str = "status") -> str:
190
191
  )
191
192
 
192
193
 
194
+ def _context_state_from_status(status: str | None) -> str:
195
+ normalized = str(status or "PENDING").strip().upper()
196
+ if normalized.startswith("COMPLETED"):
197
+ return "resolved"
198
+ if normalized == "DELETED":
199
+ return "abandoned"
200
+ if normalized == "WAITING":
201
+ return "waiting_user"
202
+ if normalized == "BLOCKED":
203
+ return "blocked"
204
+ return "active"
205
+
206
+
193
207
  # ── Reminders ──────────────────────────────────────────────────────
194
208
 
195
209
 
@@ -221,6 +235,23 @@ def create_reminder(
221
235
  note=f"Reminder created. Category={category}. Date={date or '—'}.",
222
236
  actor="db",
223
237
  )
238
+ capture_context_event(
239
+ event_type="reminder_created",
240
+ title=description[:160],
241
+ summary=description[:600],
242
+ body=f"Category={category}. Date={date or '—'}.",
243
+ context_key=f"reminder:{id}",
244
+ context_title=description[:160],
245
+ context_summary=description[:600],
246
+ context_type="reminder",
247
+ state=_context_state_from_status(status),
248
+ owner="user",
249
+ actor="db",
250
+ source_type="reminder",
251
+ source_id=id,
252
+ metadata={"category": category, "status": status, "date": date or ""},
253
+ ttl_hours=24,
254
+ )
224
255
  return dict(row)
225
256
 
226
257
 
@@ -251,9 +282,27 @@ def update_reminder(
251
282
  conn.commit()
252
283
 
253
284
  new_row = conn.execute("SELECT * FROM reminders WHERE id = ?", (id,)).fetchone()
285
+ current = dict(new_row) if new_row else dict(row)
254
286
  if log_history:
255
287
  note = history_note or _format_changes(row, new_row, ["description", "date", "status", "category"])
256
288
  add_item_history("reminder", id, history_event, note=note or "Reminder updated.", actor=history_actor)
289
+ capture_context_event(
290
+ event_type=f"reminder_{history_event}",
291
+ title=(new_row["description"] if new_row else row["description"])[:160],
292
+ summary=((note if log_history else history_note) or "Reminder updated.")[:600],
293
+ body=((new_row["description"] if new_row else row["description"]) or "")[:1600],
294
+ context_key=f"reminder:{id}",
295
+ context_title=(new_row["description"] if new_row else row["description"])[:160],
296
+ context_summary=((new_row["description"] if new_row else row["description"]) or "")[:600],
297
+ context_type="reminder",
298
+ state=_context_state_from_status(current.get("status")),
299
+ owner="user",
300
+ actor=history_actor,
301
+ source_type="reminder",
302
+ source_id=id,
303
+ metadata={"status": current.get("status", ""), "date": current.get("date", "")},
304
+ ttl_hours=24,
305
+ )
257
306
  return dict(new_row)
258
307
 
259
308
 
@@ -267,6 +316,23 @@ def complete_reminder(id: str) -> dict:
267
316
  if "error" in result:
268
317
  return result
269
318
  add_item_history("reminder", id, "completed", note="Reminder marked COMPLETED.", actor="db")
319
+ capture_context_event(
320
+ event_type="reminder_completed",
321
+ title=(result.get("description") or id)[:160],
322
+ summary="Reminder marked COMPLETED.",
323
+ body=(result.get("description") or "")[:1600],
324
+ context_key=f"reminder:{id}",
325
+ context_title=(result.get("description") or id)[:160],
326
+ context_summary=(result.get("description") or "")[:600],
327
+ context_type="reminder",
328
+ state="resolved",
329
+ owner="user",
330
+ actor="db",
331
+ source_type="reminder",
332
+ source_id=id,
333
+ metadata={"status": "COMPLETED"},
334
+ ttl_hours=24,
335
+ )
270
336
  return result
271
337
 
272
338
 
@@ -280,6 +346,23 @@ def delete_reminder(id: str) -> bool:
280
346
  if "error" in result:
281
347
  return False
282
348
  add_item_history("reminder", id, "deleted", note="Reminder soft-deleted (status=DELETED).", actor="db")
349
+ capture_context_event(
350
+ event_type="reminder_deleted",
351
+ title=(result.get("description") or id)[:160],
352
+ summary="Reminder soft-deleted (status=DELETED).",
353
+ body=(result.get("description") or "")[:1600],
354
+ context_key=f"reminder:{id}",
355
+ context_title=(result.get("description") or id)[:160],
356
+ context_summary=(result.get("description") or "")[:600],
357
+ context_type="reminder",
358
+ state="abandoned",
359
+ owner="user",
360
+ actor="db",
361
+ source_type="reminder",
362
+ source_id=id,
363
+ metadata={"status": "DELETED"},
364
+ ttl_hours=24,
365
+ )
283
366
  return True
284
367
 
285
368
 
@@ -297,6 +380,23 @@ def restore_reminder(id: str) -> dict:
297
380
  return result
298
381
  previous = row.get("status") or "unknown"
299
382
  add_item_history("reminder", id, "restored", note=f"Reminder restored from {previous} to PENDING.", actor="db")
383
+ capture_context_event(
384
+ event_type="reminder_restored",
385
+ title=(result.get("description") or id)[:160],
386
+ summary=f"Reminder restored from {previous} to PENDING.",
387
+ body=(result.get("description") or "")[:1600],
388
+ context_key=f"reminder:{id}",
389
+ context_title=(result.get("description") or id)[:160],
390
+ context_summary=(result.get("description") or "")[:600],
391
+ context_type="reminder",
392
+ state="active",
393
+ owner="user",
394
+ actor="db",
395
+ source_type="reminder",
396
+ source_id=id,
397
+ metadata={"previous_status": previous, "status": "PENDING"},
398
+ ttl_hours=24,
399
+ )
300
400
  return result
301
401
 
302
402
 
@@ -305,7 +405,25 @@ def add_reminder_note(id: str, note: str, actor: str = "nexo") -> dict:
305
405
  row = get_reminder(id)
306
406
  if not row:
307
407
  return {"error": f"Reminder {id} not found"}
308
- return add_item_history("reminder", id, "note", note=note, actor=actor)
408
+ history = add_item_history("reminder", id, "note", note=note, actor=actor)
409
+ capture_context_event(
410
+ event_type="reminder_note",
411
+ title=(row.get("description") or id)[:160],
412
+ summary=note[:600],
413
+ body=note[:1600],
414
+ context_key=f"reminder:{id}",
415
+ context_title=(row.get("description") or id)[:160],
416
+ context_summary=(row.get("description") or "")[:600],
417
+ context_type="reminder",
418
+ state=_context_state_from_status(row.get("status")),
419
+ owner="user",
420
+ actor=actor,
421
+ source_type="reminder",
422
+ source_id=id,
423
+ metadata={"status": row.get("status", "")},
424
+ ttl_hours=24,
425
+ )
426
+ return history
309
427
 
310
428
 
311
429
  def get_reminders(filter_type: str = "all") -> list[dict]:
@@ -450,6 +568,23 @@ def create_followup(
450
568
  note=f"Followup created. Date={date or '—'}. Recurrence={recurrence or '—'}.",
451
569
  actor="db",
452
570
  )
571
+ capture_context_event(
572
+ event_type="followup_created",
573
+ title=description[:160],
574
+ summary=description[:600],
575
+ body=f"Verification={verification[:240]}. Reasoning={reasoning[:240]}.",
576
+ context_key=f"followup:{id}",
577
+ context_title=description[:160],
578
+ context_summary=description[:600],
579
+ context_type="followup",
580
+ state=_context_state_from_status(status),
581
+ owner="nexo",
582
+ actor="db",
583
+ source_type="followup",
584
+ source_id=id,
585
+ metadata={"status": status, "date": date or "", "priority": priority or "medium"},
586
+ ttl_hours=24,
587
+ )
453
588
  result = dict(row)
454
589
  if warning:
455
590
  result["warning"] = warning
@@ -483,6 +618,7 @@ def update_followup(
483
618
  conn.commit()
484
619
 
485
620
  new_row = conn.execute("SELECT * FROM followups WHERE id = ?", (id,)).fetchone()
621
+ current = dict(new_row) if new_row else dict(row)
486
622
  if new_row and _table_exists(conn, "unified_search"):
487
623
  new_row_dict = dict(new_row)
488
624
  fts_upsert(
@@ -500,6 +636,27 @@ def update_followup(
500
636
  ["description", "date", "verification", "status", "reasoning", "recurrence", "priority"],
501
637
  )
502
638
  add_item_history("followup", id, history_event, note=note or "Followup updated.", actor=history_actor)
639
+ capture_context_event(
640
+ event_type=f"followup_{history_event}",
641
+ title=(new_row["description"] if new_row else row["description"])[:160],
642
+ summary=((note if log_history else history_note) or "Followup updated.")[:600],
643
+ body=((new_row["description"] if new_row else row["description"]) or "")[:1600],
644
+ context_key=f"followup:{id}",
645
+ context_title=(new_row["description"] if new_row else row["description"])[:160],
646
+ context_summary=((new_row["description"] if new_row else row["description"]) or "")[:600],
647
+ context_type="followup",
648
+ state=_context_state_from_status(current.get("status")),
649
+ owner="nexo",
650
+ actor=history_actor,
651
+ source_type="followup",
652
+ source_id=id,
653
+ metadata={
654
+ "status": current.get("status", ""),
655
+ "date": current.get("date", ""),
656
+ "priority": current.get("priority", ""),
657
+ },
658
+ ttl_hours=24,
659
+ )
503
660
  return dict(new_row)
504
661
 
505
662
 
@@ -572,6 +729,23 @@ def complete_followup(id: str, result: str = "") -> dict:
572
729
  note=result or "Followup marked COMPLETED.",
573
730
  actor="db",
574
731
  )
732
+ capture_context_event(
733
+ event_type="followup_completed",
734
+ title=(update_result.get("description") or id)[:160],
735
+ summary=(result or "Followup marked COMPLETED.")[:600],
736
+ body=(update_result.get("description") or "")[:1600],
737
+ context_key=f"followup:{id}",
738
+ context_title=(update_result.get("description") or id)[:160],
739
+ context_summary=(update_result.get("description") or "")[:600],
740
+ context_type="followup",
741
+ state="resolved",
742
+ owner="nexo",
743
+ actor="db",
744
+ source_type="followup",
745
+ source_id=id,
746
+ metadata={"status": "COMPLETED"},
747
+ ttl_hours=24,
748
+ )
575
749
 
576
750
  recurrence = row["recurrence"]
577
751
  if recurrence:
@@ -619,6 +793,23 @@ def complete_followup(id: str, result: str = "") -> dict:
619
793
  actor="db",
620
794
  metadata={"source_followup_id": archived_id},
621
795
  )
796
+ capture_context_event(
797
+ event_type="followup_recurrence_archived",
798
+ title=(archived_row["description"] or archived_id)[:160],
799
+ summary=f"Recurring followup archived as {archived_id}. Next occurrence spawned as {id} for {next_date}.",
800
+ body=(archived_row["description"] or "")[:1600],
801
+ context_key=f"followup:{archived_id}",
802
+ context_title=(archived_row["description"] or archived_id)[:160],
803
+ context_summary=(archived_row["description"] or "")[:600],
804
+ context_type="followup",
805
+ state="resolved",
806
+ owner="nexo",
807
+ actor="db",
808
+ source_type="followup",
809
+ source_id=archived_id,
810
+ metadata={"next_id": id, "next_date": next_date},
811
+ ttl_hours=24,
812
+ )
622
813
  return {
623
814
  "id": archived_id,
624
815
  "status": "COMPLETED",
@@ -636,6 +827,23 @@ def delete_followup(id: str) -> bool:
636
827
  if "error" in result:
637
828
  return False
638
829
  add_item_history("followup", id, "deleted", note="Followup soft-deleted (status=DELETED).", actor="db")
830
+ capture_context_event(
831
+ event_type="followup_deleted",
832
+ title=(result.get("description") or id)[:160],
833
+ summary="Followup soft-deleted (status=DELETED).",
834
+ body=(result.get("description") or "")[:1600],
835
+ context_key=f"followup:{id}",
836
+ context_title=(result.get("description") or id)[:160],
837
+ context_summary=(result.get("description") or "")[:600],
838
+ context_type="followup",
839
+ state="abandoned",
840
+ owner="nexo",
841
+ actor="db",
842
+ source_type="followup",
843
+ source_id=id,
844
+ metadata={"status": "DELETED"},
845
+ ttl_hours=24,
846
+ )
639
847
  return True
640
848
 
641
849
 
@@ -649,6 +857,23 @@ def restore_followup(id: str) -> dict:
649
857
  return result
650
858
  previous = row.get("status") or "unknown"
651
859
  add_item_history("followup", id, "restored", note=f"Followup restored from {previous} to PENDING.", actor="db")
860
+ capture_context_event(
861
+ event_type="followup_restored",
862
+ title=(result.get("description") or id)[:160],
863
+ summary=f"Followup restored from {previous} to PENDING.",
864
+ body=(result.get("description") or "")[:1600],
865
+ context_key=f"followup:{id}",
866
+ context_title=(result.get("description") or id)[:160],
867
+ context_summary=(result.get("description") or "")[:600],
868
+ context_type="followup",
869
+ state="active",
870
+ owner="nexo",
871
+ actor="db",
872
+ source_type="followup",
873
+ source_id=id,
874
+ metadata={"previous_status": previous, "status": "PENDING"},
875
+ ttl_hours=24,
876
+ )
652
877
  return result
653
878
 
654
879
 
@@ -657,7 +882,25 @@ def add_followup_note(id: str, note: str, actor: str = "nexo") -> dict:
657
882
  row = get_followup(id)
658
883
  if not row:
659
884
  return {"error": f"Followup {id} not found"}
660
- return add_item_history("followup", id, "note", note=note, actor=actor)
885
+ history = add_item_history("followup", id, "note", note=note, actor=actor)
886
+ capture_context_event(
887
+ event_type="followup_note",
888
+ title=(row.get("description") or id)[:160],
889
+ summary=note[:600],
890
+ body=note[:1600],
891
+ context_key=f"followup:{id}",
892
+ context_title=(row.get("description") or id)[:160],
893
+ context_summary=(row.get("description") or "")[:600],
894
+ context_type="followup",
895
+ state=_context_state_from_status(row.get("status")),
896
+ owner="nexo",
897
+ actor=actor,
898
+ source_type="followup",
899
+ source_id=id,
900
+ metadata={"status": row.get("status", "")},
901
+ ttl_hours=24,
902
+ )
903
+ return history
661
904
 
662
905
 
663
906
  def get_followups(filter_type: str = "all") -> list[dict]:
package/src/db/_schema.py CHANGED
@@ -703,6 +703,55 @@ def _m29_item_history_and_soft_delete(conn):
703
703
  _migrate_add_index(conn, "idx_item_read_tokens_lookup", "item_read_tokens", "item_type, item_id, expires_at")
704
704
 
705
705
 
706
+ def _m30_hot_context_memory(conn):
707
+ """Persist recent events + hot context for 24h operational continuity."""
708
+ conn.execute("""
709
+ CREATE TABLE IF NOT EXISTS hot_context (
710
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
711
+ context_key TEXT NOT NULL UNIQUE,
712
+ title TEXT NOT NULL,
713
+ summary TEXT DEFAULT '',
714
+ context_type TEXT DEFAULT 'topic',
715
+ state TEXT DEFAULT 'active',
716
+ owner TEXT DEFAULT '',
717
+ source_type TEXT DEFAULT '',
718
+ source_id TEXT DEFAULT '',
719
+ session_id TEXT DEFAULT '',
720
+ metadata TEXT DEFAULT '{}',
721
+ first_seen_at REAL NOT NULL,
722
+ last_event_at REAL NOT NULL,
723
+ expires_at REAL NOT NULL,
724
+ created_at REAL NOT NULL,
725
+ updated_at REAL NOT NULL
726
+ )
727
+ """)
728
+ conn.execute("""
729
+ CREATE TABLE IF NOT EXISTS recent_events (
730
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
731
+ context_key TEXT DEFAULT '',
732
+ event_type TEXT NOT NULL,
733
+ title TEXT DEFAULT '',
734
+ summary TEXT DEFAULT '',
735
+ body TEXT DEFAULT '',
736
+ actor TEXT DEFAULT '',
737
+ source_type TEXT DEFAULT '',
738
+ source_id TEXT DEFAULT '',
739
+ session_id TEXT DEFAULT '',
740
+ metadata TEXT DEFAULT '{}',
741
+ created_at REAL NOT NULL,
742
+ expires_at REAL NOT NULL
743
+ )
744
+ """)
745
+ _migrate_add_index(conn, "idx_hot_context_last_event", "hot_context", "last_event_at")
746
+ _migrate_add_index(conn, "idx_hot_context_state", "hot_context", "state")
747
+ _migrate_add_index(conn, "idx_hot_context_source", "hot_context", "source_type, source_id")
748
+ _migrate_add_index(conn, "idx_hot_context_session", "hot_context", "session_id, last_event_at")
749
+ _migrate_add_index(conn, "idx_recent_events_created", "recent_events", "created_at")
750
+ _migrate_add_index(conn, "idx_recent_events_context", "recent_events", "context_key, created_at")
751
+ _migrate_add_index(conn, "idx_recent_events_source", "recent_events", "source_type, source_id, created_at")
752
+ _migrate_add_index(conn, "idx_recent_events_session", "recent_events", "session_id, created_at")
753
+
754
+
706
755
  MIGRATIONS = [
707
756
  (1, "learnings_columns", _m1_learnings_columns),
708
757
  (2, "followups_reasoning", _m2_followups_reasoning),
@@ -733,6 +782,7 @@ MIGRATIONS = [
733
782
  (27, "state_watchers", _m27_state_watchers),
734
783
  (28, "automation_runs", _m28_automation_runs),
735
784
  (29, "item_history_and_soft_delete", _m29_item_history_and_soft_delete),
785
+ (30, "hot_context_memory", _m30_hot_context_memory),
736
786
  ]
737
787
 
738
788
 
@@ -13,6 +13,9 @@ from db import (
13
13
  create_followup,
14
14
  create_protocol_debt,
15
15
  create_protocol_task,
16
+ build_pre_action_context,
17
+ capture_context_event,
18
+ format_pre_action_context_bundle,
16
19
  get_db,
17
20
  get_protocol_task,
18
21
  list_workflow_goals,
@@ -484,6 +487,12 @@ def handle_task_open(
484
487
  verification_step=state["verification_step"],
485
488
  stakes=stakes,
486
489
  )
490
+ recent_bundle = build_pre_action_context(
491
+ query=" | ".join(part for part in [clean_goal, context_hint.strip()] if part),
492
+ session_id=sid.strip(),
493
+ hours=24,
494
+ limit=4,
495
+ )
487
496
  heartbeat_result = handle_heartbeat(sid, clean_goal[:120], context_hint=context_hint[:500])
488
497
  attention = _attention_snapshot(sid.strip())
489
498
  anticipatory_warnings = _preview_prospective_triggers(clean_goal, context_hint.strip(), files_list)
@@ -540,6 +549,29 @@ def handle_task_open(
540
549
  response_reasons=response_contract["reasons"],
541
550
  response_high_stakes=response_contract["high_stakes"],
542
551
  )
552
+ protocol_context_key = f"protocol_task:{task['task_id']}"
553
+ capture_context_event(
554
+ event_type="protocol_task_opened",
555
+ title=clean_goal[:160],
556
+ summary=(context_hint or clean_goal)[:600],
557
+ body="\n".join(state["plan"][:5])[:1600] if state["plan"] else "",
558
+ context_key=protocol_context_key,
559
+ context_title=clean_goal[:160],
560
+ context_summary=(context_hint or clean_goal)[:600],
561
+ context_type="protocol_task",
562
+ state="active",
563
+ owner="nexo",
564
+ actor=sid,
565
+ source_type="protocol_task",
566
+ source_id=task["task_id"],
567
+ session_id=sid,
568
+ metadata={
569
+ "task_type": clean_type,
570
+ "area": area.strip(),
571
+ "files": files_list[:8],
572
+ },
573
+ ttl_hours=24,
574
+ )
543
575
  blocking_rule_ids = _extract_guard_blocking_ids(guard_summary) if guard_has_blocking else []
544
576
  if guard_has_blocking:
545
577
  _record_debt(
@@ -605,6 +637,10 @@ def handle_task_open(
605
637
  ),
606
638
  },
607
639
  "response_contract": response_contract,
640
+ "recent_context": {
641
+ "has_matches": bool(recent_bundle.get("has_matches")),
642
+ "excerpt": format_pre_action_context_bundle(recent_bundle, compact=True) if recent_bundle.get("has_matches") else "",
643
+ },
608
644
  "contract": {
609
645
  "must_verify": must_verify,
610
646
  "must_change_log": must_change_log,
@@ -835,6 +871,29 @@ def handle_task_close(
835
871
  followup_id=created_followup_id,
836
872
  outcome_notes=outcome_notes,
837
873
  )
874
+ capture_context_event(
875
+ event_type=f"protocol_task_{clean_outcome}",
876
+ title=(task.get("goal") or task_id)[:160],
877
+ summary=(outcome_notes or clean_evidence or clean_outcome)[:600],
878
+ body=(change_summary or change_why or "")[:1600],
879
+ context_key=f"protocol_task:{task_id}",
880
+ context_title=(task.get("goal") or task_id)[:160],
881
+ context_summary=(task.get("context_hint") or task.get("goal") or "")[:600],
882
+ context_type="protocol_task",
883
+ state="resolved" if clean_outcome in {"done", "cancelled"} else ("abandoned" if clean_outcome == "failed" else "blocked"),
884
+ owner="nexo",
885
+ actor=sid or task.get("session_id") or "nexo",
886
+ source_type="protocol_task",
887
+ source_id=task_id,
888
+ session_id=task.get("session_id") or sid,
889
+ metadata={
890
+ "outcome": clean_outcome,
891
+ "change_log_id": change_log_id,
892
+ "learning_id": learning_id,
893
+ "followup_id": created_followup_id,
894
+ },
895
+ ttl_hours=24,
896
+ )
838
897
  open_debts = list_protocol_debts(status="open", task_id=task_id, limit=20)
839
898
 
840
899
  response = {