@researai/deepscientist 1.5.1 → 1.5.3

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 (116) hide show
  1. package/README.md +69 -1
  2. package/bin/ds.js +2239 -153
  3. package/docs/en/00_QUICK_START.md +60 -20
  4. package/docs/en/01_SETTINGS_REFERENCE.md +20 -20
  5. package/docs/en/02_START_RESEARCH_GUIDE.md +11 -11
  6. package/docs/en/03_QQ_CONNECTOR_GUIDE.md +10 -10
  7. package/docs/en/05_TUI_GUIDE.md +1 -1
  8. package/docs/en/09_DOCTOR.md +48 -4
  9. package/docs/en/90_ARCHITECTURE.md +4 -2
  10. package/docs/zh/00_QUICK_START.md +60 -20
  11. package/docs/zh/01_SETTINGS_REFERENCE.md +21 -21
  12. package/docs/zh/02_START_RESEARCH_GUIDE.md +19 -19
  13. package/docs/zh/03_QQ_CONNECTOR_GUIDE.md +10 -10
  14. package/docs/zh/05_TUI_GUIDE.md +1 -1
  15. package/docs/zh/09_DOCTOR.md +46 -4
  16. package/install.sh +125 -8
  17. package/package.json +2 -1
  18. package/pyproject.toml +1 -1
  19. package/src/deepscientist/__init__.py +6 -1
  20. package/src/deepscientist/artifact/service.py +553 -26
  21. package/src/deepscientist/bash_exec/monitor.py +23 -4
  22. package/src/deepscientist/bash_exec/runtime.py +3 -0
  23. package/src/deepscientist/bash_exec/service.py +132 -4
  24. package/src/deepscientist/bridges/base.py +10 -19
  25. package/src/deepscientist/channels/discord_gateway.py +25 -2
  26. package/src/deepscientist/channels/feishu_long_connection.py +41 -3
  27. package/src/deepscientist/channels/qq.py +524 -64
  28. package/src/deepscientist/channels/qq_gateway.py +22 -3
  29. package/src/deepscientist/channels/relay.py +429 -90
  30. package/src/deepscientist/channels/slack_socket.py +29 -5
  31. package/src/deepscientist/channels/telegram_polling.py +25 -2
  32. package/src/deepscientist/channels/whatsapp_local_session.py +32 -4
  33. package/src/deepscientist/cli.py +27 -0
  34. package/src/deepscientist/config/models.py +6 -40
  35. package/src/deepscientist/config/service.py +165 -156
  36. package/src/deepscientist/connector_profiles.py +346 -0
  37. package/src/deepscientist/connector_runtime.py +88 -43
  38. package/src/deepscientist/daemon/api/handlers.py +65 -11
  39. package/src/deepscientist/daemon/api/router.py +4 -2
  40. package/src/deepscientist/daemon/app.py +772 -219
  41. package/src/deepscientist/doctor.py +69 -2
  42. package/src/deepscientist/gitops/diff.py +3 -0
  43. package/src/deepscientist/home.py +25 -2
  44. package/src/deepscientist/mcp/context.py +3 -1
  45. package/src/deepscientist/mcp/server.py +66 -7
  46. package/src/deepscientist/migration.py +114 -0
  47. package/src/deepscientist/prompts/builder.py +71 -3
  48. package/src/deepscientist/qq_profiles.py +186 -0
  49. package/src/deepscientist/quest/layout.py +1 -0
  50. package/src/deepscientist/quest/service.py +70 -12
  51. package/src/deepscientist/quest/stage_views.py +46 -0
  52. package/src/deepscientist/runners/codex.py +2 -0
  53. package/src/deepscientist/shared.py +44 -17
  54. package/src/prompts/connectors/lingzhu.md +3 -0
  55. package/src/prompts/connectors/qq.md +42 -2
  56. package/src/prompts/system.md +123 -10
  57. package/src/skills/analysis-campaign/SKILL.md +35 -6
  58. package/src/skills/baseline/SKILL.md +73 -32
  59. package/src/skills/decision/SKILL.md +4 -3
  60. package/src/skills/experiment/SKILL.md +28 -6
  61. package/src/skills/finalize/SKILL.md +5 -2
  62. package/src/skills/idea/SKILL.md +2 -2
  63. package/src/skills/intake-audit/SKILL.md +2 -2
  64. package/src/skills/rebuttal/SKILL.md +4 -2
  65. package/src/skills/review/SKILL.md +4 -2
  66. package/src/skills/scout/SKILL.md +2 -2
  67. package/src/skills/write/SKILL.md +2 -2
  68. package/src/tui/package.json +1 -1
  69. package/src/ui/dist/assets/{AiManusChatView-w5lF2Ttt.js → AiManusChatView-qzChi9uh.js} +67 -94
  70. package/src/ui/dist/assets/{AnalysisPlugin-DJOED79I.js → AnalysisPlugin-CcC_-UqN.js} +1 -1
  71. package/src/ui/dist/assets/{AutoFigurePlugin-DaG61Y0M.js → AutoFigurePlugin-DD8LkJLe.js} +5 -5
  72. package/src/ui/dist/assets/{CliPlugin-CV4LqUB_.js → CliPlugin-DJJFfVmW.js} +17 -110
  73. package/src/ui/dist/assets/{CodeEditorPlugin-DylfAea4.js → CodeEditorPlugin-CrjkHNLh.js} +8 -8
  74. package/src/ui/dist/assets/{CodeViewerPlugin-F7saY0LM.js → CodeViewerPlugin-obnD6G5R.js} +5 -5
  75. package/src/ui/dist/assets/{DocViewerPlugin-COP0c7jf.js → DocViewerPlugin-DB9SUQVd.js} +3 -3
  76. package/src/ui/dist/assets/{GitDiffViewerPlugin-CAS05pT9.js → GitDiffViewerPlugin-DZLlNlD2.js} +1 -1
  77. package/src/ui/dist/assets/{ImageViewerPlugin-Bco1CN_w.js → ImageViewerPlugin-BGwfDZ0Y.js} +5 -5
  78. package/src/ui/dist/assets/{LabCopilotPanel-CvMlCD99.js → LabCopilotPanel-dfLptQcR.js} +10 -10
  79. package/src/ui/dist/assets/{LabPlugin-BYankkE4.js → LabPlugin-CeGjAl3A.js} +1 -1
  80. package/src/ui/dist/assets/{LatexPlugin-LDSMR-t-.js → LatexPlugin-BBJ7kd1V.js} +7 -7
  81. package/src/ui/dist/assets/{MarkdownViewerPlugin-B7o80jgm.js → MarkdownViewerPlugin-DKZi7BcB.js} +4 -4
  82. package/src/ui/dist/assets/{MarketplacePlugin-CM6ZOcpC.js → MarketplacePlugin-C_k-9jD0.js} +3 -3
  83. package/src/ui/dist/assets/{NotebookEditor-Dc61cXmK.js → NotebookEditor-4R88_BMO.js} +1 -1
  84. package/src/ui/dist/assets/{PdfLoader-DWowuQwx.js → PdfLoader-DwEFQLrw.js} +1 -1
  85. package/src/ui/dist/assets/{PdfMarkdownPlugin-BsJM1q_a.js → PdfMarkdownPlugin-D-jdsqF8.js} +3 -3
  86. package/src/ui/dist/assets/{PdfViewerPlugin-DB2eEEFQ.js → PdfViewerPlugin-CmeBGDY0.js} +10 -10
  87. package/src/ui/dist/assets/{SearchPlugin-CraThSvt.js → SearchPlugin-Dlz2WKJ4.js} +1 -1
  88. package/src/ui/dist/assets/{Stepper-CgocRTPq.js → Stepper-ClOgzWM3.js} +1 -1
  89. package/src/ui/dist/assets/{TextViewerPlugin-B1JGhKtd.js → TextViewerPlugin-DDQWxibk.js} +4 -4
  90. package/src/ui/dist/assets/{VNCViewer-CclFC7FM.js → VNCViewer-CJXT0Nm8.js} +9 -9
  91. package/src/ui/dist/assets/{bibtex-D3IKsMl7.js → bibtex-DLr4Rtk4.js} +1 -1
  92. package/src/ui/dist/assets/{code-BP37Xx0p.js → code-DgKK408Y.js} +1 -1
  93. package/src/ui/dist/assets/{file-content-BAJSu-9r.js → file-content-6HBqQnvQ.js} +1 -1
  94. package/src/ui/dist/assets/{file-diff-panel-DUGeCTuy.js → file-diff-panel-Dhu0TbBM.js} +1 -1
  95. package/src/ui/dist/assets/{file-socket-CXc1Ojf7.js → file-socket-CP3iwVZG.js} +1 -1
  96. package/src/ui/dist/assets/{file-utils-2J21jt7M.js → file-utils-BsS-Aw68.js} +1 -1
  97. package/src/ui/dist/assets/{image-CMMmgvcn.js → image-ByeK-Zcv.js} +1 -1
  98. package/src/ui/dist/assets/{index-DmwmJmbW.js → index-BLjo5--a.js} +33610 -31016
  99. package/src/ui/dist/assets/{index-CWgMgpow.js → index-BdsE0uRz.js} +11 -11
  100. package/src/ui/dist/assets/{index-s7aHnNQ4.js → index-C-eX-N6A.js} +1 -1
  101. package/src/ui/dist/assets/{index-KGt-z-dD.css → index-CuQhlrR-.css} +2747 -2
  102. package/src/ui/dist/assets/{index-BaVumsQT.js → index-DyremSIv.js} +2 -2
  103. package/src/ui/dist/assets/{message-square-CQRfX0Am.js → message-square-DnagiLnc.js} +1 -1
  104. package/src/ui/dist/assets/{monaco-B4TbdsrF.js → monaco-4kBFeprs.js} +1 -1
  105. package/src/ui/dist/assets/{popover-B8Rokodk.js → popover-hRCXZzs2.js} +1 -1
  106. package/src/ui/dist/assets/{project-sync-D_i96KH4.js → project-sync-O_85YuP6.js} +1 -1
  107. package/src/ui/dist/assets/{sigma-D12PnzCN.js → sigma-DvKopSnL.js} +1 -1
  108. package/src/ui/dist/assets/{tooltip-B6YrI4aJ.js → tooltip-BmlPc6kc.js} +1 -1
  109. package/src/ui/dist/assets/{trash-Bc8jGp0V.js → trash-n-UvdZFR.js} +1 -1
  110. package/src/ui/dist/assets/{useCliAccess-mXVCYSZ-.js → useCliAccess-WDd3_wIh.js} +1 -1
  111. package/src/ui/dist/assets/{useFileDiffOverlay-Bg6b9H9K.js → useFileDiffOverlay-rXLIL2NF.js} +1 -1
  112. package/src/ui/dist/assets/{wrap-text-Drh5GEnL.js → wrap-text-qIYQ4a_W.js} +1 -1
  113. package/src/ui/dist/assets/{zoom-out-CJj9DZLn.js → zoom-out-fZXCEFsy.js} +1 -1
  114. package/src/ui/dist/index.html +2 -2
  115. package/uv.lock +1155 -0
  116. package/src/ui/dist/assets/LabPlugin-D9jVIo0A.css +0 -2698
