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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +5 -1
- package/bin/nexo-brain.js +9 -0
- package/package.json +1 -1
- package/src/auto_update.py +9 -0
- package/src/cli.py +138 -0
- package/src/continuity.py +442 -0
- package/src/db/__init__.py +8 -0
- package/src/db/_continuity.py +171 -0
- package/src/db/_schema.py +33 -0
- package/src/db/_sessions.py +23 -7
- package/src/lifecycle_events.py +59 -11
- package/src/paths.py +11 -1
- package/src/plugins/update.py +49 -0
- package/src/runtime_versioning.py +342 -0
- package/src/server.py +103 -1
- package/src/tools_sessions.py +30 -0
- package/tool-enforcement-map.json +75 -0
|
@@ -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)
|
package/src/db/__init__.py
CHANGED
|
@@ -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
|
|