nexo-brain 3.1.5 → 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/scripts/deep-sleep/apply_findings.py +35 -11
- package/src/server.py +74 -1
- package/src/tools_hot_context.py +163 -0
- package/src/tools_sessions.py +77 -4
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
"""NEXO DB — recent events + hot context for 24h operational continuity."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import importlib
|
|
6
|
+
import re
|
|
7
|
+
import sqlite3
|
|
8
|
+
import sys
|
|
9
|
+
import unicodedata
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _core():
|
|
14
|
+
module = sys.modules.get("db._core")
|
|
15
|
+
if module is None:
|
|
16
|
+
module = importlib.import_module("db._core")
|
|
17
|
+
return module
|
|
18
|
+
|
|
19
|
+
DEFAULT_CONTEXT_TTL_HOURS = 24
|
|
20
|
+
MAX_CONTEXT_TTL_HOURS = 7 * 24
|
|
21
|
+
ACTIVE_CONTEXT_STATES = {"active", "waiting_user", "waiting_third_party", "blocked"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _table_exists(conn, table_name: str) -> bool:
|
|
25
|
+
try:
|
|
26
|
+
row = conn.execute(
|
|
27
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name = ? LIMIT 1",
|
|
28
|
+
(table_name,),
|
|
29
|
+
).fetchone()
|
|
30
|
+
except sqlite3.OperationalError:
|
|
31
|
+
return False
|
|
32
|
+
return row is not None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _hot_context_tables_available(conn) -> bool:
|
|
36
|
+
return _table_exists(conn, "hot_context") and _table_exists(conn, "recent_events")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _serialize_metadata(metadata: dict[str, Any] | None) -> str:
|
|
40
|
+
if not metadata:
|
|
41
|
+
return "{}"
|
|
42
|
+
try:
|
|
43
|
+
return json.dumps(metadata, ensure_ascii=True, sort_keys=True)
|
|
44
|
+
except Exception:
|
|
45
|
+
return "{}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _truncate(text: str | None, limit: int = 240) -> str:
|
|
49
|
+
if not text:
|
|
50
|
+
return ""
|
|
51
|
+
clean = str(text).strip()
|
|
52
|
+
return clean if len(clean) <= limit else clean[: limit - 3] + "..."
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _normalize_text(text: str | None) -> str:
|
|
56
|
+
if not text:
|
|
57
|
+
return ""
|
|
58
|
+
normalized = unicodedata.normalize("NFKD", str(text))
|
|
59
|
+
ascii_text = normalized.encode("ascii", "ignore").decode("ascii")
|
|
60
|
+
return ascii_text.lower()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _tokenize(text: str | None) -> set[str]:
|
|
64
|
+
normalized = _normalize_text(text)
|
|
65
|
+
return {
|
|
66
|
+
token
|
|
67
|
+
for token in re.findall(r"[a-z0-9][a-z0-9._:-]{1,}", normalized)
|
|
68
|
+
if len(token) >= 3
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _slugify(text: str | None) -> str:
|
|
73
|
+
normalized = _normalize_text(text)
|
|
74
|
+
slug = re.sub(r"[^a-z0-9]+", "-", normalized).strip("-")
|
|
75
|
+
return slug[:80]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def clamp_ttl_hours(ttl_hours: int | float | str | None) -> int:
|
|
79
|
+
try:
|
|
80
|
+
value = int(float(ttl_hours or DEFAULT_CONTEXT_TTL_HOURS))
|
|
81
|
+
except Exception:
|
|
82
|
+
value = DEFAULT_CONTEXT_TTL_HOURS
|
|
83
|
+
return max(1, min(value, MAX_CONTEXT_TTL_HOURS))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def derive_context_key(
|
|
87
|
+
*,
|
|
88
|
+
context_key: str = "",
|
|
89
|
+
topic: str = "",
|
|
90
|
+
title: str = "",
|
|
91
|
+
source_type: str = "",
|
|
92
|
+
source_id: str = "",
|
|
93
|
+
) -> str:
|
|
94
|
+
explicit = (context_key or "").strip()
|
|
95
|
+
if explicit:
|
|
96
|
+
return explicit
|
|
97
|
+
if source_type and source_id:
|
|
98
|
+
return f"{source_type.strip()}:{source_id.strip()}"
|
|
99
|
+
if topic.strip():
|
|
100
|
+
slug = _slugify(topic)
|
|
101
|
+
if slug:
|
|
102
|
+
return f"topic:{slug}"
|
|
103
|
+
slug = _slugify(title)
|
|
104
|
+
if slug:
|
|
105
|
+
return f"topic:{slug}"
|
|
106
|
+
return ""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def cleanup_expired_hot_context(now: float | None = None) -> dict[str, int]:
|
|
110
|
+
conn = _core().get_db()
|
|
111
|
+
if not _hot_context_tables_available(conn):
|
|
112
|
+
return {"deleted_events": 0, "deleted_contexts": 0}
|
|
113
|
+
ts = now if now is not None else _core().now_epoch()
|
|
114
|
+
deleted_events = conn.execute(
|
|
115
|
+
"DELETE FROM recent_events WHERE expires_at < ?",
|
|
116
|
+
(ts,),
|
|
117
|
+
).rowcount
|
|
118
|
+
deleted_contexts = conn.execute(
|
|
119
|
+
"DELETE FROM hot_context WHERE expires_at < ?",
|
|
120
|
+
(ts,),
|
|
121
|
+
).rowcount
|
|
122
|
+
conn.commit()
|
|
123
|
+
return {
|
|
124
|
+
"deleted_events": int(deleted_events or 0),
|
|
125
|
+
"deleted_contexts": int(deleted_contexts or 0),
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def remember_hot_context(
|
|
130
|
+
*,
|
|
131
|
+
context_key: str,
|
|
132
|
+
title: str,
|
|
133
|
+
summary: str = "",
|
|
134
|
+
context_type: str = "topic",
|
|
135
|
+
state: str = "active",
|
|
136
|
+
owner: str = "",
|
|
137
|
+
source_type: str = "",
|
|
138
|
+
source_id: str = "",
|
|
139
|
+
session_id: str = "",
|
|
140
|
+
metadata: dict[str, Any] | None = None,
|
|
141
|
+
ttl_hours: int | float | str | None = DEFAULT_CONTEXT_TTL_HOURS,
|
|
142
|
+
last_event_at: float | None = None,
|
|
143
|
+
) -> dict:
|
|
144
|
+
clean_key = (context_key or "").strip()
|
|
145
|
+
clean_title = _truncate(title or summary or clean_key, 160)
|
|
146
|
+
if not clean_key:
|
|
147
|
+
return {"error": "context_key is required"}
|
|
148
|
+
if not clean_title:
|
|
149
|
+
return {"error": "title is required"}
|
|
150
|
+
|
|
151
|
+
conn = _core().get_db()
|
|
152
|
+
if not _table_exists(conn, "hot_context"):
|
|
153
|
+
return {"skipped": True, "reason": "hot_context table unavailable"}
|
|
154
|
+
now = _core().now_epoch()
|
|
155
|
+
event_ts = float(last_event_at if last_event_at is not None else now)
|
|
156
|
+
ttl = clamp_ttl_hours(ttl_hours)
|
|
157
|
+
expires_at = max(event_ts, now) + ttl * 3600
|
|
158
|
+
clean_state = (state or "active").strip().lower()
|
|
159
|
+
|
|
160
|
+
existing = conn.execute(
|
|
161
|
+
"SELECT * FROM hot_context WHERE context_key = ?",
|
|
162
|
+
(clean_key,),
|
|
163
|
+
).fetchone()
|
|
164
|
+
if existing:
|
|
165
|
+
first_seen_at = float(existing["first_seen_at"] or event_ts)
|
|
166
|
+
summary_value = _truncate(summary, 600) if summary else (existing["summary"] or "")
|
|
167
|
+
metadata_value = _serialize_metadata(metadata) if metadata is not None else (existing["metadata"] or "{}")
|
|
168
|
+
conn.execute(
|
|
169
|
+
"""
|
|
170
|
+
UPDATE hot_context
|
|
171
|
+
SET title = ?,
|
|
172
|
+
summary = ?,
|
|
173
|
+
context_type = ?,
|
|
174
|
+
state = ?,
|
|
175
|
+
owner = ?,
|
|
176
|
+
source_type = ?,
|
|
177
|
+
source_id = ?,
|
|
178
|
+
session_id = ?,
|
|
179
|
+
metadata = ?,
|
|
180
|
+
last_event_at = ?,
|
|
181
|
+
expires_at = ?,
|
|
182
|
+
updated_at = ?
|
|
183
|
+
WHERE context_key = ?
|
|
184
|
+
""",
|
|
185
|
+
(
|
|
186
|
+
clean_title,
|
|
187
|
+
summary_value,
|
|
188
|
+
(context_type or existing["context_type"] or "topic").strip().lower(),
|
|
189
|
+
clean_state,
|
|
190
|
+
(owner or existing["owner"] or "").strip(),
|
|
191
|
+
(source_type or existing["source_type"] or "").strip(),
|
|
192
|
+
(source_id or existing["source_id"] or "").strip(),
|
|
193
|
+
(session_id or existing["session_id"] or "").strip(),
|
|
194
|
+
metadata_value,
|
|
195
|
+
event_ts,
|
|
196
|
+
expires_at,
|
|
197
|
+
now,
|
|
198
|
+
clean_key,
|
|
199
|
+
),
|
|
200
|
+
)
|
|
201
|
+
else:
|
|
202
|
+
first_seen_at = event_ts
|
|
203
|
+
conn.execute(
|
|
204
|
+
"""
|
|
205
|
+
INSERT INTO hot_context (
|
|
206
|
+
context_key, title, summary, context_type, state, owner,
|
|
207
|
+
source_type, source_id, session_id, metadata,
|
|
208
|
+
first_seen_at, last_event_at, expires_at, created_at, updated_at
|
|
209
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
210
|
+
""",
|
|
211
|
+
(
|
|
212
|
+
clean_key,
|
|
213
|
+
clean_title,
|
|
214
|
+
_truncate(summary, 600),
|
|
215
|
+
(context_type or "topic").strip().lower(),
|
|
216
|
+
clean_state,
|
|
217
|
+
(owner or "").strip(),
|
|
218
|
+
(source_type or "").strip(),
|
|
219
|
+
(source_id or "").strip(),
|
|
220
|
+
(session_id or "").strip(),
|
|
221
|
+
_serialize_metadata(metadata),
|
|
222
|
+
first_seen_at,
|
|
223
|
+
event_ts,
|
|
224
|
+
expires_at,
|
|
225
|
+
now,
|
|
226
|
+
now,
|
|
227
|
+
),
|
|
228
|
+
)
|
|
229
|
+
conn.commit()
|
|
230
|
+
row = conn.execute(
|
|
231
|
+
"SELECT * FROM hot_context WHERE context_key = ?",
|
|
232
|
+
(clean_key,),
|
|
233
|
+
).fetchone()
|
|
234
|
+
return dict(row) if row else {"error": f"hot_context {clean_key} not found after upsert"}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def record_recent_event(
|
|
238
|
+
*,
|
|
239
|
+
event_type: str,
|
|
240
|
+
title: str = "",
|
|
241
|
+
summary: str = "",
|
|
242
|
+
body: str = "",
|
|
243
|
+
context_key: str = "",
|
|
244
|
+
actor: str = "system",
|
|
245
|
+
source_type: str = "",
|
|
246
|
+
source_id: str = "",
|
|
247
|
+
session_id: str = "",
|
|
248
|
+
metadata: dict[str, Any] | None = None,
|
|
249
|
+
ttl_hours: int | float | str | None = DEFAULT_CONTEXT_TTL_HOURS,
|
|
250
|
+
created_at: float | None = None,
|
|
251
|
+
) -> dict:
|
|
252
|
+
clean_event = (event_type or "").strip().lower()
|
|
253
|
+
if not clean_event:
|
|
254
|
+
return {"error": "event_type is required"}
|
|
255
|
+
|
|
256
|
+
conn = _core().get_db()
|
|
257
|
+
if not _table_exists(conn, "recent_events"):
|
|
258
|
+
return {"skipped": True, "reason": "recent_events table unavailable"}
|
|
259
|
+
now = _core().now_epoch()
|
|
260
|
+
event_ts = float(created_at if created_at is not None else now)
|
|
261
|
+
ttl = clamp_ttl_hours(ttl_hours)
|
|
262
|
+
expires_at = max(event_ts, now) + ttl * 3600
|
|
263
|
+
conn.execute(
|
|
264
|
+
"""
|
|
265
|
+
INSERT INTO recent_events (
|
|
266
|
+
context_key, event_type, title, summary, body, actor,
|
|
267
|
+
source_type, source_id, session_id, metadata, created_at, expires_at
|
|
268
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
269
|
+
""",
|
|
270
|
+
(
|
|
271
|
+
(context_key or "").strip(),
|
|
272
|
+
clean_event,
|
|
273
|
+
_truncate(title, 160),
|
|
274
|
+
_truncate(summary, 600),
|
|
275
|
+
_truncate(body, 1600),
|
|
276
|
+
(actor or "system").strip(),
|
|
277
|
+
(source_type or "").strip(),
|
|
278
|
+
(source_id or "").strip(),
|
|
279
|
+
(session_id or "").strip(),
|
|
280
|
+
_serialize_metadata(metadata),
|
|
281
|
+
event_ts,
|
|
282
|
+
expires_at,
|
|
283
|
+
),
|
|
284
|
+
)
|
|
285
|
+
conn.commit()
|
|
286
|
+
row = conn.execute(
|
|
287
|
+
"SELECT * FROM recent_events ORDER BY id DESC LIMIT 1"
|
|
288
|
+
).fetchone()
|
|
289
|
+
return dict(row) if row else {"error": "recent_event insert failed"}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def capture_context_event(
|
|
293
|
+
*,
|
|
294
|
+
event_type: str,
|
|
295
|
+
title: str = "",
|
|
296
|
+
summary: str = "",
|
|
297
|
+
body: str = "",
|
|
298
|
+
context_key: str = "",
|
|
299
|
+
topic: str = "",
|
|
300
|
+
context_title: str = "",
|
|
301
|
+
context_summary: str = "",
|
|
302
|
+
context_type: str = "topic",
|
|
303
|
+
state: str = "active",
|
|
304
|
+
owner: str = "",
|
|
305
|
+
actor: str = "system",
|
|
306
|
+
source_type: str = "",
|
|
307
|
+
source_id: str = "",
|
|
308
|
+
session_id: str = "",
|
|
309
|
+
metadata: dict[str, Any] | None = None,
|
|
310
|
+
ttl_hours: int | float | str | None = DEFAULT_CONTEXT_TTL_HOURS,
|
|
311
|
+
created_at: float | None = None,
|
|
312
|
+
) -> dict:
|
|
313
|
+
cleanup_expired_hot_context()
|
|
314
|
+
conn = _core().get_db()
|
|
315
|
+
if not _hot_context_tables_available(conn):
|
|
316
|
+
return {
|
|
317
|
+
"context_key": derive_context_key(
|
|
318
|
+
context_key=context_key,
|
|
319
|
+
topic=topic,
|
|
320
|
+
title=context_title or title or summary,
|
|
321
|
+
source_type=source_type,
|
|
322
|
+
source_id=source_id,
|
|
323
|
+
),
|
|
324
|
+
"context": {"skipped": True, "reason": "hot_context tables unavailable"},
|
|
325
|
+
"event": {"skipped": True, "reason": "hot_context tables unavailable"},
|
|
326
|
+
}
|
|
327
|
+
clean_key = derive_context_key(
|
|
328
|
+
context_key=context_key,
|
|
329
|
+
topic=topic,
|
|
330
|
+
title=context_title or title or summary,
|
|
331
|
+
source_type=source_type,
|
|
332
|
+
source_id=source_id,
|
|
333
|
+
)
|
|
334
|
+
context = None
|
|
335
|
+
if clean_key:
|
|
336
|
+
context = remember_hot_context(
|
|
337
|
+
context_key=clean_key,
|
|
338
|
+
title=context_title or title or summary or clean_key,
|
|
339
|
+
summary=context_summary or summary or body,
|
|
340
|
+
context_type=context_type,
|
|
341
|
+
state=state,
|
|
342
|
+
owner=owner,
|
|
343
|
+
source_type=source_type,
|
|
344
|
+
source_id=source_id,
|
|
345
|
+
session_id=session_id,
|
|
346
|
+
metadata=metadata,
|
|
347
|
+
ttl_hours=ttl_hours,
|
|
348
|
+
last_event_at=created_at,
|
|
349
|
+
)
|
|
350
|
+
event = record_recent_event(
|
|
351
|
+
event_type=event_type,
|
|
352
|
+
title=title or context_title,
|
|
353
|
+
summary=summary or context_summary,
|
|
354
|
+
body=body,
|
|
355
|
+
context_key=clean_key,
|
|
356
|
+
actor=actor,
|
|
357
|
+
source_type=source_type,
|
|
358
|
+
source_id=source_id,
|
|
359
|
+
session_id=session_id,
|
|
360
|
+
metadata=metadata,
|
|
361
|
+
ttl_hours=ttl_hours,
|
|
362
|
+
created_at=created_at,
|
|
363
|
+
)
|
|
364
|
+
return {"context_key": clean_key, "context": context, "event": event}
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def get_hot_context(context_key: str, include_events: bool = False, limit: int = 10) -> dict | None:
|
|
368
|
+
cleanup_expired_hot_context()
|
|
369
|
+
conn = _core().get_db()
|
|
370
|
+
if not _table_exists(conn, "hot_context"):
|
|
371
|
+
return None
|
|
372
|
+
row = conn.execute(
|
|
373
|
+
"SELECT * FROM hot_context WHERE context_key = ?",
|
|
374
|
+
((context_key or "").strip(),),
|
|
375
|
+
).fetchone()
|
|
376
|
+
if not row:
|
|
377
|
+
return None
|
|
378
|
+
result = dict(row)
|
|
379
|
+
if include_events:
|
|
380
|
+
result["events"] = [
|
|
381
|
+
dict(item)
|
|
382
|
+
for item in conn.execute(
|
|
383
|
+
"""
|
|
384
|
+
SELECT * FROM recent_events
|
|
385
|
+
WHERE context_key = ?
|
|
386
|
+
ORDER BY created_at DESC
|
|
387
|
+
LIMIT ?
|
|
388
|
+
""",
|
|
389
|
+
((context_key or "").strip(), max(1, int(limit or 10))),
|
|
390
|
+
).fetchall()
|
|
391
|
+
]
|
|
392
|
+
return result
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _score_text_match(query_tokens: set[str], haystack: str) -> float:
|
|
396
|
+
if not query_tokens:
|
|
397
|
+
return 0.0
|
|
398
|
+
haystack_tokens = _tokenize(haystack)
|
|
399
|
+
if not haystack_tokens:
|
|
400
|
+
return 0.0
|
|
401
|
+
intersection = query_tokens & haystack_tokens
|
|
402
|
+
if not intersection:
|
|
403
|
+
return 0.0
|
|
404
|
+
smaller = min(len(query_tokens), len(haystack_tokens))
|
|
405
|
+
return len(intersection) / max(1, smaller)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def search_hot_context(query: str = "", *, hours: int = DEFAULT_CONTEXT_TTL_HOURS, limit: int = 10, state: str = "") -> list[dict]:
|
|
409
|
+
cleanup_expired_hot_context()
|
|
410
|
+
conn = _core().get_db()
|
|
411
|
+
if not _table_exists(conn, "hot_context"):
|
|
412
|
+
return []
|
|
413
|
+
ts = _core().now_epoch() - clamp_ttl_hours(hours) * 3600
|
|
414
|
+
if state.strip():
|
|
415
|
+
rows = conn.execute(
|
|
416
|
+
"SELECT * FROM hot_context WHERE last_event_at >= ? AND state = ? ORDER BY last_event_at DESC LIMIT 200",
|
|
417
|
+
(ts, state.strip().lower()),
|
|
418
|
+
).fetchall()
|
|
419
|
+
else:
|
|
420
|
+
rows = conn.execute(
|
|
421
|
+
"SELECT * FROM hot_context WHERE last_event_at >= ? ORDER BY last_event_at DESC LIMIT 200",
|
|
422
|
+
(ts,),
|
|
423
|
+
).fetchall()
|
|
424
|
+
query_tokens = _tokenize(query)
|
|
425
|
+
scored: list[dict] = []
|
|
426
|
+
for row in rows:
|
|
427
|
+
item = dict(row)
|
|
428
|
+
combined = " ".join(
|
|
429
|
+
[
|
|
430
|
+
item.get("context_key") or "",
|
|
431
|
+
item.get("title") or "",
|
|
432
|
+
item.get("summary") or "",
|
|
433
|
+
item.get("source_type") or "",
|
|
434
|
+
item.get("source_id") or "",
|
|
435
|
+
]
|
|
436
|
+
)
|
|
437
|
+
score = _score_text_match(query_tokens, combined) if query_tokens else 0.5
|
|
438
|
+
if query_tokens and score <= 0:
|
|
439
|
+
continue
|
|
440
|
+
current_ts = _core().now_epoch()
|
|
441
|
+
recency_boost = max(0.0, 1.0 - ((current_ts - float(item.get("last_event_at") or current_ts)) / (clamp_ttl_hours(hours) * 3600)))
|
|
442
|
+
item["_score"] = round(score + recency_boost * 0.35, 4)
|
|
443
|
+
scored.append(item)
|
|
444
|
+
scored.sort(key=lambda item: (item["_score"], item.get("last_event_at", 0)), reverse=True)
|
|
445
|
+
return scored[: max(1, int(limit or 10))]
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def search_recent_events(
|
|
449
|
+
query: str = "",
|
|
450
|
+
*,
|
|
451
|
+
hours: int = DEFAULT_CONTEXT_TTL_HOURS,
|
|
452
|
+
limit: int = 10,
|
|
453
|
+
context_keys: list[str] | None = None,
|
|
454
|
+
session_id: str = "",
|
|
455
|
+
exclude_source: tuple[str, str] | None = None,
|
|
456
|
+
) -> list[dict]:
|
|
457
|
+
cleanup_expired_hot_context()
|
|
458
|
+
conn = _core().get_db()
|
|
459
|
+
if not _table_exists(conn, "recent_events"):
|
|
460
|
+
return []
|
|
461
|
+
ts = _core().now_epoch() - clamp_ttl_hours(hours) * 3600
|
|
462
|
+
rows = conn.execute(
|
|
463
|
+
"SELECT * FROM recent_events WHERE created_at >= ? ORDER BY created_at DESC LIMIT 400",
|
|
464
|
+
(ts,),
|
|
465
|
+
).fetchall()
|
|
466
|
+
query_tokens = _tokenize(query)
|
|
467
|
+
context_filter = {item for item in (context_keys or []) if item}
|
|
468
|
+
scored: list[dict] = []
|
|
469
|
+
for row in rows:
|
|
470
|
+
item = dict(row)
|
|
471
|
+
if exclude_source and (
|
|
472
|
+
item.get("source_type") == exclude_source[0]
|
|
473
|
+
and item.get("source_id") == exclude_source[1]
|
|
474
|
+
):
|
|
475
|
+
continue
|
|
476
|
+
if session_id and item.get("session_id") == session_id:
|
|
477
|
+
session_bonus = 0.25
|
|
478
|
+
else:
|
|
479
|
+
session_bonus = 0.0
|
|
480
|
+
if context_filter and item.get("context_key") not in context_filter:
|
|
481
|
+
if not query_tokens:
|
|
482
|
+
continue
|
|
483
|
+
combined = " ".join(
|
|
484
|
+
[
|
|
485
|
+
item.get("context_key") or "",
|
|
486
|
+
item.get("title") or "",
|
|
487
|
+
item.get("summary") or "",
|
|
488
|
+
item.get("body") or "",
|
|
489
|
+
item.get("source_type") or "",
|
|
490
|
+
item.get("source_id") or "",
|
|
491
|
+
]
|
|
492
|
+
)
|
|
493
|
+
score = _score_text_match(query_tokens, combined) if query_tokens else 0.35
|
|
494
|
+
if query_tokens and score <= 0 and item.get("context_key") not in context_filter:
|
|
495
|
+
continue
|
|
496
|
+
current_ts = _core().now_epoch()
|
|
497
|
+
recency_boost = max(0.0, 1.0 - ((current_ts - float(item.get("created_at") or current_ts)) / (clamp_ttl_hours(hours) * 3600)))
|
|
498
|
+
item["_score"] = round(score + recency_boost * 0.45 + session_bonus, 4)
|
|
499
|
+
scored.append(item)
|
|
500
|
+
scored.sort(key=lambda item: (item["_score"], item.get("created_at", 0)), reverse=True)
|
|
501
|
+
return scored[: max(1, int(limit or 10))]
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _find_related_items(table: str, query: str, *, hours: int = DEFAULT_CONTEXT_TTL_HOURS, limit: int = 5) -> list[dict]:
|
|
505
|
+
conn = _core().get_db()
|
|
506
|
+
if not _table_exists(conn, table):
|
|
507
|
+
return []
|
|
508
|
+
query_tokens = _tokenize(query)
|
|
509
|
+
if not query_tokens:
|
|
510
|
+
return []
|
|
511
|
+
rows = conn.execute(
|
|
512
|
+
f"SELECT * FROM {table} ORDER BY updated_at DESC LIMIT 200"
|
|
513
|
+
).fetchall()
|
|
514
|
+
items: list[dict] = []
|
|
515
|
+
for row in rows:
|
|
516
|
+
item = dict(row)
|
|
517
|
+
combined = " ".join(
|
|
518
|
+
[
|
|
519
|
+
item.get("id") or "",
|
|
520
|
+
item.get("description") or "",
|
|
521
|
+
item.get("verification") or "",
|
|
522
|
+
item.get("reasoning") or "",
|
|
523
|
+
item.get("category") or "",
|
|
524
|
+
item.get("status") or "",
|
|
525
|
+
]
|
|
526
|
+
)
|
|
527
|
+
score = _score_text_match(query_tokens, combined)
|
|
528
|
+
if score <= 0:
|
|
529
|
+
continue
|
|
530
|
+
item["_score"] = round(score, 4)
|
|
531
|
+
items.append(item)
|
|
532
|
+
items.sort(key=lambda item: (item["_score"], item.get("updated_at", 0)), reverse=True)
|
|
533
|
+
return items[: max(1, int(limit or 5))]
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def build_pre_action_context(
|
|
537
|
+
*,
|
|
538
|
+
query: str = "",
|
|
539
|
+
context_key: str = "",
|
|
540
|
+
session_id: str = "",
|
|
541
|
+
hours: int = DEFAULT_CONTEXT_TTL_HOURS,
|
|
542
|
+
limit: int = 6,
|
|
543
|
+
) -> dict:
|
|
544
|
+
cleanup_expired_hot_context()
|
|
545
|
+
clean_query = (query or "").strip()
|
|
546
|
+
clean_key = (context_key or "").strip()
|
|
547
|
+
contexts: list[dict] = []
|
|
548
|
+
if clean_key:
|
|
549
|
+
exact = get_hot_context(clean_key, include_events=False)
|
|
550
|
+
if exact:
|
|
551
|
+
exact["_score"] = 1.0
|
|
552
|
+
contexts.append(exact)
|
|
553
|
+
searched = search_hot_context(clean_query, hours=hours, limit=limit, state="")
|
|
554
|
+
seen_keys = {item.get("context_key") for item in contexts}
|
|
555
|
+
for item in searched:
|
|
556
|
+
if item.get("context_key") not in seen_keys:
|
|
557
|
+
contexts.append(item)
|
|
558
|
+
seen_keys.add(item.get("context_key"))
|
|
559
|
+
contexts = contexts[: max(1, int(limit or 6))]
|
|
560
|
+
|
|
561
|
+
context_keys = [item.get("context_key") for item in contexts if item.get("context_key")]
|
|
562
|
+
events = search_recent_events(
|
|
563
|
+
clean_query,
|
|
564
|
+
hours=hours,
|
|
565
|
+
limit=max(4, int(limit or 6) * 2),
|
|
566
|
+
context_keys=context_keys,
|
|
567
|
+
session_id=session_id,
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
reminders = _find_related_items("reminders", clean_query, hours=hours, limit=4) if clean_query else []
|
|
571
|
+
followups = _find_related_items("followups", clean_query, hours=hours, limit=4) if clean_query else []
|
|
572
|
+
|
|
573
|
+
return {
|
|
574
|
+
"query": clean_query,
|
|
575
|
+
"context_key": clean_key,
|
|
576
|
+
"hours": clamp_ttl_hours(hours),
|
|
577
|
+
"contexts": contexts,
|
|
578
|
+
"events": events,
|
|
579
|
+
"reminders": reminders,
|
|
580
|
+
"followups": followups,
|
|
581
|
+
"has_matches": bool(contexts or events or reminders or followups),
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def format_pre_action_context_bundle(bundle: dict, *, compact: bool = False) -> str:
|
|
586
|
+
if not bundle or not bundle.get("has_matches"):
|
|
587
|
+
return "No recent context."
|
|
588
|
+
|
|
589
|
+
lines: list[str] = []
|
|
590
|
+
header = "RECENT CONTEXT (24h)"
|
|
591
|
+
if bundle.get("query"):
|
|
592
|
+
header += f" — query: {bundle['query'][:120]}"
|
|
593
|
+
lines.append(header)
|
|
594
|
+
|
|
595
|
+
contexts = bundle.get("contexts") or []
|
|
596
|
+
if contexts:
|
|
597
|
+
lines.append("Contexts:")
|
|
598
|
+
for item in contexts[: (3 if compact else 5)]:
|
|
599
|
+
state = item.get("state") or "active"
|
|
600
|
+
last_event = item.get("last_event_at") or "?"
|
|
601
|
+
summary = _truncate(item.get("summary") or "", 140)
|
|
602
|
+
suffix = f" — {summary}" if summary else ""
|
|
603
|
+
lines.append(f"- {item.get('context_key')}: [{state}] {item.get('title') or ''} ({last_event}){suffix}")
|
|
604
|
+
|
|
605
|
+
events = bundle.get("events") or []
|
|
606
|
+
if events:
|
|
607
|
+
lines.append("Events:")
|
|
608
|
+
for event in events[: (3 if compact else 6)]:
|
|
609
|
+
note = _truncate(event.get("summary") or event.get("body") or "", 140)
|
|
610
|
+
suffix = f" — {note}" if note else ""
|
|
611
|
+
lines.append(
|
|
612
|
+
f"- {event.get('created_at')} [{event.get('event_type')}] {event.get('title') or event.get('context_key') or '(event)'}{suffix}"
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
if not compact:
|
|
616
|
+
reminders = bundle.get("reminders") or []
|
|
617
|
+
if reminders:
|
|
618
|
+
lines.append("Related reminders:")
|
|
619
|
+
for item in reminders[:3]:
|
|
620
|
+
lines.append(f"- {item.get('id')}: {item.get('description') or ''} [{item.get('status') or '—'}]")
|
|
621
|
+
followups = bundle.get("followups") or []
|
|
622
|
+
if followups:
|
|
623
|
+
lines.append("Related followups:")
|
|
624
|
+
for item in followups[:3]:
|
|
625
|
+
lines.append(f"- {item.get('id')}: {item.get('description') or ''} [{item.get('status') or '—'}]")
|
|
626
|
+
|
|
627
|
+
return "\n".join(lines)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def resolve_hot_context(
|
|
631
|
+
*,
|
|
632
|
+
context_key: str,
|
|
633
|
+
resolution: str = "",
|
|
634
|
+
actor: str = "system",
|
|
635
|
+
session_id: str = "",
|
|
636
|
+
source_type: str = "",
|
|
637
|
+
source_id: str = "",
|
|
638
|
+
ttl_hours: int | float | str | None = DEFAULT_CONTEXT_TTL_HOURS,
|
|
639
|
+
) -> dict:
|
|
640
|
+
existing = get_hot_context(context_key, include_events=False)
|
|
641
|
+
if not existing:
|
|
642
|
+
return {"error": f"Unknown context_key: {context_key}"}
|
|
643
|
+
return capture_context_event(
|
|
644
|
+
event_type="resolved",
|
|
645
|
+
title=existing.get("title") or context_key,
|
|
646
|
+
summary=resolution or existing.get("summary") or "Context resolved.",
|
|
647
|
+
body=resolution,
|
|
648
|
+
context_key=context_key,
|
|
649
|
+
context_title=existing.get("title") or context_key,
|
|
650
|
+
context_summary=existing.get("summary") or resolution or "",
|
|
651
|
+
context_type=existing.get("context_type") or "topic",
|
|
652
|
+
state="resolved",
|
|
653
|
+
owner=existing.get("owner") or "",
|
|
654
|
+
actor=actor,
|
|
655
|
+
source_type=source_type or existing.get("source_type") or "",
|
|
656
|
+
source_id=source_id or existing.get("source_id") or "",
|
|
657
|
+
session_id=session_id or existing.get("session_id") or "",
|
|
658
|
+
metadata=None,
|
|
659
|
+
ttl_hours=ttl_hours,
|
|
660
|
+
)
|