@@ -5,6 +5,7 @@ import json
5
5
  import mimetypes
6
6
  import os
7
7
  import shutil
8
+ import subprocess
8
9
  import threading
9
10
  import time
10
11
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -16,7 +17,7 @@ from urllib.request import Request, urlopen
16
17
  from ..artifact import ArtifactService
17
18
  from ..bash_exec import BashExecService
18
19
  from ..bash_exec.runtime import TerminalClient
19
- from ..bridges import get_connector_bridge, register_builtin_connector_bridges
20
+ from ..bridges import register_builtin_connector_bridges
20
21
  from ..bridges.connectors import QQConnectorBridge
21
22
  from ..channels import QQRelayChannel, get_channel_factory, list_channel_names, register_builtin_channels
22
23
  from ..channels.discord_gateway import DiscordGatewayService
@@ -26,13 +27,15 @@ from ..channels.slack_socket import SlackSocketModeService
26
27
  from ..channels.telegram_polling import TelegramPollingService
27
28
  from ..channels.whatsapp_local_session import WhatsAppLocalSessionService
28
29
  from ..cloud import CloudLinkService
29
- from ..connector_runtime import conversation_identity_key, normalize_conversation_id, parse_conversation_id
30
+ from ..connector_profiles import PROFILEABLE_CONNECTOR_NAMES, connector_profile_label, list_connector_profiles, merge_connector_profile_config
31
+ from ..connector_runtime import conversation_identity_key, format_conversation_id, normalize_conversation_id, parse_conversation_id
30
32
  from ..config import ConfigManager
31
33
  from ..home import repo_root
32
34
  from ..memory import MemoryService
33
35
  from ..latex_runtime import QuestLatexService
34
36
  from ..prompts import PromptBuilder
35
37
  from ..prompts.builder import STANDARD_SKILLS
38
+ from ..qq_profiles import list_qq_profiles, merge_qq_profile_config
36
39
  from ..quest import QuestService
37
40
  from ..runners import CodexRunner, RunRequest, get_runner_factory, register_builtin_runners
38
41
  from ..runtime_logs import JsonlLogger
@@ -61,7 +64,7 @@ class DaemonApp:
61
64
  self.repo_root = repo_root()
62
65
  self.config_manager = ConfigManager(home)
63
66
  self.runners_config = self.config_manager.load_named("runners")
64
- self.connectors_config = self.config_manager.load_named("connectors")
67
+ self.connectors_config = self.config_manager.load_named_normalized("connectors")
65
68
  self.skill_installer = SkillInstaller(self.repo_root, home)
66
69
  self.quest_service = QuestService(home, skill_installer=self.skill_installer)
67
70
  self.latex_service = QuestLatexService(self.quest_service)
@@ -106,22 +109,310 @@ class DaemonApp:
106
109
  self._terminal_attach_thread: threading.Thread | None = None
107
110
  self._terminal_attach_host: str | None = None
108
111
  self._terminal_attach_port: int | None = None
112
+ self._serve_host: str | None = None
113
+ self._serve_port: int | None = None
109
114
  self._shutdown_requested = threading.Event()
110
- self._qq_gateway: QQGatewayService | None = None
111
- self._telegram_polling: TelegramPollingService | None = None
112
- self._slack_socket: SlackSocketModeService | None = None
113
- self._discord_gateway: DiscordGatewayService | None = None
114
- self._feishu_long_connection: FeishuLongConnectionService | None = None
115
- self._whatsapp_local_session: WhatsAppLocalSessionService | None = None
115
+ self._qq_gateways: dict[str, QQGatewayService] = {}
116
+ self._telegram_polling: dict[str, TelegramPollingService] = {}
117
+ self._slack_socket: dict[str, SlackSocketModeService] = {}
118
+ self._discord_gateway: dict[str, DiscordGatewayService] = {}
119
+ self._feishu_long_connection: dict[str, FeishuLongConnectionService] = {}
120
+ self._whatsapp_local_session: dict[str, WhatsAppLocalSessionService] = {}
116
121
  self.handlers = ApiHandlers(self)
117
122
 
118
123
  def list_connector_statuses(self) -> list[dict[str, object]]:
119
- items = [channel.status() for channel in self.channels.values()]
124
+ title_by_quest = self._quest_titles_by_id()
125
+ items = [self._augment_connector_status(channel.status(), title_by_quest=title_by_quest) for channel in self.channels.values()]
120
126
  lingzhu_config = self.connectors_config.get("lingzhu")
121
127
  if isinstance(lingzhu_config, dict):
122
- items.append(self.config_manager.lingzhu_snapshot(lingzhu_config))
128
+ items.append(self._augment_connector_status(self.config_manager.lingzhu_snapshot(lingzhu_config), title_by_quest=title_by_quest))
123
129
  return items
124
130
 
