synapse-cli-agent 0.1.13__py3-none-any.whl

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.
Files changed (131) hide show
  1. synapse/__init__.py +13 -0
  2. synapse/__main__.py +6 -0
  3. synapse/app/__init__.py +1 -0
  4. synapse/app/agent.py +492 -0
  5. synapse/app/agent_md.py +107 -0
  6. synapse/cli.py +750 -0
  7. synapse/commands/__init__.py +1 -0
  8. synapse/commands/compression.py +573 -0
  9. synapse/commands/helpers.py +22 -0
  10. synapse/commands/mcp.py +406 -0
  11. synapse/commands/model.py +173 -0
  12. synapse/commands/result.py +34 -0
  13. synapse/commands/sessions.py +443 -0
  14. synapse/commands/slash_cmds.py +521 -0
  15. synapse/commands/slash_complete.py +816 -0
  16. synapse/commands/theme.py +99 -0
  17. synapse/config.py +27 -0
  18. synapse/content/__init__.py +1 -0
  19. synapse/content/input_history.py +122 -0
  20. synapse/content/multimodal.py +733 -0
  21. synapse/content/prompts.py +249 -0
  22. synapse/content/skills_catalog.py +128 -0
  23. synapse/integrations/__init__.py +1 -0
  24. synapse/integrations/checkpoint_seed.py +281 -0
  25. synapse/integrations/codex_history.py +375 -0
  26. synapse/integrations/codex_import.py +393 -0
  27. synapse/integrations/codex_sessions.py +629 -0
  28. synapse/integrations/describe_image.py +370 -0
  29. synapse/integrations/http_clients.py +199 -0
  30. synapse/integrations/llm_openai_compat.py +90 -0
  31. synapse/integrations/llm_openai_websocket.py +187 -0
  32. synapse/integrations/mcp_client.py +646 -0
  33. synapse/integrations/vision_middleware.py +62 -0
  34. synapse/models/__init__.py +5 -0
  35. synapse/models/config.py +240 -0
  36. synapse/models/helpers.py +206 -0
  37. synapse/models/profile.py +59 -0
  38. synapse/models/registry.py +722 -0
  39. synapse/models_registry.py +7 -0
  40. synapse/observability/__init__.py +1 -0
  41. synapse/observability/startup_trace.py +127 -0
  42. synapse/runtime/__init__.py +1 -0
  43. synapse/runtime/async_runtime.py +176 -0
  44. synapse/runtime/backends.py +458 -0
  45. synapse/runtime/context_compact.py +249 -0
  46. synapse/runtime/execute_capture.py +48 -0
  47. synapse/runtime/fs_permissions.py +79 -0
  48. synapse/runtime/harness.py +57 -0
  49. synapse/runtime/hitl.py +197 -0
  50. synapse/runtime/interaction_ledger.py +82 -0
  51. synapse/runtime/middleware.py +802 -0
  52. synapse/runtime/model_request_compression_middleware.py +745 -0
  53. synapse/runtime/pathing.py +146 -0
  54. synapse/runtime/safety.py +184 -0
  55. synapse/runtime/steer.py +240 -0
  56. synapse/runtime/subagents.py +207 -0
  57. synapse/runtime/tool_ignore.py +221 -0
  58. synapse/runtime/tool_output_eval.py +118 -0
  59. synapse/runtime/tool_output_middleware.py +585 -0
  60. synapse/runtime/tool_output_usage_middleware.py +60 -0
  61. synapse/sessions/__init__.py +31 -0
  62. synapse/sessions/cancel_repair.py +208 -0
  63. synapse/sessions/session_recap.py +174 -0
  64. synapse/sessions/store.py +695 -0
  65. synapse/sessions/transcript.py +754 -0
  66. synapse/settings/__init__.py +5 -0
  67. synapse/settings/config_paths.py +184 -0
  68. synapse/settings/schema.py +464 -0
  69. synapse/tool_output/__init__.py +59 -0
  70. synapse/tool_output/detection.py +170 -0
  71. synapse/tool_output/metrics.py +32 -0
  72. synapse/tool_output/models.py +173 -0
  73. synapse/tool_output/pipeline.py +330 -0
  74. synapse/tool_output/repository.py +721 -0
  75. synapse/tool_output/transformers.py +648 -0
  76. synapse/tools/__init__.py +5 -0
  77. synapse/tools/session_tools.py +204 -0
  78. synapse/ui/__init__.py +10 -0
  79. synapse/ui/bottombar/__init__.py +73 -0
  80. synapse/ui/bottombar/components/__init__.py +143 -0
  81. synapse/ui/bottombar/components/key_hints.py +30 -0
  82. synapse/ui/bottombar/components/mcp.py +64 -0
  83. synapse/ui/bottombar/components/mode.py +24 -0
  84. synapse/ui/bottombar/components/model.py +28 -0
  85. synapse/ui/bottombar/components/thread.py +29 -0
  86. synapse/ui/bottombar/context.py +36 -0
  87. synapse/ui/bottombar/core.py +74 -0
  88. synapse/ui/dialogs/__init__.py +25 -0
  89. synapse/ui/dialogs/base.py +362 -0
  90. synapse/ui/dialogs/codex_session_list.py +84 -0
  91. synapse/ui/dialogs/compression_diagnostics.py +210 -0
  92. synapse/ui/dialogs/git_explore.py +702 -0
  93. synapse/ui/dialogs/mcp_panel.py +407 -0
  94. synapse/ui/dialogs/model_picker.py +128 -0
  95. synapse/ui/dialogs/safety_panel.py +63 -0
  96. synapse/ui/dialogs/session_list.py +98 -0
  97. synapse/ui/dialogs/theme_designer.py +863 -0
  98. synapse/ui/dialogs/theme_picker.py +113 -0
  99. synapse/ui/git_explore/__init__.py +31 -0
  100. synapse/ui/git_explore/engine.py +82 -0
  101. synapse/ui/git_explore/provider.py +242 -0
  102. synapse/ui/git_explore/unified.py +85 -0
  103. synapse/ui/rendering.py +350 -0
  104. synapse/ui/sink.py +70 -0
  105. synapse/ui/steer_widget.py +367 -0
  106. synapse/ui/stream.py +1207 -0
  107. synapse/ui/stream_events.py +421 -0
  108. synapse/ui/stream_runtime.py +252 -0
  109. synapse/ui/theme.py +1154 -0
  110. synapse/ui/timeline.py +621 -0
  111. synapse/ui/topbar/__init__.py +97 -0
  112. synapse/ui/topbar/components/__init__.py +150 -0
  113. synapse/ui/topbar/components/branch.py +41 -0
  114. synapse/ui/topbar/components/title.py +24 -0
  115. synapse/ui/topbar/components/tool_output.py +24 -0
  116. synapse/ui/topbar/components/usage.py +24 -0
  117. synapse/ui/topbar/components/workspace.py +32 -0
  118. synapse/ui/topbar/context.py +32 -0
  119. synapse/ui/topbar/core.py +979 -0
  120. synapse/ui/topbar/git_changes_popover.py +178 -0
  121. synapse/ui/topbar/git_chrome.py +475 -0
  122. synapse/ui/topbar/tool_output_popover.py +84 -0
  123. synapse/ui/topbar/widget.py +474 -0
  124. synapse/ui/tui.py +5717 -0
  125. synapse/ui/turn_rail.py +71 -0
  126. synapse/ui/user_turn.py +83 -0
  127. synapse/ui/welcome.py +261 -0
  128. synapse_cli_agent-0.1.13.dist-info/METADATA +412 -0
  129. synapse_cli_agent-0.1.13.dist-info/RECORD +131 -0
  130. synapse_cli_agent-0.1.13.dist-info/WHEEL +4 -0
  131. synapse_cli_agent-0.1.13.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,695 @@
