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.
- synapse/__init__.py +13 -0
- synapse/__main__.py +6 -0
- synapse/app/__init__.py +1 -0
- synapse/app/agent.py +492 -0
- synapse/app/agent_md.py +107 -0
- synapse/cli.py +750 -0
- synapse/commands/__init__.py +1 -0
- synapse/commands/compression.py +573 -0
- synapse/commands/helpers.py +22 -0
- synapse/commands/mcp.py +406 -0
- synapse/commands/model.py +173 -0
- synapse/commands/result.py +34 -0
- synapse/commands/sessions.py +443 -0
- synapse/commands/slash_cmds.py +521 -0
- synapse/commands/slash_complete.py +816 -0
- synapse/commands/theme.py +99 -0
- synapse/config.py +27 -0
- synapse/content/__init__.py +1 -0
- synapse/content/input_history.py +122 -0
- synapse/content/multimodal.py +733 -0
- synapse/content/prompts.py +249 -0
- synapse/content/skills_catalog.py +128 -0
- synapse/integrations/__init__.py +1 -0
- synapse/integrations/checkpoint_seed.py +281 -0
- synapse/integrations/codex_history.py +375 -0
- synapse/integrations/codex_import.py +393 -0
- synapse/integrations/codex_sessions.py +629 -0
- synapse/integrations/describe_image.py +370 -0
- synapse/integrations/http_clients.py +199 -0
- synapse/integrations/llm_openai_compat.py +90 -0
- synapse/integrations/llm_openai_websocket.py +187 -0
- synapse/integrations/mcp_client.py +646 -0
- synapse/integrations/vision_middleware.py +62 -0
- synapse/models/__init__.py +5 -0
- synapse/models/config.py +240 -0
- synapse/models/helpers.py +206 -0
- synapse/models/profile.py +59 -0
- synapse/models/registry.py +722 -0
- synapse/models_registry.py +7 -0
- synapse/observability/__init__.py +1 -0
- synapse/observability/startup_trace.py +127 -0
- synapse/runtime/__init__.py +1 -0
- synapse/runtime/async_runtime.py +176 -0
- synapse/runtime/backends.py +458 -0
- synapse/runtime/context_compact.py +249 -0
- synapse/runtime/execute_capture.py +48 -0
- synapse/runtime/fs_permissions.py +79 -0
- synapse/runtime/harness.py +57 -0
- synapse/runtime/hitl.py +197 -0
- synapse/runtime/interaction_ledger.py +82 -0
- synapse/runtime/middleware.py +802 -0
- synapse/runtime/model_request_compression_middleware.py +745 -0
- synapse/runtime/pathing.py +146 -0
- synapse/runtime/safety.py +184 -0
- synapse/runtime/steer.py +240 -0
- synapse/runtime/subagents.py +207 -0
- synapse/runtime/tool_ignore.py +221 -0
- synapse/runtime/tool_output_eval.py +118 -0
- synapse/runtime/tool_output_middleware.py +585 -0
- synapse/runtime/tool_output_usage_middleware.py +60 -0
- synapse/sessions/__init__.py +31 -0
- synapse/sessions/cancel_repair.py +208 -0
- synapse/sessions/session_recap.py +174 -0
- synapse/sessions/store.py +695 -0
- synapse/sessions/transcript.py +754 -0
- synapse/settings/__init__.py +5 -0
- synapse/settings/config_paths.py +184 -0
- synapse/settings/schema.py +464 -0
- synapse/tool_output/__init__.py +59 -0
- synapse/tool_output/detection.py +170 -0
- synapse/tool_output/metrics.py +32 -0
- synapse/tool_output/models.py +173 -0
- synapse/tool_output/pipeline.py +330 -0
- synapse/tool_output/repository.py +721 -0
- synapse/tool_output/transformers.py +648 -0
- synapse/tools/__init__.py +5 -0
- synapse/tools/session_tools.py +204 -0
- synapse/ui/__init__.py +10 -0
- synapse/ui/bottombar/__init__.py +73 -0
- synapse/ui/bottombar/components/__init__.py +143 -0
- synapse/ui/bottombar/components/key_hints.py +30 -0
- synapse/ui/bottombar/components/mcp.py +64 -0
- synapse/ui/bottombar/components/mode.py +24 -0
- synapse/ui/bottombar/components/model.py +28 -0
- synapse/ui/bottombar/components/thread.py +29 -0
- synapse/ui/bottombar/context.py +36 -0
- synapse/ui/bottombar/core.py +74 -0
- synapse/ui/dialogs/__init__.py +25 -0
- synapse/ui/dialogs/base.py +362 -0
- synapse/ui/dialogs/codex_session_list.py +84 -0
- synapse/ui/dialogs/compression_diagnostics.py +210 -0
- synapse/ui/dialogs/git_explore.py +702 -0
- synapse/ui/dialogs/mcp_panel.py +407 -0
- synapse/ui/dialogs/model_picker.py +128 -0
- synapse/ui/dialogs/safety_panel.py +63 -0
- synapse/ui/dialogs/session_list.py +98 -0
- synapse/ui/dialogs/theme_designer.py +863 -0
- synapse/ui/dialogs/theme_picker.py +113 -0
- synapse/ui/git_explore/__init__.py +31 -0
- synapse/ui/git_explore/engine.py +82 -0
- synapse/ui/git_explore/provider.py +242 -0
- synapse/ui/git_explore/unified.py +85 -0
- synapse/ui/rendering.py +350 -0
- synapse/ui/sink.py +70 -0
- synapse/ui/steer_widget.py +367 -0
- synapse/ui/stream.py +1207 -0
- synapse/ui/stream_events.py +421 -0
- synapse/ui/stream_runtime.py +252 -0
- synapse/ui/theme.py +1154 -0
- synapse/ui/timeline.py +621 -0
- synapse/ui/topbar/__init__.py +97 -0
- synapse/ui/topbar/components/__init__.py +150 -0
- synapse/ui/topbar/components/branch.py +41 -0
- synapse/ui/topbar/components/title.py +24 -0
- synapse/ui/topbar/components/tool_output.py +24 -0
- synapse/ui/topbar/components/usage.py +24 -0
- synapse/ui/topbar/components/workspace.py +32 -0
- synapse/ui/topbar/context.py +32 -0
- synapse/ui/topbar/core.py +979 -0
- synapse/ui/topbar/git_changes_popover.py +178 -0
- synapse/ui/topbar/git_chrome.py +475 -0
- synapse/ui/topbar/tool_output_popover.py +84 -0
- synapse/ui/topbar/widget.py +474 -0
- synapse/ui/tui.py +5717 -0
- synapse/ui/turn_rail.py +71 -0
- synapse/ui/user_turn.py +83 -0
- synapse/ui/welcome.py +261 -0
- synapse_cli_agent-0.1.13.dist-info/METADATA +412 -0
- synapse_cli_agent-0.1.13.dist-info/RECORD +131 -0
- synapse_cli_agent-0.1.13.dist-info/WHEEL +4 -0
- 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
|