131
+ def _quest_titles_by_id(self) -> dict[str, str | None]:
132
+ return {
133
+ str(item.get("quest_id") or "").strip(): str(item.get("title") or "").strip() or None
134
+ for item in self.quest_service.list_quests()
135
+ }
136
+
137
+ def _augment_connector_status(
138
+ self,
139
+ snapshot: dict[str, object],
140
+ *,
141
+ title_by_quest: dict[str, str | None] | None = None,
142
+ ) -> dict[str, object]:
143
+ if not isinstance(snapshot, dict):
144
+ return snapshot
145
+ connector_name = str(snapshot.get("name") or "").strip().lower()
146
+ titles = title_by_quest or self._quest_titles_by_id()
147
+ binding_map: dict[str, dict[str, str | None]] = {}
148
+ for raw in snapshot.get("bindings") or []:
149
+ if not isinstance(raw, dict):
150
+ continue
151
+ conversation_id = str(raw.get("conversation_id") or "").strip()
152
+ if not conversation_id:
153
+ continue
154
+ quest_id = str(raw.get("quest_id") or "").strip() or None
155
+ binding_map[conversation_identity_key(conversation_id)] = {
156
+ "quest_id": quest_id,
157
+ "quest_title": titles.get(quest_id or ""),
158
+ }
159
+
160
+ def augment_target(target: object) -> dict[str, object] | None:
161
+ if not isinstance(target, dict):
162
+ return None
163
+ payload = dict(target)
164
+ conversation_id = str(payload.get("conversation_id") or "").strip()
165
+ if not conversation_id:
166
+ return payload
167
+ binding = binding_map.get(conversation_identity_key(conversation_id)) or {}
168
+ bound_quest_id = str(binding.get("quest_id") or "").strip() or None
169
+ bound_quest_title = str(binding.get("quest_title") or "").strip() or None
170
+ if bound_quest_id:
171
+ payload["bound_quest_id"] = bound_quest_id
172
+ payload["bound_quest_title"] = bound_quest_title
173
+ payload["is_bound"] = True
174
+ payload["warning"] = f"Currently bound to {bound_quest_id}"
175
+ else:
176
+ payload["bound_quest_id"] = None
177
+ payload["bound_quest_title"] = None
178
+ payload["is_bound"] = False
179
+ payload["selectable"] = True
180
+ if not payload.get("connector") and connector_name:
181
+ payload["connector"] = connector_name
182
+ return payload
183
+
184
+ known_targets = [item for item in (augment_target(target) for target in snapshot.get("known_targets") or []) if item is not None]
185
+ discovered_targets = [item for item in (augment_target(target) for target in snapshot.get("discovered_targets") or []) if item is not None]
186
+ bindings = []
187
+ for raw in snapshot.get("bindings") or []:
188
+ if not isinstance(raw, dict):
189
+ continue
190
+ payload = dict(raw)
191
+ quest_id = str(payload.get("quest_id") or "").strip() or None
192
+ payload["quest_title"] = titles.get(quest_id or "")
193
+ bindings.append(payload)
194
+ payload = dict(snapshot)
195
+ if known_targets:
196
+ payload["known_targets"] = known_targets
197
+ if discovered_targets:
198
+ payload["discovered_targets"] = discovered_targets
199
+ payload["target_count"] = len(discovered_targets)
200
+ default_target = augment_target(snapshot.get("default_target"))
201
+ if default_target is not None:
202
+ payload["default_target"] = default_target
203
+ if bindings:
204
+ payload["bindings"] = bindings
205
+ profiles_payload = []
206
+ for raw_profile in snapshot.get("profiles") or []:
207
+ if not isinstance(raw_profile, dict):
208
+ continue
209
+ profile_payload = dict(raw_profile)
210
+ profile_payload["discovered_targets"] = [
211
+ item
212
+ for item in (
213
+ augment_target(target)
214
+ for target in raw_profile.get("discovered_targets") or []
215
+ )
216
+ if item is not None
217
+ ]
218
+ profile_payload["recent_conversations"] = [
219
+ dict(item)
220
+ for item in raw_profile.get("recent_conversations") or []
221
+ if isinstance(item, dict)
222
+ ]
223
+ profile_payload["bindings"] = [
224
+ {
225
+ **dict(item),
226
+ "quest_title": titles.get(str(item.get("quest_id") or "").strip() or ""),
227
+ }
228
+ for item in raw_profile.get("bindings") or []
229
+ if isinstance(item, dict)
230
+ ]
231
+ profiles_payload.append(profile_payload)
232
+ if profiles_payload:
233
+ payload["profiles"] = profiles_payload
234
+ return payload
235
+
236
+ @staticmethod
237
+ def _connector_has_delivery_target(snapshot: dict[str, object]) -> bool:
238
+ if str(snapshot.get("main_chat_id") or "").strip():
239
+ return True
240
+ if str(snapshot.get("last_conversation_id") or "").strip():
241
+ return True
242
+ if isinstance(snapshot.get("default_target"), dict) and snapshot.get("default_target"):
243
+ return True
244
+ if isinstance(snapshot.get("bindings"), list) and snapshot.get("bindings"):
245
+ return True
246
+ if isinstance(snapshot.get("recent_conversations"), list) and snapshot.get("recent_conversations"):
247
+ return True
248
+ if isinstance(snapshot.get("discovered_targets"), list) and snapshot.get("discovered_targets"):
249
+ return True
250
+ for key in ("binding_count", "target_count"):
251
+ try:
252
+ if int(snapshot.get(key) or 0) > 0:
253
+ return True
254
+ except (TypeError, ValueError):
255
+ continue
256
+ return False
257
+
258
+ def connector_availability_summary(self) -> dict[str, object]:
259
+ available_connectors: list[dict[str, object]] = []
260
+ preferred_connector_name: str | None = None
261
+ preferred_conversation_id: str | None = None
262
+ has_enabled_external_connector = False
263
+ has_bound_external_connector = False
264
+
265
+ for item in self.list_connector_statuses():
266
+ if not isinstance(item, dict):
267
+ continue
268
+ name = str(item.get("name") or "").strip()
269
+ if not name or name == "local":
270
+ continue
271
+ enabled = bool(item.get("enabled"))
272
+ connection_state = str(item.get("connection_state") or "").strip() or None
273
+ has_target = self._connector_has_delivery_target(item)
274
+ if enabled:
275
+ has_enabled_external_connector = True
276
+ if enabled and has_target:
277
+ has_bound_external_connector = True
278
+ if preferred_connector_name is None:
279
+ preferred_connector_name = name
280
+ preferred_conversation_id = str(
281
+ ((item.get("default_target") or {}) if isinstance(item.get("default_target"), dict) else {}).get(
282
+ "conversation_id"
283
+ )
284
+ or item.get("last_conversation_id")
285
+ or ""
286
+ ).strip() or None
287
+ available_connectors.append(
288
+ {
289
+ "name": name,
290
+ "enabled": enabled,
291
+ "connection_state": connection_state,
292
+ "binding_count": int(item.get("binding_count") or 0),
293
+ "target_count": int(item.get("target_count") or 0),
294
+ "has_delivery_target": has_target,
295
+ }
296
+ )
297
+
298
+ return {
299
+ "has_enabled_external_connector": has_enabled_external_connector,
300
+ "has_bound_external_connector": has_bound_external_connector,
301
+ "should_recommend_binding": not has_bound_external_connector,
302
+ "preferred_connector_name": preferred_connector_name,
303
+ "preferred_conversation_id": preferred_conversation_id,
304
+ "available_connectors": available_connectors,
305
+ }
306
+
307
+ def _normalize_requested_connector_bindings(
308
+ self,
309
+ requested_connector_bindings: list[dict[str, object]] | None,
310
+ ) -> list[dict[str, str | None]]:
311
+ items = requested_connector_bindings if isinstance(requested_connector_bindings, list) else []
312
+ normalized_by_connector: dict[str, dict[str, str | None]] = {}
313
+ for raw in items:
314
+ if not isinstance(raw, dict):
315
+ continue
316
+ raw_connector_name = str(raw.get("connector") or "").strip().lower()
317
+ conversation_id = normalize_conversation_id(raw.get("conversation_id"))
318
+ parsed = parse_conversation_id(conversation_id)
319
+ connector_name = raw_connector_name
320
+ if parsed is not None:
321
+ connector_name = str(parsed.get("connector") or connector_name).strip().lower()
322
+ if not connector_name or connector_name == "local":
323
+ continue
324
+ if connector_name not in self.channels:
325
+ continue
326
+ if parsed is not None and str(parsed.get("connector") or "").strip().lower() != connector_name:
327
+ continue
328
+ normalized_by_connector[connector_name] = {
329
+ "connector": connector_name,
330
+ "conversation_id": conversation_id or None,
331
+ }
332
+ return list(normalized_by_connector.values())
333
+
334
+ def _launcher_update_base_command(self) -> list[str]:
335
+ node_binary = str(os.environ.get("DEEPSCIENTIST_NODE_BINARY") or "").strip() or which("node") or which("nodejs")
336
+ launcher_path = str(os.environ.get("DEEPSCIENTIST_LAUNCHER_PATH") or "").strip()
337
+ if not launcher_path:
338
+ launcher_path = str(self.repo_root / "bin" / "ds.js")
339
+ if not node_binary:
340
+ raise RuntimeError("Node.js is not available on PATH, so DeepScientist cannot check npm updates.")
341
+ if not Path(launcher_path).exists():
342
+ raise RuntimeError(f"DeepScientist launcher path does not exist: {launcher_path}")
343
+ return [node_binary, launcher_path, "update", "--home", str(self.home)]
344
+
345
+ def system_update_status(self) -> dict[str, object]:
346
+ command = [*self._launcher_update_base_command(), "--check", "--json"]
347
+ try:
348
+ result = subprocess.run(
349
+ command,
350
+ cwd=str(self.repo_root),
351
+ capture_output=True,
352
+ text=True,
353
+ timeout=8,
354
+ check=False,
355
+ env=os.environ.copy(),
356
+ )
357
+ except subprocess.TimeoutExpired as exc:
358
+ raise RuntimeError("DeepScientist update check timed out.") from exc
359
+ if result.returncode != 0:
360
+ raise RuntimeError((result.stderr or result.stdout or "Update check failed.").strip())
361
+ try:
362
+ payload = json.loads(result.stdout or "{}")
363
+ except json.JSONDecodeError as exc:
364
+ raise RuntimeError("DeepScientist update check returned invalid JSON.") from exc
365
+ if not isinstance(payload, dict):
366
+ raise RuntimeError("DeepScientist update check returned an invalid payload.")
367
+ return payload
368
+
369
+ def request_system_update(self, *, action: str) -> dict[str, object]:
370
+ normalized = str(action or "").strip().lower()
371
+ if normalized not in {"install_latest", "remind_later", "skip_version"}:
372
+ raise ValueError(f"Unsupported update action `{action}`.")
373
+ command = self._launcher_update_base_command()
374
+ if normalized == "install_latest":
375
+ host = self._serve_host or "0.0.0.0"
376
+ port = self._serve_port or 20999
377
+ command.extend(
378
+ [
379
+ "--yes",
380
+ "--background",
381
+ "--restart-daemon",
382
+ "--host",
383
+ str(host),
384
+ "--port",
385
+ str(port),
386
+ "--json",
387
+ ]
388
+ )
389
+ elif normalized == "remind_later":
390
+ command.extend(["--remind-later", "--json"])
391
+ else:
392
+ command.extend(["--skip-version", "--json"])
393
+
394
+ try:
395
+ result = subprocess.run(
396
+ command,
397
+ cwd=str(self.repo_root),
398
+ capture_output=True,
399
+ text=True,
400
+ timeout=8,
401
+ check=False,
402
+ env=os.environ.copy(),
403
+ )
404
+ except subprocess.TimeoutExpired as exc:
405
+ raise RuntimeError("DeepScientist update request timed out.") from exc
406
+ if result.returncode != 0:
407
+ raise RuntimeError((result.stderr or result.stdout or "Update request failed.").strip())
408
+ try:
409
+ payload = json.loads(result.stdout or "{}")
410
+ except json.JSONDecodeError as exc:
411
+ raise RuntimeError("DeepScientist update request returned invalid JSON.") from exc
412
+ if not isinstance(payload, dict):
413
+ raise RuntimeError("DeepScientist update request returned an invalid payload.")
414
+ return payload
415
+
125
416
  def _process_terminal_attach_request(
126
417
  self,
127
418
  connection: ServerConnection,
@@ -322,7 +613,7 @@ class DaemonApp:
322
613
  return factory(home=self.home, app=self, config=self.connectors_config.get(name, {}))
323
614
 
324
615
  def reload_connectors_config(self, *, restart_background: bool = True) -> dict[str, object]:
325
- self.connectors_config = self.config_manager.load_named("connectors")
616
+ self.connectors_config = self.config_manager.load_named_normalized("connectors")
326
617
  register_builtin_channels(home=self.home, connectors_config=self.connectors_config)
327
618
  for name, channel in self.channels.items():
328
619
  config = self.connectors_config.get(name, {})
@@ -410,9 +701,12 @@ class DaemonApp:
410
701
  announce_connector_binding: bool = True,
411
702
  exclude_conversation_id: str | None = None,
412
703
  preferred_connector_conversation_id: str | None = None,
704
+ requested_connector_bindings: list[dict[str, object]] | None = None,
705
+ force_connector_rebind: bool = False,
413
706
  requested_baseline_ref: dict[str, object] | None = None,
414
707
  startup_contract: dict[str, object] | None = None,
415
708
  ) -> dict:
