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/db/_learnings.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
package/src/db/_reminders.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/plugins/protocol.py
CHANGED
|
@@ -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 = {
|