1
+ """Session metadata store (product shell around LangGraph checkpointer).
2
+
3
+ LangGraph/deepagents persist conversation state by thread_id.
4
+ This module stores human-facing metadata: title, timestamps, model binding, tags.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import sqlite3
11
+ from collections.abc import Iterable
12
+ from dataclasses import dataclass
13
+ from datetime import UTC, datetime
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+
18
+ def _utcnow() -> str:
19
+ return datetime.now(UTC).replace(microsecond=0).isoformat()
20
+
21
+
22
+ @dataclass
23
+ class ModelBinding:
24
+ """Model + thinking preference bound to a session (or last-used prefs)."""
25
+
26
+ active_model: str | None = None # profile alias, e.g. "deep"
27
+ model: str | None = None # concrete id, e.g. "openai:deepseek-v4-pro"
28
+ thinking: str | None = None # off|minimal|low|medium|high|max|on
29
+
30
+ def has_data(self) -> bool:
31
+ return bool(self.active_model or self.model or self.thinking)
32
+
33
+ def display(self) -> str:
34
+ mid = (self.active_model or self.model or "-").strip() or "-"
35
+ if self.thinking:
36
+ return f"{mid} · {self.thinking}"
37
+ return mid
38
+
39
+ def to_dict(self) -> dict[str, Any]:
40
+ return {
41
+ "active_model": self.active_model,
42
+ "model": self.model,
43
+ "thinking": self.thinking,
44
+ }
45
+
46
+ @classmethod
47
+ def from_dict(cls, data: dict[str, Any] | None) -> ModelBinding:
48
+ if not data:
49
+ return cls()
50
+ return cls(
51
+ active_model=_opt_str(data.get("active_model")),
52
+ model=_opt_str(data.get("model")),
53
+ thinking=_opt_str(data.get("thinking")),
54
+ )
55
+
56
+
57
+ def _opt_str(value: Any) -> str | None:
58
+ if value is None:
59
+ return None
60
+ text = str(value).strip()
61
+ return text or None
62
+
63
+
64
+ def is_default_session_title(title: str | None, thread_id: str) -> bool:
65
+ """True when title is still a placeholder (not first-user-message bound)."""
66
+ text = (title or "").strip()
67
+ if not text:
68
+ return True
69
+ if text == thread_id:
70
+ return True
71
+ if text.startswith("session "):
72
+ return True
73
+ if text.casefold() in {"session", "new session", "untitled"}:
74
+ return True
75
+ return False
76
+
77
+
78
+ def title_from_user_message(text: str | None, *, max_len: int = 80) -> str | None:
79
+ """Normalize first user message into a session title."""
80
+ if not text:
81
+ return None
82
+ one = " ".join(str(text).strip().split())
83
+ if not one:
84
+ return None
85
+ return one[:max_len]
86
+
87
+
88
+ @dataclass
89
+ class SessionInfo:
90
+ thread_id: str
91
+ title: str
92
+ model: str | None
93
+ created_at: str
94
+ updated_at: str
95
+ tags: list[str]
96
+ summary: str | None = None
97
+ active_model: str | None = None
98
+ thinking: str | None = None
99
+
100
+ def binding(self) -> ModelBinding:
101
+ return ModelBinding(
102
+ active_model=self.active_model,
103
+ model=self.model,
104
+ thinking=self.thinking,
105
+ )
106
+
107
+ def to_dict(self) -> dict[str, Any]:
108
+ return {
109
+ "thread_id": self.thread_id,
110
+ "title": self.title,
111
+ "model": self.model,
112
+ "active_model": self.active_model,
113
+ "thinking": self.thinking,
114
+ "created_at": self.created_at,
115
+ "updated_at": self.updated_at,
116
+ "tags": self.tags,
117
+ "summary": self.summary,
118
+ }
119
+
120
+
121
+ class SessionStore:
122
+ """SQLite-backed session metadata."""
123
+
124
+ def __init__(self, path: Path | str) -> None:
125
+ self.path = Path(path).expanduser()
126
+ self.path.parent.mkdir(parents=True, exist_ok=True)
127
+ self._conn = sqlite3.connect(str(self.path), check_same_thread=False)
128
+ self._conn.row_factory = sqlite3.Row
129
+ self._init_db()
130
+
131
+ def _init_db(self) -> None:
132
+ self._conn.execute(
133
+ """
134
+ CREATE TABLE IF NOT EXISTS sessions (
135
+ thread_id TEXT PRIMARY KEY,
136
+ title TEXT NOT NULL,
137
+ model TEXT,
138
+ created_at TEXT NOT NULL,
139
+ updated_at TEXT NOT NULL,
140
+ tags_json TEXT NOT NULL DEFAULT '[]',
141
+ summary TEXT
142
+ )
143
+ """
144
+ )
145
+ self._conn.execute(
146
+ """
147
+ CREATE TABLE IF NOT EXISTS prefs (
148
+ key TEXT PRIMARY KEY,
149
+ value TEXT NOT NULL
150
+ )
151
+ """
152
+ )
153
+ self._ensure_column("active_model", "TEXT")
154
+ self._ensure_column("thinking", "TEXT")
155
+ self._conn.commit()
156
+
157
+ def _ensure_column(self, name: str, decl: str) -> None:
158
+ cols = {
159
+ str(row[1])
160
+ for row in self._conn.execute("PRAGMA table_info(sessions)").fetchall()
161
+ }
162
+ if name not in cols:
163
+ self._conn.execute(f"ALTER TABLE sessions ADD COLUMN {name} {decl}")
164
+
165
+ def close(self) -> None:
166
+ self._conn.close()
167
+
168
+ def ensure(
169
+ self,
170
+ thread_id: str,
171
+ *,
172
+ title: str | None = None,
173
+ model: str | None = None,
174
+ active_model: str | None = None,
175
+ thinking: str | None = None,
176
+ ) -> SessionInfo:
177
+ existing = self.get(thread_id)
178
+ if existing is not None:
179
+ return existing
180
+ now = _utcnow()
181
+ created_title = title_from_user_message(title) or (
182
+ title.strip()[:120] if title else f"session {thread_id}"
183
+ )
184
+ info = SessionInfo(
185
+ thread_id=thread_id,
186
+ title=created_title[:120],
187
+ model=model,
188
+ created_at=now,
189
+ updated_at=now,
190
+ tags=[],
191
+ summary=None,
192
+ active_model=active_model,
193
+ thinking=thinking,
194
+ )
195
+ self._conn.execute(
196
+ """
197
+ INSERT INTO sessions(
198
+ thread_id, title, model, created_at, updated_at,
199
+ tags_json, summary, active_model, thinking
200
+ )
201
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
202
+ """,
203
+ (
204
+ info.thread_id,
205
+ info.title,
206
+ info.model,
207
+ info.created_at,
208
+ info.updated_at,
209
+ "[]",
210
+ None,
211
+ info.active_model,
212
+ info.thinking,
213
+ ),
214
+ )
215
+ self._conn.commit()
216
+ return info
217
+
218
+ def touch(
219
+ self,
220
+ thread_id: str,
221
+ *,
222
+ title_hint: str | None = None,
223
+ model: str | None = None,
224
+ active_model: str | None = None,
225
+ thinking: str | None = None,
226
+ ) -> SessionInfo:
227
+ info = self.ensure(
228
+ thread_id,
229
+ title=title_hint,
230
+ model=model,
231
+ active_model=active_model,
232
+ thinking=thinking,
233
+ )
234
+ now = _utcnow()
235
+ title = info.title
236
+ # Bind first user message as title while still placeholder.
237
+ hint = title_from_user_message(title_hint)
238
+ if hint and is_default_session_title(title, thread_id):
239
+ title = hint
240
+ self._conn.execute(
241
+ """
242
+ UPDATE sessions
243
+ SET updated_at = ?,
244
+ title = ?,
245
+ model = COALESCE(?, model),
246
+ active_model = COALESCE(?, active_model),
247
+ thinking = COALESCE(?, thinking)
248
+ WHERE thread_id = ?
249
+ """,
250
+ (now, title, model, active_model, thinking, thread_id),
251
+ )
252
+ self._conn.commit()
253
+ return self.get(thread_id) # type: ignore[return-value]
254
+
255
+ def resolve_session_ref(self, token: str, *, limit: int = 100) -> SessionInfo | None:
256
+ """Resolve thread_id / unique id-prefix / title to a session.
257
+
258
+ Matching order:
259
+ 1. exact thread_id
260
+ 2. unique thread_id prefix
261
+ 3. exact title (case-insensitive)
262
+ 4. unique title prefix
263
+ 5. unique title substring
264
+ """
265
+ query = " ".join((token or "").strip().split())
266
+ if not query:
267
+ return None
268
+ exact = self.get(query)
269
+ if exact is not None:
270
+ return exact
271
+
272
+ items = self.list(limit=max(1, limit))
273
+ q = query.casefold()
274
+
275
+ id_hits = [s for s in items if s.thread_id.casefold().startswith(q)]
276
+ if len(id_hits) == 1:
277
+ return id_hits[0]
278
+ if len(id_hits) > 1:
279
+ # Prefer exact-casefold id if present among prefixes (already handled).
280
+ return None
281
+
282
+ exact_title = [
283
+ s
284
+ for s in items
285
+ if (s.title or "").strip().casefold() == q
286
+ and not is_default_session_title(s.title, s.thread_id)
287
+ ]
288
+ if len(exact_title) == 1:
289
+ return exact_title[0]
290
+
291
+ prefix_title = [
292
+ s
293
+ for s in items
294
+ if (s.title or "").strip().casefold().startswith(q)
295
+ and not is_default_session_title(s.title, s.thread_id)
296
+ ]
297
+ if len(prefix_title) == 1:
298
+ return prefix_title[0]
299
+
300
+ contains_title = [
301
+ s
302
+ for s in items
303
+ if q in (s.title or "").strip().casefold()
304
+ and not is_default_session_title(s.title, s.thread_id)
305
+ ]
306
+ if len(contains_title) == 1:
307
+ return contains_title[0]
308
+ return None
309
+
310
+ def match_sessions(self, partial: str = "", *, limit: int = 50) -> list[SessionInfo]:
311
+ """Filter sessions by thread_id / title prefix or substring."""
312
+ items = self.list(limit=max(limit, 50))
313
+ query = " ".join((partial or "").strip().split())
314
+ if not query:
315
+ return items[:limit]
316
+ q = query.casefold()
317
+ out: list[SessionInfo] = []
318
+ for s in items:
319
+ tid = s.thread_id.casefold()
320
+ title = (s.title or "").strip().casefold()
321
+ if tid.startswith(q) or title.startswith(q) or (q in title):
322
+ out.append(s)
323
+ if len(out) >= limit:
324
+ break
325
+ return out
326
+
327
+ def save_model_binding(
328
+ self,
329
+ thread_id: str | None,
330
+ binding: ModelBinding,
331
+ *,
332
+ also_last: bool = True,
333
+ ) -> None:
334
+ """Persist model/thinking for a session and optionally as last-used prefs."""
335
+ if not binding.has_data():
336
+ return
337
+ if thread_id:
338
+ self.ensure(thread_id, model=binding.model)
339
+ self._conn.execute(
340
+ """
341
+ UPDATE sessions
342
+ SET updated_at = ?,
343
+ model = COALESCE(?, model),
344
+ active_model = COALESCE(?, active_model),
345
+ thinking = COALESCE(?, thinking)
346
+ WHERE thread_id = ?
347
+ """,
348
+ (
349
+ _utcnow(),
350
+ binding.model,
351
+ binding.active_model,
352
+ binding.thinking,
353
+ thread_id,
354
+ ),
355
+ )
356
+ self._conn.commit()
357
+ if also_last:
358
+ self.set_last_model_binding(binding)
359
+
360
+ def get_model_binding(self, thread_id: str) -> ModelBinding:
361
+ info = self.get(thread_id)
362
+ if info is None:
363
+ return ModelBinding()
364
+ return info.binding()
365
+
366
+ def set_last_model_binding(self, binding: ModelBinding) -> None:
367
+ if not binding.has_data():
368
+ return
369
+ self._set_pref("last_model_binding", json.dumps(binding.to_dict(), ensure_ascii=False))
370
+
371
+ def get_last_model_binding(self) -> ModelBinding:
372
+ raw = self._get_pref("last_model_binding")
373
+ if not raw:
374
+ return ModelBinding()
375
+ try:
376
+ data = json.loads(raw)
377
+ except json.JSONDecodeError:
378
+ return ModelBinding()
379
+ if not isinstance(data, dict):
380
+ return ModelBinding()
381
+ return ModelBinding.from_dict(data)
382
+
383
+ def _set_pref(self, key: str, value: str) -> None:
384
+ self._conn.execute(
385
+ """
386
+ INSERT INTO prefs(key, value) VALUES (?, ?)
387
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
388
+ """,
389
+ (key, value),
390
+ )
391
+ self._conn.commit()
392
+
393
+ def _get_pref(self, key: str) -> str | None:
394
+ row = self._conn.execute(
395
+ "SELECT value FROM prefs WHERE key = ?",
396
+ (key,),
397
+ ).fetchone()
398
+ if row is None:
399
+ return None
400
+ return str(row["value"])
401
+
402
+ def get(self, thread_id: str) -> SessionInfo | None:
403
+ row = self._conn.execute(
404
+ "SELECT * FROM sessions WHERE thread_id = ?",
405
+ (thread_id,),
406
+ ).fetchone()
407
+ return self._row_to_info(row) if row else None
408
+
409
+ def list(self, *, limit: int = 50) -> list[SessionInfo]:
410
+ rows = self._conn.execute(
411
+ """
412
+ SELECT * FROM sessions
413
+ ORDER BY updated_at DESC
414
+ LIMIT ?
415
+ """,
416
+ (max(1, limit),),
417
+ ).fetchall()
418
+ return [self._row_to_info(r) for r in rows]
419
+
420
+ def rename(self, thread_id: str, title: str) -> SessionInfo | None:
421
+ if self.get(thread_id) is None:
422
+ return None
423
+ self._conn.execute(
424
+ "UPDATE sessions SET title = ?, updated_at = ? WHERE thread_id = ?",
425
+ (title.strip()[:120] or thread_id, _utcnow(), thread_id),
426
+ )
427
+ self._conn.commit()
428
+ return self.get(thread_id)
429
+
430
+ def delete(self, thread_id: str) -> bool:
431
+ cur = self._conn.execute(
432
+ "DELETE FROM sessions WHERE thread_id = ?",
433
+ (thread_id,),
434
+ )
435
+ self._conn.commit()
436
+ return cur.rowcount > 0
437
+
438
+ def is_placeholder(self, info: SessionInfo) -> bool:
439
+ """True when the session never got a real first-message title."""
440
+ return is_default_session_title(info.title, info.thread_id)
441
+
442
+ def list_nonempty(self, *, limit: int = 50) -> list[SessionInfo]:
443
+ """Sessions that look used (title bound from a real user message)."""
444
+ out: list[SessionInfo] = []
445
+ for item in self.list(limit=max(limit * 3, 50)):
446
+ if self.is_placeholder(item):
447
+ continue
448
+ out.append(item)
449
+ if len(out) >= limit:
450
+ break
451
+ return out
452
+
453
+ def latest_nonempty(self) -> SessionInfo | None:
454
+ items = self.list_nonempty(limit=1)
455
+ return items[0] if items else None
456
+
457
+ def prune_empty(
458
+ self,
459
+ *,
460
+ except_ids: Iterable[str] | None = None,
461
+ limit: int = 500,
462
+ ) -> list[str]:
463
+ """Delete placeholder sessions that never received a real title.
464
+
465
+ Returns deleted thread_ids. Does not touch LangGraph checkpoint files.
466
+ """
467
+ keep = {str(x) for x in (except_ids or []) if x}
468
+ deleted: list[str] = []
469
+ for item in self.list(limit=max(1, limit)):
470
+ if item.thread_id in keep:
471
+ continue
472
+ if not self.is_placeholder(item):
473
+ continue
474
+ if self.delete(item.thread_id):
475
+ deleted.append(item.thread_id)
476
+ return deleted
477
+
478
+ def search(self, query: str, *, limit: int = 50) -> list[SessionInfo]:
479
+ q = f"%{query.strip()}%"
480
+ rows = self._conn.execute(
481
+ """
482
+ SELECT * FROM sessions
483
+ WHERE title LIKE ? OR IFNULL(summary, '') LIKE ? OR thread_id LIKE ?
484
+ OR IFNULL(model, '') LIKE ? OR IFNULL(active_model, '') LIKE ?
485
+ ORDER BY updated_at DESC
486
+ LIMIT ?
487
+ """,
488
+ (q, q, q, q, q, max(1, limit)),
489
+ ).fetchall()
490
+ return [self._row_to_info(r) for r in rows]
491
+
492
+ def export_json(self, thread_id: str) -> dict[str, Any] | None:
493
+ info = self.get(thread_id)
494
+ return info.to_dict() if info else None
495
+
496
+ def export_markdown(self, thread_id: str) -> str | None:
497
+ info = self.get(thread_id)
498
+ if info is None:
499
+ return None
500
+ tags = ", ".join(info.tags) if info.tags else "-"
501
+ lines = [
502
+ f"# {info.title}",
503
+ "",
504
+ f"- thread_id: `{info.thread_id}`",
505
+ f"- model: `{info.model or '-'}`",
506
+ f"- active_model: `{info.active_model or '-'}`",
507
+ f"- thinking: `{info.thinking or '-'}`",
508
+ f"- created_at: {info.created_at}",
509
+ f"- updated_at: {info.updated_at}",
510
+ f"- tags: {tags}",
511
+ "",
512
+ ]
513
+ if info.summary:
514
+ lines.extend(["## Summary", "", info.summary, ""])
515
+ lines.extend(
516
+ [
517
+ "## Notes",
518
+ "",
519
+ "Conversation messages live in the LangGraph checkpointer "
520
+ f"(thread_id=`{info.thread_id}`).",
521
+ "",
522
+ ]
523
+ )
524
+ return "\n".join(lines)
525
+
526
+ def _row_to_info(self, row: sqlite3.Row) -> SessionInfo:
527
+ try:
528
+ tags = json.loads(row["tags_json"] or "[]")
529
+ except json.JSONDecodeError:
530
+ tags = []
531
+ if not isinstance(tags, list):
532
+ tags = []
533
+ keys = set(row.keys())
534
+ return SessionInfo(
535
+ thread_id=row["thread_id"],
536
+ title=row["title"],
537
+ model=row["model"],
538
+ created_at=row["created_at"],
539
+ updated_at=row["updated_at"],
540
+ tags=[str(t) for t in tags],
541
+ summary=row["summary"],
542
+ active_model=row["active_model"] if "active_model" in keys else None,
543
+ thinking=row["thinking"] if "thinking" in keys else None,
544
+ )
545
+
546
+
547
+ def default_sessions_path(checkpoint_path: Path | str | None = None) -> Path:
548
+ if checkpoint_path is not None:
549
+ return Path(checkpoint_path).expanduser().resolve().parent / "sessions.sqlite"
550
+ return Path(".coding-agent/sessions.sqlite").resolve()
551
+
552
+
553
+ def format_session_table(items: Iterable[SessionInfo]) -> str:
554
+ rows = list(items)
555
+ if not rows:
556
+ return "(no sessions)"
557
+ lines = ["thread_id updated_at model title"]
558
+ for s in rows:
559
+ model = s.binding().display()[:20]
560
+ lines.append(
561
+ f"{s.thread_id:<12} {s.updated_at:<24} {model:<20} {s.title[:48]}"
562
+ )
563
+ return "\n".join(lines)
564
+
565
+
566
+ def allocate_thread_id() -> str:
567
+ """Short id for a not-yet-persisted chat session."""
568
+ import uuid
569
+
570
+ return uuid.uuid4().hex[:12]
571
+
572
+
573
+ def pick_startup_thread_id(
574
+ store: SessionStore,
575
+ thread_id: str | None = None,
576
+ *,
577
+ resume_last: bool = True,
578
+ ) -> tuple[str, bool]:
579
+ """Choose thread id for app/chat start.
580
+
581
+ - Explicit ``thread_id``: use as-is.
582
+ - Else if ``resume_last``: resume most recently updated non-empty session.
583
+ - Else / nothing to resume: allocate a fresh id (not persisted until first msg).
584
+
585
+ Returns ``(thread_id, resumed_existing)``.
586
+ """
587
+ if thread_id:
588
+ return str(thread_id).strip(), True
589
+ if resume_last:
590
+ latest = store.latest_nonempty()
591
+ if latest is not None:
592
+ return latest.thread_id, True
593
+ return allocate_thread_id(), False
594
+
595
+
596
+ def binding_from_settings(settings: Any) -> ModelBinding:
597
+ """Capture current settings as a session model binding."""
598
+ from synapse.models.registry import settings_thinking_label
599
+
600
+ active = _opt_str(getattr(settings, "active_model", None))
601
+ model = _opt_str(getattr(settings, "model", None))
602
+ thinking = settings_thinking_label(settings)
603
+ return ModelBinding(active_model=active, model=model, thinking=thinking)
604
+
605
+
606
+ def apply_binding_to_settings(settings: Any, binding: ModelBinding) -> bool:
607
+ """Apply a stored binding onto settings. Returns True if settings changed.
608
+
609
+ Preference order:
610
+ 1. profile alias (active_model) when known in registry
611
+ 2. concrete model id
612
+ 3. thinking label always applied when present
613
+ """
614
+ if not binding.has_data():
615
+ return False
616
+
617
+ from synapse.models.registry import (
618
+ apply_profile_to_settings,
619
+ apply_thinking_to_settings,
620
+ registry_from_settings,
621
+ )
622
+
623
+ before = (
624
+ getattr(settings, "active_model", None),
625
+ getattr(settings, "model", None),
626
+ getattr(settings, "enable_thinking", True),
627
+ getattr(settings, "reasoning_effort", None),
628
+ getattr(settings, "openai_api_key", None),
629
+ getattr(settings, "anthropic_api_key", None),
630
+ getattr(settings, "openai_base_url", None),
631
+ )
632
+ reg = registry_from_settings(settings)
633
+
634
+ applied_profile = False
635
+ if binding.active_model:
636
+ try:
637
+ profile = reg.get(binding.active_model)
638
+ except KeyError:
639
+ profile = None
640
+ if profile is not None:
641
+ apply_profile_to_settings(settings, profile, seed_thinking=True)
642
+ applied_profile = True
643
+
644
+ if not applied_profile and binding.model:
645
+ # Legacy / ad-hoc: stored concrete model string.
646
+ try:
647
+ # If it's a known profile name or concrete model id, prefer that path.
648
+ profile = reg.get(binding.model)
649
+ apply_profile_to_settings(settings, profile, seed_thinking=True)
650
+ applied_profile = True
651
+ except KeyError:
652
+ settings.model = binding.model
653
+ # Keep active_model if it already matches, else clear to model string.
654
+ if not getattr(settings, "active_model", None):
655
+ settings.active_model = binding.model
656
+
657
+ if binding.thinking:
658
+ try:
659
+ apply_thinking_to_settings(settings, binding.thinking)
660
+ except ValueError:
661
+ pass
662
+
663
+ after = (
664
+ getattr(settings, "active_model", None),
665
+ getattr(settings, "model", None),
666
+ getattr(settings, "enable_thinking", True),
667
+ getattr(settings, "reasoning_effort", None),
668
+ getattr(settings, "openai_api_key", None),
669
+ getattr(settings, "anthropic_api_key", None),
670
+ getattr(settings, "openai_base_url", None),
671
+ )
672
+ return before != after
673
+
674
+
675
+ def resolve_startup_binding(
676
+ store: SessionStore,
677
+ *,
678
+ thread_id: str | None,
679
+ cli_model: str | None = None,
680
+ ) -> ModelBinding | None:
681
+ """Pick which binding to restore on startup.
682
+
683
+ Priority:
684
+ 1. Explicit CLI --model (caller should skip restore)
685
+ 2. Session binding when resuming thread_id
686
+ 3. Last-used global preference
687
+ """
688
+ if cli_model:
689
+ return None
690
+ if thread_id:
691
+ bound = store.get_model_binding(thread_id)
692
+ if bound.has_data():
693
+ return bound
694
+ last = store.get_last_model_binding()
695
+ return last if last.has_data() else None