709
+ normalized_requested_bindings = self._normalize_requested_connector_bindings(requested_connector_bindings)
416
710
  snapshot = self.quest_service.create(
417
711
  goal=goal,
418
712
  title=title,
@@ -460,7 +754,51 @@ class DaemonApp:
460
754
  ) from exc
461
755
  preferred_binding = normalize_conversation_id(preferred_connector_conversation_id)
462
756
  preferred_parsed = parse_conversation_id(preferred_binding)
463
- if (
757
+ if normalized_requested_bindings:
758
+ try:
759
+ binding_result = self.update_quest_bindings(
760
+ snapshot["quest_id"],
761
+ normalized_requested_bindings,
762
+ force=force_connector_rebind,
763
+ )
764
+ if isinstance(binding_result, tuple):
765
+ raise RuntimeError(str(binding_result[1].get("message") or "Unable to bind connector targets."))
766
+ if announce_connector_binding:
767
+ for result in binding_result.get("results") or []:
768
+ if not isinstance(result, dict):
769
+ continue
770
+ conversation_id = str(result.get("conversation_id") or "").strip()
771
+ connector_name = str(result.get("connector") or "").strip().lower()
772
+ if not conversation_id or not connector_name or connector_name == "local":
773
+ continue
774
+ channel = self._channel_with_bindings(connector_name)
775
+ channel.send(
776
+ {
777
+ "conversation_id": conversation_id,
778
+ "quest_id": snapshot["quest_id"],
779
+ "kind": "ack",
780
+ "message": self._quest_created_connector_message(
781
+ connector_name,
782
+ quest_id=snapshot["quest_id"],
783
+ goal=goal,
784
+ previous_quest_id=str(result.get("previous_quest_id") or "").strip() or None,
785
+ ),
786
+ }
787
+ )
788
+ snapshot = self.quest_service.snapshot(snapshot["quest_id"])
789
+ except Exception as exc:
790
+ shutil.rmtree(Path(snapshot["quest_root"]), ignore_errors=True)
791
+ self.sessions.forget(snapshot["quest_id"])
792
+ self.logger.log(
793
+ "warning",
794
+ "quest.connector_binding_failed",
795
+ quest_id=snapshot.get("quest_id"),
796
+ message=str(exc),
797
+ )
798
+ raise RuntimeError(
799
+ f"Quest creation failed because one or more selected connector targets could not be bound: {exc}"
800
+ ) from exc
801
+ elif (
464
802
  preferred_binding
465
803
  and preferred_parsed
466
804
  and str(preferred_parsed.get("connector") or "").strip().lower() in self.channels
@@ -492,7 +830,7 @@ class DaemonApp:
492
830
  ),
493
831
  }
494
832
  )
495
- elif not preferred_binding:
833
+ else:
496
834
  self._auto_bind_connectors_to_latest_quest(
497
835
  snapshot["quest_id"],
498
836
  goal=goal,
@@ -1698,50 +2036,54 @@ class DaemonApp:
1698
2036
  channel = self._channel_with_bindings(connector_name)
1699
2037
  return channel.list_bindings()
1700
2038
 
1701
- def update_quest_binding(
2039
+ def preview_connector_binding_conflicts(
1702
2040
  self,
1703
- quest_id: str,
1704
- conversation_id: str | None,
2041
+ requested_bindings: list[dict[str, object]] | None,
1705
2042
  *,
1706
- force: bool = False,
1707
- ) -> dict | tuple[int, dict]:
1708
- quest_root = self.home / "quests" / quest_id
1709
- if not quest_root.joinpath("quest.yaml").exists():
1710
- return 404, {"ok": False, "message": f"Unknown quest `{quest_id}`."}
2043
+ quest_id: str | None = None,
2044
+ ) -> list[dict[str, object]]:
2045
+ normalized_bindings = self._normalize_requested_connector_bindings(requested_bindings)
2046
+ conflicts: list[dict[str, object]] = []
2047
+ seen: set[tuple[str, str]] = set()
2048
+ for item in normalized_bindings:
2049
+ connector_name = str(item.get("connector") or "").strip().lower()
2050
+ conversation_id = str(item.get("conversation_id") or "").strip()
2051
+ if not connector_name or not conversation_id:
2052
+ continue
2053
+ for conflict in self._inspect_connector_binding_conflicts(quest_id, conversation_id):
2054
+ conflict_quest_id = str(conflict.get("quest_id") or "").strip()
2055
+ identity = (connector_name, conflict_quest_id)
2056
+ if not conflict_quest_id or identity in seen:
2057
+ continue
2058
+ seen.add(identity)
2059
+ conflicts.append(
2060
+ {
2061
+ "connector": connector_name,
2062
+ "conversation_id": conversation_id,
2063
+ **conflict,
2064
+ }
2065
+ )
2066
+ return conflicts
1711
2067
 
2068
+ def _inspect_connector_binding_conflicts(
2069
+ self,
2070
+ quest_id: str | None,
2071
+ conversation_id: str,
2072
+ ) -> list[dict[str, object]]:
1712
2073
  normalized = normalize_conversation_id(conversation_id)
1713
2074
  parsed = parse_conversation_id(normalized)
1714
-
1715
- if parsed is None or parsed.get("connector", "").lower() == "local":
1716
- removed = self._unbind_external_bindings(quest_id)
1717
- self.quest_service.set_binding_sources(quest_id, ["local:default"])
1718
- snapshot = self.quest_service.snapshot(quest_id)
1719
- return {
1720
- "ok": True,
1721
- "quest_id": quest_id,
1722
- "conversation_id": None,
1723
- "snapshot": snapshot,
1724
- "removed_conversations": removed,
1725
- }
1726
-
2075
+ if parsed is None or str(parsed.get("connector") or "").strip().lower() == "local":
2076
+ return []
1727
2077
  connector_name = str(parsed.get("connector") or "").strip().lower()
1728
- if connector_name not in self.channels or connector_name == "local":
1729
- return 400, {
1730
- "ok": False,
1731
- "message": f"Unknown connector `{connector_name}` for conversation `{normalized}`.",
1732
- }
1733
-
2078
+ if connector_name not in self.channels:
2079
+ return []
1734
2080
  channel = self._channel_with_bindings(connector_name)
1735
2081
  conversation_key = conversation_identity_key(normalized)
1736
-
1737
- titles = {
1738
- str(item.get("quest_id") or "").strip(): str(item.get("title") or "").strip() or None
1739
- for item in self.quest_service.list_quests()
1740
- }
1741
-
1742
- conflicts: list[dict] = []
2082
+ titles = self._quest_titles_by_id()
2083
+ conflicts: list[dict[str, object]] = []
1743
2084
  existing_bound = channel.resolve_bound_quest(normalized)
1744
- if existing_bound and existing_bound != quest_id:
2085
+ normalized_quest_id = str(quest_id or "").strip() or None
2086
+ if existing_bound and existing_bound != normalized_quest_id:
1745
2087
  conflicts.append(
1746
2088
  {
1747
2089
  "quest_id": existing_bound,
@@ -1749,10 +2091,9 @@ class DaemonApp:
1749
2091
  "reason": "connector_binding",
1750
2092
  }
1751
2093
  )
1752
-
1753
2094
  for item in self.quest_service.list_quests():
1754
2095
  other_id = str(item.get("quest_id") or "").strip()
1755
- if not other_id or other_id == quest_id:
2096
+ if not other_id or other_id == normalized_quest_id:
1756
2097
  continue
1757
2098
  sources = self.quest_service.binding_sources(other_id)
1758
2099
  if any(conversation_identity_key(source) == conversation_key for source in sources):
@@ -1763,8 +2104,7 @@ class DaemonApp:
1763
2104
  "reason": "quest_binding",
1764
2105
  }
