nexo-brain 3.1.6 → 3.1.9

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.
@@ -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
+ )