nexo-brain 7.9.4 → 7.9.6

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,442 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import time
5
+ from datetime import datetime, timedelta, timezone
6
+
7
+ from db import (
8
+ get_db,
9
+ latest_continuity_snapshot,
10
+ list_continuity_snapshots,
11
+ read_checkpoint,
12
+ write_continuity_snapshot,
13
+ )
14
+ from tools_sessions import _session_portability_bundle
15
+
16
+
17
+ RESUME_BUNDLE_TOKEN_BUDGET_DEFAULT = 2000
18
+ UNSAFE_SID_STALE_TTL_SECONDS = 30 * 60
19
+ RECENT_DIARY_WINDOW_MINUTES = 30
20
+
21
+
22
+ def _utc_now() -> str:
23
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
24
+
25
+
26
+ def _parse_dt(value: str) -> datetime | None:
27
+ text = str(value or "").strip()
28
+ if not text:
29
+ return None
30
+ for fmt in ("%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%d %H:%M:%S"):
31
+ try:
32
+ dt = datetime.strptime(text, fmt)
33
+ return dt.replace(tzinfo=timezone.utc)
34
+ except Exception:
35
+ continue
36
+ return None
37
+
38
+
39
+ def _estimate_tokens(value) -> int:
40
+ if value is None:
41
+ return 0
42
+ if not isinstance(value, str):
43
+ value = json.dumps(value, ensure_ascii=False)
44
+ return max(1, int(len(value) / 4))
45
+
46
+
47
+ def _recent_diary_for_session(session_id: str = "") -> dict:
48
+ sid = str(session_id or "").strip()
49
+ if not sid:
50
+ return {}
51
+ conn = get_db()
52
+ row = conn.execute(
53
+ """
54
+ SELECT summary, decisions, pending, context_next, created_at
55
+ FROM session_diary
56
+ WHERE session_id = ?
57
+ ORDER BY created_at DESC
58
+ LIMIT 1
59
+ """,
60
+ (sid,),
61
+ ).fetchone()
62
+ if not row:
63
+ return {}
64
+ item = dict(row)
65
+ created_dt = _parse_dt(str(item.get("created_at") or ""))
66
+ if created_dt:
67
+ window_start = datetime.now(timezone.utc) - timedelta(minutes=RECENT_DIARY_WINDOW_MINUTES)
68
+ item["within_recent_window"] = created_dt >= window_start
69
+ return item
70
+
71
+
72
+ def _resolve_session_row(sid: str = "", conversation_id: str = "") -> dict | None:
73
+ conn = get_db()
74
+ sid = str(sid or "").strip()
75
+ conversation_id = str(conversation_id or "").strip()
76
+ if sid:
77
+ row = conn.execute(
78
+ """
79
+ SELECT sid, task, started_epoch, last_update_epoch, local_time,
80
+ claude_session_id, external_session_id, session_client, conversation_id
81
+ FROM sessions
82
+ WHERE sid = ?
83
+ LIMIT 1
84
+ """,
85
+ (sid,),
86
+ ).fetchone()
87
+ if row:
88
+ return dict(row)
89
+ if conversation_id:
90
+ row = conn.execute(
91
+ """
92
+ SELECT sid, task, started_epoch, last_update_epoch, local_time,
93
+ claude_session_id, external_session_id, session_client, conversation_id
94
+ FROM sessions
95
+ WHERE conversation_id = ?
96
+ ORDER BY last_update_epoch DESC
97
+ LIMIT 1
98
+ """,
99
+ (conversation_id,),
100
+ ).fetchone()
101
+ if row:
102
+ return dict(row)
103
+ return None
104
+
105
+
106
+ def _active_session_rows_for_conversation(conversation_id: str) -> list[dict]:
107
+ conv = str(conversation_id or "").strip()
108
+ if not conv:
109
+ return []
110
+ conn = get_db()
111
+ cutoff = time.time() - 900
112
+ rows = conn.execute(
113
+ """
114
+ SELECT sid, task, started_epoch, last_update_epoch, external_session_id,
115
+ session_client, conversation_id
116
+ FROM sessions
117
+ WHERE conversation_id = ? AND last_update_epoch > ?
118
+ ORDER BY last_update_epoch DESC
119
+ """,
120
+ (conv, cutoff),
121
+ ).fetchall()
122
+ return [dict(row) for row in rows]
123
+
124
+
125
+ def _resolve_unsafe_state(
126
+ *,
127
+ conversation_id: str = "",
128
+ session_id: str = "",
129
+ external_session_id: str = "",
130
+ client: str = "",
131
+ ) -> tuple[bool, list[str], dict | None, dict | None]:
132
+ conv = str(conversation_id or "").strip()
133
+ sid = str(session_id or "").strip()
134
+ external = str(external_session_id or "").strip()
135
+ client_label = str(client or "").strip()
136
+
137
+ session_row = _resolve_session_row(sid=sid, conversation_id=conv)
138
+ latest_snapshot = latest_continuity_snapshot(conversation_id=conv, session_id=sid)
139
+ reasons: list[str] = []
140
+
141
+ if sid and session_row is None and latest_snapshot is None:
142
+ reasons.append("sid_not_found")
143
+ active_rows = _active_session_rows_for_conversation(conv) if conv else []
144
+ active_sids = {row["sid"] for row in active_rows if row.get("sid")}
145
+ if conv and len(active_sids) > 1:
146
+ reasons.append("conversation_has_multiple_active_sessions")
147
+ if session_row and conv and session_row.get("conversation_id") and session_row["conversation_id"] != conv:
148
+ reasons.append("conversation_id_mismatch")
149
+ if session_row and external and session_row.get("external_session_id") and session_row["external_session_id"] != external:
150
+ reasons.append("external_session_id_mismatch")
151
+ if latest_snapshot and client_label:
152
+ snapshot_client = str(latest_snapshot.get("client") or "").strip()
153
+ if snapshot_client and snapshot_client != client_label:
154
+ reasons.append("client_mismatch")
155
+ if latest_snapshot and session_row and sid:
156
+ snap_dt = _parse_dt(str(latest_snapshot.get("created_at") or "")) or _parse_dt(str(latest_snapshot.get("updated_at") or ""))
157
+ if snap_dt and session_row.get("started_epoch"):
158
+ start_dt = datetime.fromtimestamp(float(session_row["started_epoch"]), tz=timezone.utc)
159
+ if (snap_dt - start_dt).total_seconds() > UNSAFE_SID_STALE_TTL_SECONDS and session_row.get("last_update_epoch", 0) < session_row.get("started_epoch", 0):
160
+ reasons.append("startup_older_than_snapshot_ttl")
161
+
162
+ return (len(reasons) > 0), reasons, session_row, latest_snapshot
163
+
164
+
165
+ def _build_resume_lines(bundle: dict) -> str:
166
+ lines = [
167
+ "[NEXO Continuity Resume]",
168
+ f"conversation_id={bundle.get('conversation_id', '')}",
169
+ ]
170
+ if bundle.get("session_id"):
171
+ lines.append(f"session_id={bundle['session_id']}")
172
+ objective = str(bundle.get("objective") or "").strip()
173
+ if objective:
174
+ lines.append(f"objective={objective}")
175
+ pending = bundle.get("pending") or []
176
+ if pending:
177
+ lines.append("pending:")
178
+ for item in pending[:5]:
179
+ lines.append(f"- {item}")
180
+ decisions = bundle.get("decisions") or []
181
+ if decisions:
182
+ lines.append("recent_decisions:")
183
+ for item in decisions[:4]:
184
+ lines.append(f"- {item}")
185
+ errors = bundle.get("recent_errors") or []
186
+ if errors:
187
+ lines.append("recent_errors:")
188
+ for item in errors[:3]:
189
+ lines.append(f"- {item}")
190
+ transcript_tail = bundle.get("transcript_tail") or []
191
+ if transcript_tail:
192
+ lines.append("transcript_tail:")
193
+ for item in transcript_tail[:4]:
194
+ lines.append(f"- {item}")
195
+ hot_context = str(bundle.get("hot_context") or "").strip()
196
+ if hot_context:
197
+ lines.append("hot_context:")
198
+ lines.append(hot_context)
199
+ diary = str(bundle.get("latest_diary_summary") or "").strip()
200
+ if diary:
201
+ lines.append(f"latest_diary={diary}")
202
+ return "\n".join(lines)
203
+
204
+
205
+ def _truncate_bundle(bundle: dict, *, token_budget: int) -> dict:
206
+ current = dict(bundle)
207
+ for field in ["latest_diary_summary", "hot_context", "transcript_tail", "recent_errors", "decisions", "pending"]:
208
+ rendered = _build_resume_lines(current)
209
+ if _estimate_tokens(rendered) <= token_budget:
210
+ current["size_tokens_estimated"] = _estimate_tokens(rendered)
211
+ current["resume_text"] = rendered
212
+ return current
213
+ value = current.get(field)
214
+ if isinstance(value, list):
215
+ current[field] = value[: max(0, len(value) // 2)]
216
+ elif isinstance(value, str):
217
+ current[field] = value[: max(120, int(len(value) * 0.5))]
218
+ rendered = _build_resume_lines(current)
219
+ current["size_tokens_estimated"] = _estimate_tokens(rendered)
220
+ current["resume_text"] = rendered
221
+ return current
222
+
223
+
224
+ def write_snapshot(
225
+ *,
226
+ conversation_id: str,
227
+ session_id: str = "",
228
+ external_session_id: str = "",
229
+ client: str = "",
230
+ event_type: str = "turn_end",
231
+ payload=None,
232
+ trace_id: str = "",
233
+ idempotency_key: str = "",
234
+ ) -> dict:
235
+ unsafe, reasons, _session_row, _latest = _resolve_unsafe_state(
236
+ conversation_id=conversation_id,
237
+ session_id=session_id,
238
+ external_session_id=external_session_id,
239
+ client=client,
240
+ )
241
+ if unsafe and "conversation_has_multiple_active_sessions" in reasons:
242
+ return {
243
+ "ok": False,
244
+ "unsafe_sid": True,
245
+ "reasons": reasons,
246
+ "conversation_id": str(conversation_id or "").strip(),
247
+ "session_id": str(session_id or "").strip(),
248
+ }
249
+ snapshot = write_continuity_snapshot(
250
+ conversation_id=conversation_id,
251
+ session_id=session_id,
252
+ external_session_id=external_session_id,
253
+ client=client,
254
+ event_type=event_type,
255
+ payload=payload,
256
+ trace_id=trace_id,
257
+ idempotency_key=idempotency_key,
258
+ )
259
+ return {
260
+ "ok": True,
261
+ "unsafe_sid": False,
262
+ "conversation_id": snapshot.get("conversation_id"),
263
+ "session_id": snapshot.get("session_id"),
264
+ "snapshot_id": snapshot.get("id"),
265
+ "trace_id": snapshot.get("trace_id") or trace_id,
266
+ "idempotency_key": snapshot.get("idempotency_key"),
267
+ "event_type": snapshot.get("event_type"),
268
+ }
269
+
270
+
271
+ def read_snapshot(
272
+ *,
273
+ conversation_id: str = "",
274
+ session_id: str = "",
275
+ limit: int = 20,
276
+ ) -> dict:
277
+ rows = list_continuity_snapshots(
278
+ conversation_id=conversation_id,
279
+ session_id=session_id,
280
+ limit=limit,
281
+ )
282
+ return {
283
+ "ok": True,
284
+ "conversation_id": str(conversation_id or "").strip(),
285
+ "session_id": str(session_id or "").strip(),
286
+ "count": len(rows),
287
+ "items": rows,
288
+ }
289
+
290
+
291
+ def build_resume_bundle(
292
+ *,
293
+ conversation_id: str = "",
294
+ session_id: str = "",
295
+ external_session_id: str = "",
296
+ client: str = "",
297
+ token_budget: int = RESUME_BUNDLE_TOKEN_BUDGET_DEFAULT,
298
+ ) -> dict:
299
+ unsafe, reasons, session_row, latest_snapshot = _resolve_unsafe_state(
300
+ conversation_id=conversation_id,
301
+ session_id=session_id,
302
+ external_session_id=external_session_id,
303
+ client=client,
304
+ )
305
+ conv = str(conversation_id or (session_row or {}).get("conversation_id") or (latest_snapshot or {}).get("conversation_id") or "").strip()
306
+ sid = str(session_id or (session_row or {}).get("sid") or (latest_snapshot or {}).get("session_id") or "").strip()
307
+
308
+ if unsafe:
309
+ return {
310
+ "ok": True,
311
+ "unsafe_sid": True,
312
+ "conversation_id": conv,
313
+ "session_id": sid,
314
+ "reasons": reasons,
315
+ "bundle": {},
316
+ "resume_text": "",
317
+ }
318
+
319
+ portability = _session_portability_bundle(sid) if sid else {"ok": False}
320
+ checkpoint = dict(read_checkpoint(sid) or {}) if sid else {}
321
+ diary = _recent_diary_for_session(sid)
322
+ snapshot_payload = dict((latest_snapshot or {}).get("payload") or {})
323
+ transcript_tail = snapshot_payload.get("transcript_tail") or snapshot_payload.get("messages") or []
324
+ if isinstance(transcript_tail, list):
325
+ transcript_tail = [str(item).strip() for item in transcript_tail if str(item).strip()][:8]
326
+ else:
327
+ transcript_tail = [str(transcript_tail).strip()] if str(transcript_tail).strip() else []
328
+
329
+ objective = (
330
+ snapshot_payload.get("current_goal")
331
+ or snapshot_payload.get("goal")
332
+ or checkpoint.get("current_goal")
333
+ or (portability.get("session") or {}).get("task")
334
+ or ""
335
+ )
336
+ raw_pending = diary.get("pending") or snapshot_payload.get("pending") or checkpoint.get("next_step") or ""
337
+ if isinstance(raw_pending, list):
338
+ pending_items = [str(item).strip() for item in raw_pending if str(item).strip()]
339
+ else:
340
+ pending_items = [part.strip(" -") for part in str(raw_pending).splitlines() if part.strip()]
341
+ checkpoint_next = str(checkpoint.get("next_step") or "").strip()
342
+ if checkpoint_next and checkpoint_next not in pending_items:
343
+ pending_items.insert(0, checkpoint_next)
344
+
345
+ raw_decisions = diary.get("decisions") or checkpoint.get("decisions_summary") or snapshot_payload.get("decisions") or ""
346
+ if isinstance(raw_decisions, list):
347
+ decisions = [str(item).strip() for item in raw_decisions if str(item).strip()]
348
+ else:
349
+ decisions = [part.strip(" -") for part in str(raw_decisions).splitlines() if part.strip()]
350
+
351
+ raw_errors = checkpoint.get("errors_found") or snapshot_payload.get("recent_errors") or ""
352
+ if isinstance(raw_errors, list):
353
+ recent_errors = [str(item).strip() for item in raw_errors if str(item).strip()]
354
+ else:
355
+ recent_errors = [part.strip(" -") for part in str(raw_errors).splitlines() if part.strip()]
356
+
357
+ hot_context = portability.get("recent_context") if portability.get("ok") else {}
358
+ bundle = {
359
+ "ok": True,
360
+ "unsafe_sid": False,
361
+ "bundle_version": 1,
362
+ "schema_version": 1,
363
+ "generated_at": _utc_now(),
364
+ "conversation_id": conv,
365
+ "session_id": sid,
366
+ "identity": {
367
+ "conversation_id": conv,
368
+ "session_id": sid,
369
+ "external_session_id": (session_row or {}).get("external_session_id", ""),
370
+ "client": client or (session_row or {}).get("session_client", "") or (latest_snapshot or {}).get("client", ""),
371
+ },
372
+ "objective": str(objective or "").strip(),
373
+ "pending": pending_items,
374
+ "decisions": decisions,
375
+ "recent_errors": recent_errors,
376
+ "transcript_tail": transcript_tail,
377
+ "hot_context": json.dumps(hot_context, ensure_ascii=False) if hot_context else "",
378
+ "latest_diary_summary": str(diary.get("summary") or "").strip(),
379
+ "trace_id": (latest_snapshot or {}).get("trace_id", ""),
380
+ }
381
+ return _truncate_bundle(bundle, token_budget=max(400, int(token_budget or RESUME_BUNDLE_TOKEN_BUDGET_DEFAULT)))
382
+
383
+
384
+ def record_compaction_event(
385
+ *,
386
+ conversation_id: str,
387
+ session_id: str = "",
388
+ payload=None,
389
+ trace_id: str = "",
390
+ event_type: str = "post_compact",
391
+ ) -> dict:
392
+ return write_snapshot(
393
+ conversation_id=conversation_id,
394
+ session_id=session_id,
395
+ client="brain",
396
+ event_type=event_type,
397
+ payload=payload,
398
+ trace_id=trace_id,
399
+ )
400
+
401
+
402
+ def continuity_audit(
403
+ *,
404
+ conversation_id: str,
405
+ limit: int = 50,
406
+ ) -> dict:
407
+ conv = str(conversation_id or "").strip()
408
+ items = list_continuity_snapshots(conversation_id=conv, limit=limit)
409
+ session_ids = [item.get("session_id") for item in items if item.get("session_id")]
410
+ diaries = []
411
+ if session_ids:
412
+ conn = get_db()
413
+ placeholders = ",".join("?" for _ in session_ids)
414
+ rows = conn.execute(
415
+ f"""
416
+ SELECT session_id, created_at, summary, decisions, pending
417
+ FROM session_diary
418
+ WHERE session_id IN ({placeholders})
419
+ ORDER BY created_at DESC
420
+ LIMIT ?
421
+ """,
422
+ (*session_ids, max(1, int(limit or 50))),
423
+ ).fetchall()
424
+ diaries = [dict(row) for row in rows]
425
+ return {
426
+ "ok": True,
427
+ "conversation_id": conv,
428
+ "snapshot_count": len(items),
429
+ "items": items,
430
+ "diaries": diaries,
431
+ }
432
+
433
+
434
+ def format_bundle_text(bundle: dict) -> str:
435
+ if bundle.get("unsafe_sid"):
436
+ reasons = ", ".join(bundle.get("reasons") or []) or "unknown"
437
+ return f"unsafe_sid=true ({reasons})"
438
+ return str(bundle.get("resume_text") or "").strip()
439
+
440
+
441
+ def json_dumps(data: dict) -> str:
442
+ return json.dumps(data, ensure_ascii=False)
@@ -54,6 +54,7 @@ _hot_context = _load_submodule("db._hot_context")
54
54
  _drive = _load_submodule("db._drive")
55
55
  _outcomes = _load_submodule("db._outcomes")
56
56
  _goal_profiles = _load_submodule("db._goal_profiles")
57
+ _continuity = _load_submodule("db._continuity")
57
58
 
58
59
  # Core: connection, constants, init, utils
59
60
  from db._core import (
@@ -85,6 +86,13 @@ from db._sessions import (
85
86
  count_pending_inbox_messages, resolve_sid_from_external,
86
87
  )
87
88
 
89
+ from db._continuity import (
90
+ build_snapshot_idempotency_key,
91
+ write_continuity_snapshot,
92
+ list_continuity_snapshots,
93
+ latest_continuity_snapshot,
94
+ )
95
+
88
96
  # PostToolUse inbox-reminder rate limit (v6.0.1)
89
97
  _hook_inbox_reminders = _load_submodule("db._hook_inbox_reminders")
90
98
  from db._hook_inbox_reminders import (
@@ -0,0 +1,171 @@
1
+ from __future__ import annotations
2
+
3
+ """Durable continuity snapshots used by Desktop and compaction recovery."""
4
+
5
+ import hashlib
6
+ import json
7
+ from datetime import datetime, timezone
8
+
9
+ from db._core import get_db
10
+
11
+
12
+ def _utc_now() -> str:
13
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
14
+
15
+
16
+ def _normalize_payload(payload) -> dict:
17
+ if isinstance(payload, dict):
18
+ return payload
19
+ if isinstance(payload, str):
20
+ text = payload.strip()
21
+ if not text:
22
+ return {}
23
+ try:
24
+ loaded = json.loads(text)
25
+ except Exception:
26
+ return {"raw": text}
27
+ return loaded if isinstance(loaded, dict) else {"value": loaded}
28
+ return {}
29
+
30
+
31
+ def build_snapshot_idempotency_key(
32
+ *,
33
+ conversation_id: str,
34
+ session_id: str = "",
35
+ event_type: str = "",
36
+ trace_id: str = "",
37
+ payload=None,
38
+ ) -> str:
39
+ normalized = json.dumps(_normalize_payload(payload), sort_keys=True, ensure_ascii=False)
40
+ seed = "|".join(
41
+ [
42
+ str(conversation_id or "").strip(),
43
+ str(session_id or "").strip(),
44
+ str(event_type or "").strip(),
45
+ str(trace_id or "").strip(),
46
+ normalized,
47
+ ]
48
+ )
49
+ return hashlib.sha1(seed.encode("utf-8")).hexdigest()
50
+
51
+
52
+ def write_continuity_snapshot(
53
+ *,
54
+ conversation_id: str,
55
+ session_id: str = "",
56
+ external_session_id: str = "",
57
+ client: str = "",
58
+ event_type: str = "turn_end",
59
+ payload=None,
60
+ trace_id: str = "",
61
+ idempotency_key: str = "",
62
+ ) -> dict:
63
+ conversation_id = str(conversation_id or "").strip()
64
+ if not conversation_id:
65
+ raise ValueError("conversation_id is required")
66
+
67
+ payload_dict = _normalize_payload(payload)
68
+ idem = str(idempotency_key or "").strip() or build_snapshot_idempotency_key(
69
+ conversation_id=conversation_id,
70
+ session_id=session_id,
71
+ event_type=event_type,
72
+ trace_id=trace_id,
73
+ payload=payload_dict,
74
+ )
75
+ conn = get_db()
76
+ conn.execute(
77
+ """
78
+ INSERT INTO continuity_snapshots (
79
+ conversation_id, session_id, external_session_id, client,
80
+ event_type, payload_json, trace_id, idempotency_key, created_at, updated_at
81
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
82
+ ON CONFLICT(conversation_id, idempotency_key) DO UPDATE SET
83
+ session_id = excluded.session_id,
84
+ external_session_id = excluded.external_session_id,
85
+ client = excluded.client,
86
+ event_type = excluded.event_type,
87
+ payload_json = excluded.payload_json,
88
+ trace_id = excluded.trace_id,
89
+ updated_at = excluded.updated_at
90
+ """,
91
+ (
92
+ conversation_id,
93
+ str(session_id or "").strip(),
94
+ str(external_session_id or "").strip(),
95
+ str(client or "").strip(),
96
+ str(event_type or "turn_end").strip(),
97
+ json.dumps(payload_dict, ensure_ascii=False),
98
+ str(trace_id or "").strip(),
99
+ idem,
100
+ _utc_now(),
101
+ _utc_now(),
102
+ ),
103
+ )
104
+ conn.commit()
105
+ row = conn.execute(
106
+ """
107
+ SELECT id, conversation_id, session_id, external_session_id, client,
108
+ event_type, payload_json, trace_id, idempotency_key, created_at, updated_at
109
+ FROM continuity_snapshots
110
+ WHERE conversation_id = ? AND idempotency_key = ?
111
+ LIMIT 1
112
+ """,
113
+ (conversation_id, idem),
114
+ ).fetchone()
115
+ snapshot = dict(row) if row else {}
116
+ try:
117
+ snapshot["payload"] = json.loads(snapshot.get("payload_json") or "{}")
118
+ except Exception:
119
+ snapshot["payload"] = {}
120
+ return snapshot
121
+
122
+
123
+ def list_continuity_snapshots(
124
+ *,
125
+ conversation_id: str = "",
126
+ session_id: str = "",
127
+ limit: int = 20,
128
+ ) -> list[dict]:
129
+ conn = get_db()
130
+ clauses = []
131
+ params: list[object] = []
132
+ if conversation_id:
133
+ clauses.append("conversation_id = ?")
134
+ params.append(str(conversation_id).strip())
135
+ if session_id:
136
+ clauses.append("session_id = ?")
137
+ params.append(str(session_id).strip())
138
+ where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
139
+ rows = conn.execute(
140
+ f"""
141
+ SELECT id, conversation_id, session_id, external_session_id, client,
142
+ event_type, payload_json, trace_id, idempotency_key, created_at, updated_at
143
+ FROM continuity_snapshots
144
+ {where}
145
+ ORDER BY id DESC
146
+ LIMIT ?
147
+ """,
148
+ (*params, max(1, int(limit or 20))),
149
+ ).fetchall()
150
+ result: list[dict] = []
151
+ for row in rows:
152
+ item = dict(row)
153
+ try:
154
+ item["payload"] = json.loads(item.get("payload_json") or "{}")
155
+ except Exception:
156
+ item["payload"] = {}
157
+ result.append(item)
158
+ return result
159
+
160
+
161
+ def latest_continuity_snapshot(
162
+ *,
163
+ conversation_id: str = "",
164
+ session_id: str = "",
165
+ ) -> dict | None:
166
+ rows = list_continuity_snapshots(
167
+ conversation_id=conversation_id,
168
+ session_id=session_id,
169
+ limit=1,
170
+ )
171
+ return rows[0] if rows else None
package/src/db/_schema.py CHANGED
@@ -1393,6 +1393,37 @@ def _m52_lifecycle_canonical_plan(conn):
1393
1393
  _migrate_add_index(conn, "idx_lifecycle_events_plan_id", "lifecycle_events", "canonical_plan_id")
1394
1394
 
1395
1395
 
1396
+ def _m53_session_conversation_identity(conn):
1397
+ """Stable Desktop conversation identity independent from the runtime SID."""
1398
+ _migrate_add_column(conn, "sessions", "conversation_id", "TEXT DEFAULT ''")
1399
+ _migrate_add_index(conn, "idx_sessions_conversation_id", "sessions", "conversation_id")
1400
+
1401
+
1402
+ def _m54_continuity_snapshots(conn):
1403
+ """Durable continuity snapshots for Desktop/Brain handoff and audit."""
1404
+ conn.execute(
1405
+ """
1406
+ CREATE TABLE IF NOT EXISTS continuity_snapshots (
1407
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1408
+ conversation_id TEXT NOT NULL,
1409
+ session_id TEXT DEFAULT '',
1410
+ external_session_id TEXT DEFAULT '',
1411
+ client TEXT DEFAULT '',
1412
+ event_type TEXT NOT NULL DEFAULT 'turn_end',
1413
+ payload_json TEXT NOT NULL DEFAULT '{}',
1414
+ trace_id TEXT DEFAULT '',
1415
+ idempotency_key TEXT NOT NULL DEFAULT '',
1416
+ created_at TEXT DEFAULT (datetime('now')),
1417
+ updated_at TEXT DEFAULT (datetime('now')),
1418
+ UNIQUE(conversation_id, idempotency_key)
1419
+ )
1420
+ """
1421
+ )
1422
+ _migrate_add_index(conn, "idx_continuity_snapshots_conv", "continuity_snapshots", "conversation_id")
1423
+ _migrate_add_index(conn, "idx_continuity_snapshots_sid", "continuity_snapshots", "session_id")
1424
+ _migrate_add_index(conn, "idx_continuity_snapshots_created", "continuity_snapshots", "created_at")
1425
+
1426
+
1396
1427
  MIGRATIONS = [
1397
1428
  (1, "learnings_columns", _m1_learnings_columns),
1398
1429
  (2, "followups_reasoning", _m2_followups_reasoning),
@@ -1446,6 +1477,8 @@ MIGRATIONS = [
1446
1477
  (50, "dedupe_nexo_product_learning_pair", _m50_dedupe_nexo_product_learning_pair),
1447
1478
  (51, "lifecycle_events", _m51_lifecycle_events),
1448
1479
  (52, "lifecycle_canonical_plan", _m52_lifecycle_canonical_plan),
1480
+ (53, "session_conversation_identity", _m53_session_conversation_identity),
1481
+ (54, "continuity_snapshots", _m54_continuity_snapshots),
1449
1482
  ]
1450
1483
 
1451
1484