1765
2106
  )
1766
-
1767
- deduped_conflicts: list[dict] = []
2107
+ deduped_conflicts: list[dict[str, object]] = []
1768
2108
  seen_conflict_ids: set[str] = set()
1769
2109
  for item in conflicts:
1770
2110
  candidate = str(item.get("quest_id") or "").strip()
@@ -1772,34 +2112,91 @@ class DaemonApp:
1772
2112
  continue
1773
2113
  seen_conflict_ids.add(candidate)
1774
2114
  deduped_conflicts.append(item)
2115
+ return deduped_conflicts
1775
2116
 
1776
- if deduped_conflicts and not force:
2117
+ def _unbind_quest_connector_bindings(
2118
+ self,
2119
+ quest_id: str,
2120
+ connector_name: str,
2121
+ *,
2122
+ preserve: set[str] | None = None,
2123
+ ) -> list[str]:
2124
+ normalized_connector = str(connector_name or "").strip().lower()
2125
+ preserve_keys = {conversation_identity_key(item) for item in (preserve or set()) if item}
2126
+ removed: list[str] = []
2127
+ if not normalized_connector or normalized_connector == "local":
2128
+ return removed
2129
+ try:
2130
+ channel = self._channel_with_bindings(normalized_connector)
2131
+ except Exception:
2132
+ return removed
2133
+ for item in channel.list_bindings():
2134
+ if str(item.get("quest_id") or "").strip() != quest_id:
2135
+ continue
2136
+ conversation_id = str(item.get("conversation_id") or "").strip()
2137
+ if not conversation_id:
2138
+ continue
2139
+ if preserve_keys and conversation_identity_key(conversation_id) in preserve_keys:
2140
+ continue
2141
+ if channel.unbind_conversation(conversation_id, quest_id=quest_id):
2142
+ removed.append(conversation_id)
2143
+ self.sessions.unbind(quest_id, conversation_id)
2144
+ for conversation_id in removed:
2145
+ self.quest_service.unbind_source(quest_id, conversation_id)
2146
+ return removed
2147
+
2148
+ def _apply_conversation_binding(
2149
+ self,
2150
+ quest_id: str,
2151
+ conversation_id: str,
2152
+ *,
2153
+ force: bool = False,
2154
+ clear_scope: str = "connector",
2155
+ ) -> dict | tuple[int, dict]:
2156
+ quest_root = self.home / "quests" / quest_id
2157
+ if not quest_root.joinpath("quest.yaml").exists():
2158
+ return 404, {"ok": False, "message": f"Unknown quest `{quest_id}`."}
2159
+ normalized = normalize_conversation_id(conversation_id)
2160
+ parsed = parse_conversation_id(normalized)
2161
+ if parsed is None:
2162
+ return 400, {"ok": False, "message": f"Invalid connector conversation `{conversation_id}`."}
2163
+ connector_name = str(parsed.get("connector") or "").strip().lower()
2164
+ if not connector_name or connector_name == "local" or connector_name not in self.channels:
2165
+ return 400, {"ok": False, "message": f"Unknown connector `{connector_name}` for conversation `{normalized}`."}
2166
+ channel = self._channel_with_bindings(connector_name)
2167
+ conflicts = self._inspect_connector_binding_conflicts(quest_id, normalized)
2168
+ if conflicts and not force:
1777
2169
  return 409, {
1778
2170
  "ok": False,
1779
2171
  "conflict": True,
1780
2172
  "message": "Conversation is already bound to another quest.",
1781
2173
  "quest_id": quest_id,
2174
+ "connector": connector_name,
1782
2175
  "conversation_id": normalized,
1783
- "conflicts": deduped_conflicts,
2176
+ "conflicts": conflicts,
1784
2177
  }
1785
-
1786
- for item in deduped_conflicts:
2178
+ existing_bound = channel.resolve_bound_quest(normalized)
2179
+ for item in conflicts:
1787
2180
  other_id = str(item.get("quest_id") or "").strip()
1788
2181
  if other_id and other_id != quest_id:
1789
2182
  self.quest_service.unbind_source(other_id, normalized)
1790
2183
  self.sessions.unbind(other_id, normalized)
1791
-
1792
2184
  channel.bind_conversation(normalized, quest_id)
1793
2185
  self.sessions.bind(quest_id, normalized)
1794
2186
  self.quest_service.bind_source(quest_id, "local:default")
1795
2187
  self.quest_service.bind_source(quest_id, normalized)
1796
- removed = self._unbind_external_bindings(quest_id, preserve={normalized})
2188
+ if clear_scope == "all_external":
2189
+ removed = self._unbind_external_bindings(quest_id, preserve={normalized})
2190
+ elif clear_scope == "connector":
2191
+ removed = self._unbind_quest_connector_bindings(quest_id, connector_name, preserve={normalized})
2192
+ else:
2193
+ removed = []
1797
2194
  snapshot = self.quest_service.snapshot(quest_id)
1798
2195
  previous_quest_id = str(existing_bound or "").strip() or None
1799
2196
  if previous_quest_id == quest_id:
1800
2197
  previous_quest_id = None
1801
2198
  if previous_quest_id is None:
1802
- for item in deduped_conflicts:
2199
+ for item in conflicts:
1803
2200
  candidate = str(item.get("quest_id") or "").strip()
1804
2201
  if candidate and candidate != quest_id:
1805
2202
  previous_quest_id = candidate
@@ -1807,13 +2204,123 @@ class DaemonApp:
1807
2204
  return {
1808
2205
  "ok": True,
1809
2206
  "quest_id": quest_id,
2207
+ "connector": connector_name,
1810
2208
  "conversation_id": normalized,
1811
2209
  "snapshot": snapshot,
1812
2210
  "removed_conversations": removed,
1813
- "conflicts_resolved": [item.get("quest_id") for item in deduped_conflicts if item.get("quest_id")],
2211
+ "conflicts_resolved": [item.get("quest_id") for item in conflicts if item.get("quest_id")],
1814
2212
  "previous_quest_id": previous_quest_id,
1815
2213
  }
1816
2214
 
2215
+ def update_quest_connector_binding(
2216
+ self,
2217
+ quest_id: str,
2218
+ connector_name: str,
2219
+ conversation_id: str | None,
2220
+ *,
2221
+ force: bool = False,
2222
+ ) -> dict | tuple[int, dict]:
2223
+ quest_root = self.home / "quests" / quest_id
2224
+ if not quest_root.joinpath("quest.yaml").exists():
2225
+ return 404, {"ok": False, "message": f"Unknown quest `{quest_id}`."}
2226
+ normalized_connector = str(connector_name or "").strip().lower()
2227
+ if not normalized_connector or normalized_connector == "local":
2228
+ return 400, {"ok": False, "message": "A non-local connector name is required."}
2229
+ if normalized_connector not in self.channels:
2230
+ return 400, {"ok": False, "message": f"Unknown connector `{normalized_connector}`."}
2231
+
2232
+ normalized = normalize_conversation_id(conversation_id)
2233
+ if not normalized:
2234
+ removed = self._unbind_quest_connector_bindings(quest_id, normalized_connector)
2235
+ self.quest_service.bind_source(quest_id, "local:default")
2236
+ snapshot = self.quest_service.snapshot(quest_id)
2237
+ return {
2238
+ "ok": True,
2239
+ "quest_id": quest_id,
2240
+ "connector": normalized_connector,
2241
+ "conversation_id": None,
2242
+ "snapshot": snapshot,
2243
+ "removed_conversations": removed,
2244
+ }
2245
+
2246
+ parsed = parse_conversation_id(normalized)
2247
+ if parsed is None:
2248
+ return 400, {"ok": False, "message": f"Invalid connector conversation `{normalized}`."}
2249
+ if str(parsed.get("connector") or "").strip().lower() != normalized_connector:
2250
+ return 400, {
2251
+ "ok": False,
2252
+ "message": f"Conversation `{normalized}` does not belong to connector `{normalized_connector}`.",
2253
+ }
2254
+
2255
+ return self._apply_conversation_binding(quest_id, normalized, force=force, clear_scope="connector")
2256
+
2257
+ def update_quest_bindings(
2258
+ self,
2259
+ quest_id: str,
2260
+ requested_bindings: list[dict[str, object]] | None,
2261
+ *,
2262
+ force: bool = False,
2263
+ ) -> dict | tuple[int, dict]:
2264
+ quest_root = self.home / "quests" / quest_id
2265
+ if not quest_root.joinpath("quest.yaml").exists():
2266
+ return 404, {"ok": False, "message": f"Unknown quest `{quest_id}`."}
2267
+ normalized_bindings = self._normalize_requested_connector_bindings(requested_bindings)
2268
+ conflicts = self.preview_connector_binding_conflicts(normalized_bindings, quest_id=quest_id)
2269
+ if conflicts and not force:
2270
+ return 409, {
2271
+ "ok": False,
2272
+ "conflict": True,
2273
+ "message": "One or more connector targets are already bound to another quest.",
2274
+ "quest_id": quest_id,
2275
+ "conflicts": conflicts,
2276
+ }
2277
+ results: list[dict[str, object]] = []
2278
+ for item in normalized_bindings:
2279
+ connector_name = str(item.get("connector") or "").strip().lower()
2280
+ result = self.update_quest_connector_binding(
2281
+ quest_id,
2282
+ connector_name,
2283
+ str(item.get("conversation_id") or "").strip() or None,
2284
+ force=True if force or conflicts else False,
2285
+ )
2286
+ if isinstance(result, tuple):
2287
+ return result
2288
+ results.append(result)
2289
+ snapshot = self.quest_service.snapshot(quest_id)
2290
+ return {
2291
+ "ok": True,
2292
+ "quest_id": quest_id,
2293
+ "snapshot": snapshot,
2294
+ "results": results,
2295
+ }
2296
+
2297
+ def update_quest_binding(
2298
+ self,
2299
+ quest_id: str,
2300
+ conversation_id: str | None,
2301
+ *,
2302
+ force: bool = False,
2303
+ ) -> dict | tuple[int, dict]:
2304
+ normalized = normalize_conversation_id(conversation_id)
2305
+ parsed = parse_conversation_id(normalized)
2306
+
2307
+ if parsed is None or parsed.get("connector", "").lower() == "local":
2308
+ removed = self._unbind_external_bindings(quest_id)
2309
+ self.quest_service.set_binding_sources(quest_id, ["local:default"])
2310
+ snapshot = self.quest_service.snapshot(quest_id)
2311
+ return {
2312
+ "ok": True,
2313
+ "quest_id": quest_id,
2314
+ "conversation_id": None,
2315
+ "snapshot": snapshot,
2316
+ "removed_conversations": removed,
2317
+ }
2318
+
2319
+ connector_name = str(parsed.get("connector") or "").strip().lower()
2320
+ if connector_name not in self.channels or connector_name == "local":
2321
+ return 400, {"ok": False, "message": f"Unknown connector `{connector_name}` for conversation `{normalized}`."}
2322
+ return self._apply_conversation_binding(quest_id, normalized, force=force, clear_scope="all_external")
2323
+
1817
2324
  def delete_quest(self, quest_id: str, *, source: str = "web") -> dict | tuple[int, dict]:
1818
2325
  quests_root = self.home / "quests"
1819
2326
  try:
@@ -1903,52 +2410,6 @@ class DaemonApp:
1903
2410
  "reply": reply,
1904
2411
  }
1905
2412
 
1906
- def handle_bridge_webhook(
1907
- self,
1908
- connector_name: str,
1909
- *,
1910
- method: str,
1911
- path: str,
1912
- raw_body: bytes,
1913
- headers: dict[str, str],
1914
- body: dict,
1915
- ) -> tuple[int, dict, bytes | str] | dict:
1916
- bridge = get_connector_bridge(connector_name)
1917
- if bridge is None:
1918
- return 404, {"Content-Type": "application/json; charset=utf-8"}, json.dumps({"ok": False, "message": f"Unknown bridge `{connector_name}`."}, ensure_ascii=False)
1919
- query = self.handlers.parse_query(path)
1920
- result = bridge.parse_webhook(
1921
- method=method,
1922
- headers=headers,
1923
- query=query,
1924
- raw_body=raw_body,
1925
- body=body,
1926
- config=self.connectors_config.get(connector_name, {}),
1927
- )
1928
- if result.response_body is not None and not result.events:
1929
- headers_out = {"Content-Type": "application/json; charset=utf-8", **result.response_headers}
1930
- body_out = result.response_body
1931
- if isinstance(body_out, bytes):
1932
- return result.status_code, headers_out, body_out
1933
- if isinstance(body_out, str):
1934
- return result.status_code, headers_out, body_out
1935
- return result.status_code, headers_out, json.dumps(body_out, ensure_ascii=False)
1936
-
1937
- responses: list[dict] = []
1938
- accepted = False
1939
- for event in result.events:
1940
- routed = self.handle_connector_inbound(connector_name, event)
1941
- responses.append(routed)
1942
- accepted = accepted or bool(routed.get("accepted"))
1943
- return {
1944
- "ok": result.ok,
1945
- "accepted": accepted,
1946
- "connector": connector_name,
1947
- "event_count": len(result.events),
1948
- "message": result.message,
1949
- "responses": responses,
1950
- }
1951
-
1952
2413
  def _route_connector_message(self, connector_name: str, message: dict) -> dict:
1953
2414
  channel = self._channel_with_bindings(connector_name)
1954
2415
  connector_label = self._connector_label(connector_name)
@@ -2609,7 +3070,7 @@ class DaemonApp:
2609
3070
  original_identity = conversation_identity_key(conversation_id)
2610
3071
  if original_identity in seen_identity_keys:
2611
3072
  continue
2612
- result = self.update_quest_binding(quest_id, conversation_id, force=True)
3073
+ result = self._apply_conversation_binding(quest_id, conversation_id, force=True, clear_scope="none")
2613
3074
  if isinstance(result, tuple):
2614
3075
  continue
2615
3076
  bound_conversation = str(result.get("conversation_id") or "").strip() or conversation_id
@@ -2644,9 +3105,35 @@ class DaemonApp:
2644
3105
  if connector_name == "qq":
2645
3106
  qq_config = self.connectors_config.get("qq", {})
2646
3107
  if isinstance(qq_config, dict):
2647
- main_chat_id = str(qq_config.get("main_chat_id") or "").strip()
2648
- if main_chat_id:
2649
- conversation_ids.append(f"qq:direct:{main_chat_id}")
3108
+ profiles = list_qq_profiles(qq_config)
3109
+ encode_profile_id = len(profiles) > 1
3110
+ for profile in profiles:
3111
+ main_chat_id = str(profile.get("main_chat_id") or "").strip()
3112
+ profile_id = str(profile.get("profile_id") or "").strip()
3113
+ if main_chat_id:
3114
+ conversation_ids.append(
3115
+ format_conversation_id(
3116
+ "qq",
3117
+ "direct",
3118
+ main_chat_id,
3119
+ profile_id=profile_id if encode_profile_id else None,
3120
+ )
3121
+ )
3122
+ elif connector_name in PROFILEABLE_CONNECTOR_NAMES:
3123
+ connector_config = self.connectors_config.get(connector_name, {})
3124
+ if isinstance(connector_config, dict):
3125
+ for profile in list_connector_profiles(connector_name, connector_config):
3126
+ profile_id = str(profile.get("profile_id") or "").strip()
3127
+ if not profile_id:
3128
+ continue
3129
+ runtime_state = read_json(
3130
+ self.home / "logs" / "connectors" / connector_name / "profiles" / profile_id / "runtime.json",
3131
+ {},
3132
+ )
3133
+ if isinstance(runtime_state, dict):
3134
+ runtime_last_conversation_id = str(runtime_state.get("last_conversation_id") or "").strip()
3135
+ if runtime_last_conversation_id:
3136
+ conversation_ids.append(runtime_last_conversation_id)
2650
3137
  state_path = self.home / "logs" / "connectors" / connector_name / "state.json"
2651
3138
  state = read_json(state_path, {})
2652
3139
  if isinstance(state, dict):
@@ -2892,36 +3379,32 @@ class DaemonApp:
2892
3379
  chat_type = str(message.get("chat_type") or "").strip().lower()
2893
3380
  if chat_type != "direct":
2894
3381
  return None
3382
+ profile_id = str(message.get("profile_id") or "").strip() or None
2895
3383
  chat_id = str(message.get("chat_id") or message.get("direct_id") or "").strip()
2896
3384
  if not chat_id:
2897
- conversation_id = str(message.get("conversation_id") or "").strip()
2898
- parts = conversation_id.split(":", 2)
2899
- if len(parts) == 3 and parts[0] == "qq" and parts[1] == "direct":
2900
- chat_id = parts[2]
3385
+ parsed = parse_conversation_id(message.get("conversation_id"))
3386
+ if parsed is not None and str(parsed.get("connector") or "").strip().lower() == "qq":
3387
+ chat_id = str(parsed.get("chat_id") or "").strip()
3388
+ profile_id = str(parsed.get("profile_id") or "").strip() or profile_id
2901
3389
  if not chat_id:
2902
3390
  return None
2903
- result = self.config_manager.bind_qq_main_chat(chat_id=chat_id)
3391
+ result = self.config_manager.bind_qq_main_chat(profile_id=profile_id, chat_id=chat_id)
2904
3392
  if not result.get("ok"):
2905
3393
  self.logger.log(
2906
3394
  "warning",
2907
3395
  "connector.qq_main_chat_bind_failed",
2908
3396
  chat_id=chat_id,
3397
+ profile_id=profile_id,
2909
3398
  errors=result.get("errors") or [],
2910
3399
  )
2911
3400
  return None
2912
3401
  if not result.get("saved"):
2913
3402
  return None
2914
- qq_config = self.connectors_config.get("qq")
2915
- if isinstance(qq_config, dict):
2916
- qq_config["main_chat_id"] = chat_id
2917
- channel = self.channels.get("qq")
2918
- if channel is not None and hasattr(channel, "config") and isinstance(channel.config, dict):
2919
- channel.config["main_chat_id"] = chat_id
2920
- gateway = self._qq_gateway
2921
- if gateway is not None and isinstance(gateway.config, dict):
2922
- gateway.config["main_chat_id"] = chat_id
3403
+ self.reload_connectors_config(restart_background=False)
2923
3404
  return {
2924
3405
  "chat_id": chat_id,
3406
+ "profile_id": result.get("profile_id"),
3407
+ "profile_label": result.get("profile_label"),
2925
3408
  "saved_at": result.get("saved_at"),
2926
3409
  }
2927
3410
 
@@ -2932,9 +3415,17 @@ class DaemonApp:
2932
3415
  chat_id = str(binding.get("chat_id") or "").strip()
2933
3416
  if not chat_id:
2934
3417
  return base
3418
+ profile_label = str(binding.get("profile_label") or "").strip()
2935
3419
  notice = self._polite_copy(
2936
- zh=f"已自动检测并保存当前 QQ openid:`{chat_id}`。您现在可以在 settings 页面看到这个绑定结果。",
2937
- en=f"I automatically detected and saved this QQ openid: `{chat_id}`. You can now see the binding in settings.",
3420
+ zh=(
3421
+ f"已自动检测并保存当前 QQ openid:`{chat_id}`。"
3422
+ f"{f'当前 bot:{profile_label}。' if profile_label else ''}您现在可以在 settings 页面看到这个绑定结果。"
3423
+ ),
3424
+ en=(
3425
+ f"I automatically detected and saved this QQ openid: `{chat_id}`. "
3426
+ f"{f'Current bot: {profile_label}. ' if profile_label else ''}"
3427
+ "You can now see the binding in settings."
3428
+ ),
2938
3429
  )
2939
3430
  return f"{notice}\n\n{base}"
2940
3431
 
@@ -2973,93 +3464,160 @@ class DaemonApp:
2973
3464
  + restore_en,
2974
3465
  )
2975
3466
 
3467
+ def _profiled_connector_configs(self, connector_name: str) -> list[tuple[str, str | None, dict[str, Any]]]:
3468
+ connector_config = self.connectors_config.get(connector_name, {})
3469
+ if not isinstance(connector_config, dict):
3470
+ return []
3471
+ profiles = list_connector_profiles(connector_name, connector_config)
3472
+ encode_profile_id = len(profiles) > 1
3473
+ items: list[tuple[str, str | None, dict[str, Any]]] = []
3474
+ for profile in profiles:
3475
+ profile_id = str(profile.get("profile_id") or "").strip()
3476
+ if not profile_id:
3477
+ continue
3478
+ merged = merge_connector_profile_config(connector_name, connector_config, profile)
3479
+ merged["encode_profile_id"] = encode_profile_id
3480
+ items.append((profile_id, connector_profile_label(connector_name, profile), merged))
3481
+ return items
3482
+
2976
3483
  def _start_background_connectors(self) -> None:
2977
3484
  qq_config = self.connectors_config.get("qq", {})
2978
- if isinstance(qq_config, dict) and self._qq_gateway is None:
2979
- gateway = QQGatewayService(
2980
- home=self.home,
2981
- config=qq_config,
2982
- on_event=lambda event: self.handle_connector_inbound("qq", event),
2983
- log=lambda level, message: self.logger.log(level, "connector.qq_gateway", message=message),
2984
- )
2985
- if gateway.start():
2986
- self._qq_gateway = gateway
2987
- telegram_config = self.connectors_config.get("telegram", {})
2988
- if isinstance(telegram_config, dict) and self._telegram_polling is None:
2989
- polling = TelegramPollingService(
2990
- home=self.home,
2991
- config=telegram_config,
2992
- on_event=lambda event: self.handle_connector_inbound("telegram", event),
2993
- log=lambda level, message: self.logger.log(level, "connector.telegram_polling", message=message),
2994
- )
2995
- if polling.start():
2996
- self._telegram_polling = polling
2997
- slack_config = self.connectors_config.get("slack", {})
2998
- if isinstance(slack_config, dict) and self._slack_socket is None:
2999
- slack = SlackSocketModeService(
3000
- home=self.home,
3001
- config=slack_config,
3002
- on_event=lambda event: self.handle_connector_inbound("slack", event),
3003
- log=lambda level, message: self.logger.log(level, "connector.slack_socket", message=message),
3004
- )
3005
- if slack.start():
3006
- self._slack_socket = slack
3007
- discord_config = self.connectors_config.get("discord", {})
3008
- if isinstance(discord_config, dict) and self._discord_gateway is None:
3009
- discord = DiscordGatewayService(
3010
- home=self.home,
3011
- config=discord_config,
3012
- on_event=lambda event: self.handle_connector_inbound("discord", event),
3013
- log=lambda level, message: self.logger.log(level, "connector.discord_gateway", message=message),
3014
- )
3015
- if discord.start():
3016
- self._discord_gateway = discord
3017
- feishu_config = self.connectors_config.get("feishu", {})
3018
- if isinstance(feishu_config, dict) and self._feishu_long_connection is None:
3019
- feishu = FeishuLongConnectionService(
3020
- home=self.home,
3021
- config=feishu_config,
3022
- on_event=lambda event: self.handle_connector_inbound("feishu", event),
3023
- log=lambda level, message: self.logger.log(level, "connector.feishu_long_connection", message=message),
3024
- )
3025
- if feishu.start():
3026
- self._feishu_long_connection = feishu
3027
- whatsapp_config = self.connectors_config.get("whatsapp", {})
3028
- if isinstance(whatsapp_config, dict) and self._whatsapp_local_session is None:
3029
- whatsapp = WhatsAppLocalSessionService(
3030
- home=self.home,
3031
- config=whatsapp_config,
3032
- on_event=lambda event: self.handle_connector_inbound("whatsapp", event),
3033
- log=lambda level, message: self.logger.log(level, "connector.whatsapp_local_session", message=message),
3034
- )
3035
- if whatsapp.start():
3036
- self._whatsapp_local_session = whatsapp
3485
+ if isinstance(qq_config, dict) and not self._qq_gateways:
3486
+ profiles = list_qq_profiles(qq_config)
3487
+ encode_profile_id = len(profiles) > 1
3488
+ for profile in profiles:
3489
+ profile_id = str(profile.get("profile_id") or "").strip()
3490
+ profile_config = merge_qq_profile_config(qq_config, profile)
3491
+ profile_config["encode_profile_id"] = encode_profile_id
3492
+ gateway = QQGatewayService(
3493
+ home=self.home,
3494
+ config=profile_config,
3495
+ on_event=lambda event: self.handle_connector_inbound("qq", event),
3496
+ log=lambda level, message, _profile_id=profile_id: self.logger.log(
3497
+ level,
3498
+ "connector.qq_gateway",
3499
+ profile_id=_profile_id,
3500
+ message=message,
3501
+ ),
3502
+ )
3503
+ if gateway.start():
3504
+ self._qq_gateways[profile_id] = gateway
3505
+ if not self._telegram_polling:
3506
+ for profile_id, profile_label, profile_config in self._profiled_connector_configs("telegram"):
3507
+ polling = TelegramPollingService(
3508
+ home=self.home,
3509
+ config=profile_config,
3510
+ on_event=lambda event: self.handle_connector_inbound("telegram", event),
3511
+ log=lambda level, message, _profile_id=profile_id: self.logger.log(
3512
+ level,
3513
+ "connector.telegram_polling",
3514
+ profile_id=_profile_id,
3515
+ message=message,
3516
+ ),
3517
+ profile_id=profile_id,
3518
+ profile_label=profile_label,
3519
+ encode_profile_id=bool(profile_config.get("encode_profile_id")),
3520
+ )
3521
+ if polling.start():
3522
+ self._telegram_polling[profile_id] = polling
3523
+ if not self._slack_socket:
3524
+ for profile_id, profile_label, profile_config in self._profiled_connector_configs("slack"):
3525
+ slack = SlackSocketModeService(
3526
+ home=self.home,
3527
+ config=profile_config,
3528
+ on_event=lambda event: self.handle_connector_inbound("slack", event),
3529
+ log=lambda level, message, _profile_id=profile_id: self.logger.log(
3530
+ level,
3531
+ "connector.slack_socket",
3532
+ profile_id=_profile_id,
3533
+ message=message,
3534
+ ),
3535
+ profile_id=profile_id,
3536
+ profile_label=profile_label,
3537
+ encode_profile_id=bool(profile_config.get("encode_profile_id")),
3538
+ )
3539
+ if slack.start():
3540
+ self._slack_socket[profile_id] = slack
3541
+ if not self._discord_gateway:
3542
+ for profile_id, profile_label, profile_config in self._profiled_connector_configs("discord"):
3543
+ discord = DiscordGatewayService(
3544
+ home=self.home,
3545
+ config=profile_config,
3546
+ on_event=lambda event: self.handle_connector_inbound("discord", event),
3547
+ log=lambda level, message, _profile_id=profile_id: self.logger.log(
3548
+ level,
3549
+ "connector.discord_gateway",
3550
+ profile_id=_profile_id,
3551
+ message=message,
3552
+ ),
3553
+ profile_id=profile_id,
3554
+ profile_label=profile_label,
3555
+ encode_profile_id=bool(profile_config.get("encode_profile_id")),
3556
+ )
3557
+ if discord.start():
3558
+ self._discord_gateway[profile_id] = discord
3559
+ if not self._feishu_long_connection:
3560
+ for profile_id, profile_label, profile_config in self._profiled_connector_configs("feishu"):
3561
+ feishu = FeishuLongConnectionService(
3562
+ home=self.home,
3563
+ config=profile_config,
3564
+ on_event=lambda event: self.handle_connector_inbound("feishu", event),
3565
+ log=lambda level, message, _profile_id=profile_id: self.logger.log(
3566
+ level,
3567
+ "connector.feishu_long_connection",
3568
+ profile_id=_profile_id,
3569
+ message=message,
3570
+ ),
3571
+ profile_id=profile_id,
3572
+ profile_label=profile_label,
3573
+ encode_profile_id=bool(profile_config.get("encode_profile_id")),
3574
+ )
3575
+ if feishu.start():
3576
+ self._feishu_long_connection[profile_id] = feishu
3577
+ if not self._whatsapp_local_session:
3578
+ for profile_id, profile_label, profile_config in self._profiled_connector_configs("whatsapp"):
3579
+ whatsapp = WhatsAppLocalSessionService(
3580
+ home=self.home,
3581
+ config=profile_config,
3582
+ on_event=lambda event: self.handle_connector_inbound("whatsapp", event),
3583
+ log=lambda level, message, _profile_id=profile_id: self.logger.log(
3584
+ level,
3585
+ "connector.whatsapp_local_session",
3586
+ profile_id=_profile_id,
3587
+ message=message,
3588
+ ),
3589
+ profile_id=profile_id,
3590
+ profile_label=profile_label,
3591
+ encode_profile_id=bool(profile_config.get("encode_profile_id")),
3592
+ )
3593
+ if whatsapp.start():
3594
+ self._whatsapp_local_session[profile_id] = whatsapp
3037
3595
 
3038
3596
  def _stop_background_connectors(self) -> None:
3039
- gateway = self._qq_gateway
3040
- self._qq_gateway = None
3041
- if gateway is not None:
3597
+ gateways = list(self._qq_gateways.values())
3598
+ self._qq_gateways = {}
3599
+ for gateway in gateways:
3042
3600
  gateway.stop()
3043
- polling = self._telegram_polling
3044
- self._telegram_polling = None
3045
- if polling is not None:
3046
- polling.stop()
3047
- slack = self._slack_socket
3048
- self._slack_socket = None
3049
- if slack is not None:
3050
- slack.stop()
3051
- discord = self._discord_gateway
3052
- self._discord_gateway = None
3053
- if discord is not None:
3054
- discord.stop()
3055
- feishu = self._feishu_long_connection
3056
- self._feishu_long_connection = None
3057
- if feishu is not None:
3058
- feishu.stop()
3059
- whatsapp = self._whatsapp_local_session
3060
- self._whatsapp_local_session = None
3061
- if whatsapp is not None:
3062
- whatsapp.stop()
3601
+ polling = list(self._telegram_polling.values())
3602
+ self._telegram_polling = {}
3603
+ for item in polling:
3604
+ item.stop()
3605
+ slack = list(self._slack_socket.values())
3606
+ self._slack_socket = {}
3607
+ for item in slack:
3608
+ item.stop()
3609
+ discord = list(self._discord_gateway.values())
3610
+ self._discord_gateway = {}
3611
+ for item in discord:
3612
+ item.stop()
3613
+ feishu = list(self._feishu_long_connection.values())
3614
+ self._feishu_long_connection = {}
3615
+ for item in feishu:
3616
+ item.stop()
3617
+ whatsapp = list(self._whatsapp_local_session.values())
3618
+ self._whatsapp_local_session = {}
3619
+ for item in whatsapp:
3620
+ item.stop()
3063
3621
 
3064
3622
  @staticmethod
3065
3623
  def _format_status(snapshot: dict) -> str:
@@ -3658,16 +4216,7 @@ class DaemonApp:
3658
4216
  payload = result(**params, path=self.path)
3659
4217
  elif method == "GET":
3660
4218
  payload = result(**params) if params else result()
3661
- elif route_name == "bridge_webhook":
3662
- payload = result(
3663
- **params,
3664
- method=method,
3665
- path=self.path,
3666
- raw_body=raw_body,
3667
- headers=dict(self.headers.items()),
3668
- body=body,
3669
- )
3670
- elif route_name in {"document_open", "document_asset_upload", "chat", "command", "quest_control", "config_save", "quest_create", "quest_baseline_binding", "run_create", "qq_inbound", "connector_inbound", "docs_open", "admin_shutdown", "bash_stop", "quest_settings", "quest_bindings", "quest_delete", "terminal_session_ensure", "terminal_attach", "terminal_input", "stage_view", "latex_init", "latex_compile"}:
4219
+ elif route_name in {"document_open", "document_asset_upload", "chat", "command", "quest_control", "config_save", "quest_create", "quest_baseline_binding", "run_create", "qq_inbound", "connector_inbound", "docs_open", "admin_shutdown", "bash_stop", "quest_settings", "quest_bindings", "quest_delete", "terminal_session_ensure", "terminal_attach", "terminal_input", "stage_view", "latex_init", "latex_compile", "system_update_action"}:
3671
4220
  payload = result(**params, body=body)
3672
4221
  elif route_name == "config_validate":
3673
4222
  payload = result(body)
@@ -3711,6 +4260,8 @@ class DaemonApp:
3711
4260
  server = ThreadingHTTPServer((host, port), RequestHandler)
3712
4261
  server.daemon_threads = True
3713
4262
  self._server = server
4263
+ self._serve_host = host
4264
+ self._serve_port = port
3714
4265
  self._shutdown_requested.clear()
3715
4266
  self._start_terminal_attach_server(host, port)
3716
4267
  self._start_background_connectors()
@@ -3724,4 +4275,6 @@ class DaemonApp:
3724
4275
  self._stop_terminal_attach_server()
3725
4276
  self.bash_exec_service.shutdown()
3726
4277
  self._server = None
4278
+ self._serve_host = None
4279
+ self._serve_port = None
3727
4280
  server.server_close()