@researai/deepscientist 1.5.2 → 1.5.4

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 (114) hide show
  1. package/README.md +22 -0
  2. package/bin/ds.js +399 -175
  3. package/docs/en/00_QUICK_START.md +22 -0
  4. package/docs/en/01_SETTINGS_REFERENCE.md +13 -4
  5. package/docs/en/99_ACKNOWLEDGEMENTS.md +1 -0
  6. package/docs/images/connectors/discord-setup-overview.svg +52 -0
  7. package/docs/images/connectors/feishu-setup-overview.svg +53 -0
  8. package/docs/images/connectors/slack-setup-overview.svg +51 -0
  9. package/docs/images/connectors/telegram-setup-overview.svg +55 -0
  10. package/docs/images/connectors/whatsapp-setup-overview.svg +51 -0
  11. package/docs/images/lingzhu/lingzhu-openclaw-config.svg +17 -0
  12. package/docs/images/lingzhu/lingzhu-platform-values.svg +16 -0
  13. package/docs/images/lingzhu/lingzhu-settings-overview.svg +30 -0
  14. package/docs/images/qq/tencent-cloud-qq-chat.png +0 -0
  15. package/docs/images/qq/tencent-cloud-qq-register.png +0 -0
  16. package/docs/images/quickstart/00-home.png +0 -0
  17. package/docs/images/quickstart/01-start-research.png +0 -0
  18. package/docs/images/quickstart/02-list-quest.png +0 -0
  19. package/docs/zh/00_QUICK_START.md +22 -0
  20. package/docs/zh/01_SETTINGS_REFERENCE.md +14 -5
  21. package/docs/zh/99_ACKNOWLEDGEMENTS.md +1 -0
  22. package/install.sh +120 -4
  23. package/package.json +8 -4
  24. package/pyproject.toml +1 -1
  25. package/src/deepscientist/__init__.py +1 -1
  26. package/src/deepscientist/artifact/service.py +1 -1
  27. package/src/deepscientist/bash_exec/monitor.py +23 -4
  28. package/src/deepscientist/bash_exec/runtime.py +3 -0
  29. package/src/deepscientist/bash_exec/service.py +132 -4
  30. package/src/deepscientist/bridges/base.py +12 -20
  31. package/src/deepscientist/bridges/connectors.py +2 -1
  32. package/src/deepscientist/channels/discord_gateway.py +27 -4
  33. package/src/deepscientist/channels/feishu_long_connection.py +41 -3
  34. package/src/deepscientist/channels/qq.py +524 -64
  35. package/src/deepscientist/channels/qq_gateway.py +24 -5
  36. package/src/deepscientist/channels/relay.py +429 -90
  37. package/src/deepscientist/channels/slack_socket.py +31 -7
  38. package/src/deepscientist/channels/telegram_polling.py +27 -3
  39. package/src/deepscientist/channels/whatsapp_local_session.py +32 -4
  40. package/src/deepscientist/cli.py +31 -1
  41. package/src/deepscientist/config/models.py +13 -43
  42. package/src/deepscientist/config/service.py +216 -157
  43. package/src/deepscientist/connector_profiles.py +346 -0
  44. package/src/deepscientist/connector_runtime.py +88 -43
  45. package/src/deepscientist/daemon/api/handlers.py +53 -16
  46. package/src/deepscientist/daemon/api/router.py +2 -2
  47. package/src/deepscientist/daemon/app.py +747 -228
  48. package/src/deepscientist/mcp/server.py +60 -7
  49. package/src/deepscientist/migration.py +114 -0
  50. package/src/deepscientist/network.py +78 -0
  51. package/src/deepscientist/prompts/builder.py +50 -4
  52. package/src/deepscientist/qq_profiles.py +186 -0
  53. package/src/deepscientist/quest/service.py +1 -1
  54. package/src/deepscientist/skills/installer.py +77 -1
  55. package/src/prompts/connectors/qq.md +42 -2
  56. package/src/prompts/system.md +162 -6
  57. package/src/skills/analysis-campaign/SKILL.md +19 -5
  58. package/src/skills/baseline/SKILL.md +66 -31
  59. package/src/skills/decision/SKILL.md +1 -1
  60. package/src/skills/experiment/SKILL.md +11 -5
  61. package/src/skills/finalize/SKILL.md +1 -1
  62. package/src/skills/idea/SKILL.md +246 -4
  63. package/src/skills/intake-audit/SKILL.md +1 -1
  64. package/src/skills/rebuttal/SKILL.md +1 -1
  65. package/src/skills/review/SKILL.md +1 -1
  66. package/src/skills/scout/SKILL.md +1 -1
  67. package/src/skills/write/SKILL.md +152 -2
  68. package/src/tui/package.json +1 -1
  69. package/src/ui/dist/assets/{AiManusChatView-CZpg376x.js → AiManusChatView-BGLArZRn.js} +14 -37
  70. package/src/ui/dist/assets/{AnalysisPlugin-CtHA22g3.js → AnalysisPlugin-BgDGSigG.js} +1 -1
  71. package/src/ui/dist/assets/{AutoFigurePlugin-BSWmLMmF.js → AutoFigurePlugin-B65HD7L4.js} +5 -5
  72. package/src/ui/dist/assets/{CliPlugin-CJ7jdm_s.js → CliPlugin-CUqgsFHC.js} +17 -110
  73. package/src/ui/dist/assets/{CodeEditorPlugin-DhInVGFf.js → CodeEditorPlugin-CF5EdvaS.js} +8 -8
  74. package/src/ui/dist/assets/{CodeViewerPlugin-D1n8S9r5.js → CodeViewerPlugin-DEeU063D.js} +5 -5
  75. package/src/ui/dist/assets/{DocViewerPlugin-C4XM_kqk.js → DocViewerPlugin-Df-FuDlZ.js} +3 -3
  76. package/src/ui/dist/assets/{GitDiffViewerPlugin-W6kS9r6v.js → GitDiffViewerPlugin-RAnNaRxM.js} +1 -1
  77. package/src/ui/dist/assets/{ImageViewerPlugin-DPeUx_Oz.js → ImageViewerPlugin-DXJ0ZJGg.js} +5 -5
  78. package/src/ui/dist/assets/{LabCopilotPanel-eAelUaub.js → LabCopilotPanel-BlO-sKsj.js} +10 -10
  79. package/src/ui/dist/assets/{LabPlugin-BbOrBxKY.js → LabPlugin-BajPZW5v.js} +1 -1
  80. package/src/ui/dist/assets/{LatexPlugin-C-HhkVXY.js → LatexPlugin-F1OEol8D.js} +7 -7
  81. package/src/ui/dist/assets/{MarkdownViewerPlugin-BDIzIBfh.js → MarkdownViewerPlugin-MhUupqwT.js} +4 -4
  82. package/src/ui/dist/assets/{MarketplacePlugin-DAOJphwr.js → MarketplacePlugin-DxhIEsv0.js} +3 -3
  83. package/src/ui/dist/assets/{NotebookEditor-BsoMvDoU.js → NotebookEditor-q7TkhewC.js} +1 -1
  84. package/src/ui/dist/assets/{PdfLoader-fiC7RtHf.js → PdfLoader-B8ZOTKFc.js} +1 -1
  85. package/src/ui/dist/assets/{PdfMarkdownPlugin-C5OxZBFK.js → PdfMarkdownPlugin-xFPvzvWh.js} +3 -3
  86. package/src/ui/dist/assets/{PdfViewerPlugin-CAbxQebk.js → PdfViewerPlugin-EjEcsIB8.js} +10 -10
  87. package/src/ui/dist/assets/{SearchPlugin-SE33Lb9B.js → SearchPlugin-ixY-1lgW.js} +1 -1
  88. package/src/ui/dist/assets/{Stepper-0Av7GfV7.js → Stepper-gYFK2Pgz.js} +1 -1
  89. package/src/ui/dist/assets/{TextViewerPlugin-Daf2gJDI.js → TextViewerPlugin-Cym6pv_n.js} +4 -4
  90. package/src/ui/dist/assets/{VNCViewer-BKrMUIOX.js → VNCViewer-BPmIHcmK.js} +9 -9
  91. package/src/ui/dist/assets/{bibtex-JBdOEe45.js → bibtex-Btv6Wi7f.js} +1 -1
  92. package/src/ui/dist/assets/{code-B0TDFCZz.js → code-BlG7g85c.js} +1 -1
  93. package/src/ui/dist/assets/{file-content-3YtrSacz.js → file-content-DBT5OfTZ.js} +1 -1
  94. package/src/ui/dist/assets/{file-diff-panel-CJEg5OG1.js → file-diff-panel-BWXYzqHk.js} +1 -1
  95. package/src/ui/dist/assets/{file-socket-CYQYdmB1.js → file-socket-wDlx6byM.js} +1 -1
  96. package/src/ui/dist/assets/{file-utils-Cd1C9Ppl.js → file-utils-Ba3nJmH0.js} +1 -1
  97. package/src/ui/dist/assets/{image-B33ctrvC.js → image-BwtCyguk.js} +1 -1
  98. package/src/ui/dist/assets/{index-BNQWqmJ2.js → index-B-2scqCJ.js} +11 -11
  99. package/src/ui/dist/assets/{index-BVXsmS7V.js → index-Bz5AaWL7.js} +52383 -51440
  100. package/src/ui/dist/assets/{index-Buw_N1VQ.js → index-CfRpE209.js} +2 -2
  101. package/src/ui/dist/assets/{index-9CLPVeZh.js → index-DcqvKzeJ.js} +1 -1
  102. package/src/ui/dist/assets/{index-SwmFAld3.css → index-DpMZw8aM.css} +49 -2
  103. package/src/ui/dist/assets/{message-square-D0cUJ9yU.js → message-square-BnlyWVH0.js} +1 -1
  104. package/src/ui/dist/assets/{monaco-UZLYkp2n.js → monaco-CXe0pAVe.js} +1 -1
  105. package/src/ui/dist/assets/{popover-CTeiY-dK.js → popover-BCHmVhHj.js} +1 -1
  106. package/src/ui/dist/assets/{project-sync-Dbs01Xky.js → project-sync-Brk6kaOD.js} +1 -1
  107. package/src/ui/dist/assets/{sigma-CM08S-xT.js → sigma-D72eSUep.js} +1 -1
  108. package/src/ui/dist/assets/{tooltip-pDtzvU9p.js → tooltip-BMWd0dqX.js} +1 -1
  109. package/src/ui/dist/assets/{trash-YvPCP-da.js → trash-BIt_eWIS.js} +1 -1
  110. package/src/ui/dist/assets/{useCliAccess-Bavi74Ac.js → useCliAccess-N1hkTRrR.js} +1 -1
  111. package/src/ui/dist/assets/{useFileDiffOverlay-CVXY6oeg.js → useFileDiffOverlay-DPRPv6rv.js} +1 -1
  112. package/src/ui/dist/assets/{wrap-text-Cf4flRW7.js → wrap-text-E5-UheyP.js} +1 -1
  113. package/src/ui/dist/assets/{zoom-out-Hb0Z1YpT.js → zoom-out-D4TR-ZZ_.js} +1 -1
  114. package/src/ui/dist/index.html +2 -2
@@ -4,16 +4,22 @@ import json
4
4
  import os
5
5
  from pathlib import Path
6
6
  from typing import Any
7
- from urllib.error import URLError
8
- from urllib.request import Request, urlopen
9
7
 
10
8
  from ..connector_runtime import (
11
9
  build_discovered_target,
12
10
  conversation_identity_key,
11
+ format_conversation_id,
13
12
  infer_connector_transport,
14
13
  merge_discovered_targets,
15
14
  parse_conversation_id,
16
15
  )
16
+ from ..connector_profiles import (
17
+ PROFILEABLE_CONNECTOR_NAMES,
18
+ connector_profile_label,
19
+ find_connector_profile,
20
+ list_connector_profiles,
21
+ merge_connector_profile_config,
22
+ )
17
23
  from ..bridges import get_connector_bridge
18
24
  from ..shared import append_jsonl, ensure_dir, generate_id, read_json, read_jsonl, utc_now, write_json
19
25
  from .base import BaseChannel
@@ -35,6 +41,45 @@ class GenericRelayChannel(BaseChannel):
35
41
  self.bindings_path = self.root / "bindings.json"
36
42
  self.state_path = self.root / "state.json"
37
43
 
44
+ def _profiles(self) -> list[dict[str, Any]]:
45
+ if self.name not in PROFILEABLE_CONNECTOR_NAMES:
46
+ return []
47
+ return list_connector_profiles(self.name, self.config)
48
+
49
+ def _should_encode_profile_id(self) -> bool:
50
+ return len(self._profiles()) > 1
51
+
52
+ def _conversation_id(self, chat_type: str, chat_id: str, *, profile_id: str | None = None) -> str:
53
+ return format_conversation_id(
54
+ self.name,
55
+ chat_type,
56
+ chat_id,
57
+ profile_id=profile_id if self._should_encode_profile_id() else None,
58
+ )
59
+
60
+ def _profile(self, profile_id: str | None) -> dict[str, Any] | None:
61
+ normalized = str(profile_id or "").strip() or None
62
+ if normalized:
63
+ return find_connector_profile(self.name, self.config, profile_id=normalized)
64
+ profiles = self._profiles()
65
+ if len(profiles) == 1:
66
+ return profiles[0]
67
+ return None
68
+
69
+ def _profile_label(self, profile_id: str | None) -> str | None:
70
+ profile = self._profile(profile_id)
71
+ if profile is None:
72
+ return None
73
+ return connector_profile_label(self.name, profile)
74
+
75
+ def _profile_runtime_state(self, profile_id: str | None = None) -> dict[str, Any]:
76
+ runtime_path = self.root / "runtime.json"
77
+ normalized = str(profile_id or "").strip()
78
+ if normalized:
79
+ runtime_path = self.root / "profiles" / normalized / "runtime.json"
80
+ payload = read_json(runtime_path, {})
81
+ return payload if isinstance(payload, dict) else {}
82
+
38
83
  def send(self, payload: dict[str, Any]) -> dict[str, Any]:
39
84
  formatted = self._format_outbound(payload)
40
85
  record = {"sent_at": utc_now(), **formatted}
@@ -88,18 +133,66 @@ class GenericRelayChannel(BaseChannel):
88
133
  def status(self) -> dict[str, Any]:
89
134
  bindings = self.list_bindings()
90
135
  state = self._read_state()
91
- runtime_state = read_json(self.root / "runtime.json", {})
92
- runtime_last_conversation_id = (
93
- str((runtime_state or {}).get("last_conversation_id") or "").strip() or None
136
+ runtime_state = self._profile_runtime_state()
137
+ profiles = self._profiles()
138
+ profile_states: dict[str, dict[str, Any]] = {}
139
+ for profile in profiles:
140
+ profile_id = str(profile.get("profile_id") or "").strip()
141
+ profile_state = self._profile_runtime_state(profile_id)
142
+ if not profile_state and len(profiles) == 1 and isinstance(runtime_state, dict):
143
+ profile_state = runtime_state
144
+ profile_states[profile_id] = profile_state
145
+ runtime_last_conversation_candidates = [
146
+ str((runtime_state or {}).get("last_conversation_id") or "").strip()
94
147
  if isinstance(runtime_state, dict)
95
- else None
96
- )
148
+ else "",
149
+ *[
150
+ str((profile_state or {}).get("last_conversation_id") or "").strip()
151
+ for profile_state in profile_states.values()
152
+ if isinstance(profile_state, dict)
153
+ ],
154
+ ]
155
+ runtime_last_conversation_id = next((item for item in runtime_last_conversation_candidates if item), None)
97
156
  last_conversation_id = str((state or {}).get("last_conversation_id") or runtime_last_conversation_id or "").strip() or None
98
- transport = infer_connector_transport(self.name, self.config)
157
+ profile_transports = [
158
+ infer_connector_transport(
159
+ self.name,
160
+ merge_connector_profile_config(self.name, self.config, profile),
161
+ )
162
+ for profile in profiles
163
+ ]
164
+ if len(set(item for item in profile_transports if item)) == 1 and profile_transports:
165
+ transport = profile_transports[0]
166
+ elif profile_transports:
167
+ transport = "mixed"
168
+ else:
169
+ transport = infer_connector_transport(self.name, self.config)
99
170
  default_conversation_id = last_conversation_id or (bindings[0]["conversation_id"] if bindings else None)
100
171
  recent_conversations = self._recent_conversations(state)
172
+ known_targets = self._known_targets(state)
101
173
  discovered_targets = merge_discovered_targets(
102
174
  [
175
+ *[
176
+ {
177
+ **(
178
+ build_discovered_target(
179
+ item.get("conversation_id"),
180
+ source=str(item.get("source") or "known_target"),
181
+ is_default=item.get("conversation_id") == default_conversation_id,
182
+ label=str(item.get("label") or "").strip() or None,
183
+ quest_id=str(item.get("quest_id") or "").strip() or None,
184
+ updated_at=str(item.get("updated_at") or "").strip() or None,
185
+ profile_id=str(item.get("profile_id") or "").strip() or None,
186
+ profile_label=str(item.get("profile_label") or "").strip() or self._profile_label(
187
+ str(item.get("profile_id") or "").strip() or None
188
+ ),
189
+ )
190
+ or {}
191
+ ),
192
+ "first_seen_at": str(item.get("first_seen_at") or "").strip() or None,
193
+ }
194
+ for item in known_targets
195
+ ],
103
196
  *[
104
197
  build_discovered_target(
105
198
  item.get("conversation_id"),
@@ -108,15 +201,36 @@ class GenericRelayChannel(BaseChannel):
108
201
  label=str(item.get("label") or "").strip() or None,
109
202
  quest_id=str(item.get("quest_id") or "").strip() or None,
110
203
  updated_at=str(item.get("updated_at") or "").strip() or None,
204
+ profile_id=str(item.get("profile_id") or "").strip() or None,
205
+ profile_label=str(item.get("profile_label") or "").strip() or self._profile_label(
206
+ str(item.get("profile_id") or "").strip() or None
207
+ ),
111
208
  )
112
209
  for item in recent_conversations
113
210
  ],
114
- build_discovered_target(
115
- runtime_last_conversation_id,
116
- source="recent_runtime_activity",
117
- is_default=runtime_last_conversation_id == default_conversation_id,
118
- updated_at=str((runtime_state or {}).get("updated_at") or "").strip() or None,
211
+ *(
212
+ [
213
+ build_discovered_target(
214
+ runtime_last_conversation_id,
215
+ source="recent_runtime_activity",
216
+ is_default=runtime_last_conversation_id == default_conversation_id,
217
+ updated_at=str((runtime_state or {}).get("updated_at") or "").strip() or None,
218
+ )
219
+ ]
220
+ if runtime_last_conversation_id
221
+ else []
119
222
  ),
223
+ *[
224
+ build_discovered_target(
225
+ str((profile_state or {}).get("last_conversation_id") or "").strip() or None,
226
+ source="recent_runtime_activity",
227
+ is_default=str((profile_state or {}).get("last_conversation_id") or "").strip() == default_conversation_id,
228
+ updated_at=str((profile_state or {}).get("updated_at") or "").strip() or None,
229
+ profile_id=profile_id or None,
230
+ profile_label=self._profile_label(profile_id),
231
+ )
232
+ for profile_id, profile_state in profile_states.items()
233
+ ],
120
234
  *[
121
235
  build_discovered_target(
122
236
  item.get("conversation_id"),
@@ -124,54 +238,176 @@ class GenericRelayChannel(BaseChannel):
124
238
  is_default=item.get("conversation_id") == default_conversation_id,
125
239
  quest_id=str(item.get("quest_id") or "").strip() or None,
126
240
  updated_at=str(item.get("updated_at") or "").strip() or None,
241
+ profile_id=str(item.get("profile_id") or "").strip() or None,
242
+ profile_label=str(item.get("profile_label") or "").strip() or self._profile_label(
243
+ str(item.get("profile_id") or "").strip() or None
244
+ ),
127
245
  )
128
246
  for item in bindings
129
247
  ],
130
248
  ]
131
249
  )
132
250
  default_target = next((item for item in discovered_targets if item.get("is_default")), None)
133
- connection_state = self._connection_state(transport, last_conversation_id)
134
- auth_state = self._auth_state(transport)
135
- if (
136
- bool(self.config.get("enabled", False))
137
- and isinstance(runtime_state, dict)
138
- and str(runtime_state.get("transport") or "").strip() == transport
139
- ):
140
- runtime_connection_state = str(runtime_state.get("connection_state") or "").strip()
141
- runtime_auth_state = str(runtime_state.get("auth_state") or "").strip()
142
- if runtime_connection_state:
143
- connection_state = runtime_connection_state
144
- elif runtime_state.get("connected") is True:
145
- connection_state = "connected"
146
- elif runtime_state.get("last_error"):
147
- connection_state = "error"
148
- if runtime_auth_state:
149
- auth_state = runtime_auth_state
150
- elif runtime_state.get("auth_ok") is True:
151
- auth_state = "ready"
152
- elif runtime_state.get("auth_ok") is False:
153
- auth_state = "error"
251
+ def runtime_connection_state(
252
+ *,
253
+ config: dict[str, Any],
254
+ runtime_snapshot: dict[str, Any],
255
+ runtime_transport: str,
256
+ last_seen_conversation_id: str | None,
257
+ ) -> tuple[str, str]:
258
+ connection_state = self._connection_state(runtime_transport, last_seen_conversation_id, config=config)
259
+ auth_state = self._auth_state(runtime_transport, config=config)
260
+ if (
261
+ bool(config.get("enabled", False))
262
+ and isinstance(runtime_snapshot, dict)
263
+ and str(runtime_snapshot.get("transport") or "").strip() == runtime_transport
264
+ ):
265
+ snapshot_connection_state = str(runtime_snapshot.get("connection_state") or "").strip()
266
+ snapshot_auth_state = str(runtime_snapshot.get("auth_state") or "").strip()
267
+ if snapshot_connection_state:
268
+ connection_state = snapshot_connection_state
269
+ elif runtime_snapshot.get("connected") is True:
270
+ connection_state = "connected"
271
+ elif runtime_snapshot.get("last_error"):
272
+ connection_state = "error"
273
+ if snapshot_auth_state:
274
+ auth_state = snapshot_auth_state
275
+ elif runtime_snapshot.get("auth_ok") is True:
276
+ auth_state = "ready"
277
+ elif runtime_snapshot.get("auth_ok") is False:
278
+ auth_state = "error"
279
+ return connection_state, auth_state
280
+
281
+ def matches_profile(item: dict[str, Any], profile_id: str) -> bool:
282
+ item_profile_id = str(item.get("profile_id") or "").strip()
283
+ return item_profile_id == profile_id or (not item_profile_id and len(profiles) == 1)
284
+
285
+ profile_snapshots = []
286
+ for profile in profiles:
287
+ profile_id = str(profile.get("profile_id") or "").strip()
288
+ profile_config = merge_connector_profile_config(self.name, self.config, profile)
289
+ profile_transport = infer_connector_transport(self.name, profile_config)
290
+ profile_runtime_state = profile_states.get(profile_id, {})
291
+ profile_last_conversation_id = str(profile_runtime_state.get("last_conversation_id") or "").strip() or None
292
+ profile_connection_state, profile_auth_state = runtime_connection_state(
293
+ config=profile_config,
294
+ runtime_snapshot=profile_runtime_state,
295
+ runtime_transport=profile_transport,
296
+ last_seen_conversation_id=profile_last_conversation_id,
297
+ )
298
+ profile_targets = [
299
+ dict(item)
300
+ for item in discovered_targets
301
+ if matches_profile(item, profile_id)
302
+ ]
303
+ profile_recent_conversations = [
304
+ dict(item)
305
+ for item in recent_conversations
306
+ if matches_profile(item, profile_id)
307
+ ]
308
+ profile_bindings = [
309
+ dict(item)
310
+ for item in bindings
311
+ if matches_profile(item, profile_id)
312
+ ]
313
+ profile_snapshots.append(
314
+ {
315
+ "profile_id": profile_id,
316
+ "label": connector_profile_label(self.name, profile),
317
+ "bot_name": str(profile.get("bot_name") or "").strip() or None,
318
+ "transport": profile_transport,
319
+ "last_conversation_id": profile_last_conversation_id,
320
+ "connection_state": profile_connection_state,
321
+ "auth_state": profile_auth_state,
322
+ "discovered_targets": profile_targets,
323
+ "recent_conversations": profile_recent_conversations,
324
+ "bindings": profile_bindings,
325
+ "target_count": len(profile_targets),
326
+ "binding_count": len(profile_bindings),
327
+ "last_error": profile_runtime_state.get("last_error") if isinstance(profile_runtime_state, dict) else None,
328
+ }
329
+ )
330
+
331
+ if profile_snapshots:
332
+ aggregate_connection_candidates = [
333
+ str(item.get("connection_state") or "").strip()
334
+ for item in profile_snapshots
335
+ if str(item.get("connection_state") or "").strip()
336
+ ]
337
+ aggregate_auth_candidates = [
338
+ str(item.get("auth_state") or "").strip()
339
+ for item in profile_snapshots
340
+ if str(item.get("auth_state") or "").strip()
341
+ ]
342
+ connection_state = next(
343
+ (
344
+ candidate
345
+ for candidate in (
346
+ "connected",
347
+ "connecting",
348
+ "starting",
349
+ "configured",
350
+ "ready",
351
+ "awaiting_first_message",
352
+ "error",
353
+ "needs_credentials",
354
+ "disabled",
355
+ )
356
+ if candidate in aggregate_connection_candidates
357
+ ),
358
+ "disabled" if not bool(self.config.get("enabled", False)) else "configured",
359
+ )
360
+ auth_state = next(
361
+ (
362
+ candidate
363
+ for candidate in (
364
+ "ready",
365
+ "configured",
366
+ "error",
367
+ "missing_credentials",
368
+ "missing_configuration",
369
+ "disabled",
370
+ )
371
+ if candidate in aggregate_auth_candidates
372
+ ),
373
+ "disabled" if not bool(self.config.get("enabled", False)) else "configured",
374
+ )
375
+ else:
376
+ connection_state, auth_state = runtime_connection_state(
377
+ config=self.config,
378
+ runtime_snapshot=runtime_state,
379
+ runtime_transport=transport,
380
+ last_seen_conversation_id=last_conversation_id,
381
+ )
154
382
  return {
155
383
  "name": self.name,
156
384
  "display_mode": self.display_mode,
157
- "mode": self.config.get("mode", "relay"),
385
+ "mode": self.config.get("mode", transport),
158
386
  "transport": transport,
159
- "relay_url": self.config.get("relay_url"),
160
387
  "enabled": bool(self.config.get("enabled", False)),
161
388
  "connection_state": connection_state,
162
389
  "auth_state": auth_state,
163
390
  "last_conversation_id": last_conversation_id,
164
- "last_error": runtime_state.get("last_error") if isinstance(runtime_state, dict) else None,
391
+ "last_error": next(
392
+ (
393
+ str(item.get("last_error") or "").strip()
394
+ for item in profile_snapshots
395
+ if str(item.get("last_error") or "").strip()
396
+ ),
397
+ str(runtime_state.get("last_error") or "").strip() or None if isinstance(runtime_state, dict) else None,
398
+ ),
165
399
  "inbox_count": len(read_jsonl(self.inbox_path)),
166
400
  "outbox_count": len(read_jsonl(self.outbox_path)),
167
401
  "ignored_count": len(read_jsonl(self.ignored_path)),
168
402
  "binding_count": len(bindings),
169
403
  "bindings": bindings,
404
+ "known_targets": known_targets,
170
405
  "recent_conversations": recent_conversations,
171
406
  "recent_events": self._recent_events(),
172
407
  "target_count": len(discovered_targets),
173
408
  "default_target": default_target,
174
409
  "discovered_targets": discovered_targets,
410
+ "profiles": profile_snapshots,
175
411
  }
176
412
 
177
413
  def ingest(self, payload: dict[str, Any]) -> dict[str, Any]:
@@ -188,6 +424,8 @@ class GenericRelayChannel(BaseChannel):
188
424
  sender_name=str(normalized.get("sender_name") or "").strip() or None,
189
425
  quest_id=str(normalized.get("quest_id") or "").strip() or None,
190
426
  message_id=str(normalized.get("message_id") or "").strip() or None,
427
+ profile_id=str(normalized.get("profile_id") or "").strip() or None,
428
+ profile_label=str(normalized.get("profile_label") or "").strip() or None,
191
429
  )
192
430
  return {"ok": True, "accepted": True, "normalized": normalized}
193
431
 
@@ -201,10 +439,20 @@ class GenericRelayChannel(BaseChannel):
201
439
  if chat_type not in {"group", "direct"}:
202
440
  chat_type = "group" if group_id else "direct"
203
441
  chat_key = group_id if chat_type == "group" else direct_id
204
- conversation_id = str(payload.get("conversation_id") or f"{self.name}:{chat_type}:{chat_key or 'unknown'}")
442
+ parsed_conversation = parse_conversation_id(payload.get("conversation_id"))
443
+ profile_id = str(payload.get("profile_id") or (parsed_conversation or {}).get("profile_id") or "").strip() or None
444
+ profiles = self._profiles()
445
+ if profile_id is None and len(profiles) == 1:
446
+ profile_id = str(profiles[0].get("profile_id") or "").strip() or None
447
+ profile_config = self._profile(profile_id)
448
+ profile_label = self._profile_label(profile_id)
449
+ conversation_id = str(
450
+ payload.get("conversation_id")
451
+ or self._conversation_id(chat_type, chat_key or "unknown", profile_id=profile_id)
452
+ )
205
453
  message_id = str(payload.get("message_id") or payload.get("event_id") or generate_id(self.name))
206
- mentioned = bool(payload.get("mentioned")) or self._looks_like_mention(text)
207
- normalized_text = self._strip_mention_prefix(text)
454
+ mentioned = bool(payload.get("mentioned")) or self._looks_like_mention(text, profile=profile_config)
455
+ normalized_text = self._strip_mention_prefix(text, profile=profile_config)
208
456
  is_command = normalized_text.startswith(self.command_prefix())
209
457
 
210
458
  group_access = self._check_group_access(group_id=group_id, sender_id=sender_id)
@@ -219,6 +467,8 @@ class GenericRelayChannel(BaseChannel):
219
467
  "sender_id": sender_id,
220
468
  "sender_name": sender_name,
221
469
  "group_id": group_id,
470
+ "profile_id": profile_id,
471
+ "profile_label": profile_label,
222
472
  }
223
473
 
224
474
  dm_access = self._check_dm_access(sender_id=sender_id)
@@ -232,6 +482,8 @@ class GenericRelayChannel(BaseChannel):
232
482
  "text": text,
233
483
  "sender_id": sender_id,
234
484
  "sender_name": sender_name,
485
+ "profile_id": profile_id,
486
+ "profile_label": profile_label,
235
487
  }
236
488
 
237
489
  if chat_type == "group" and self.config.get("require_mention_in_groups", True) and not (mentioned or is_command):
@@ -244,6 +496,8 @@ class GenericRelayChannel(BaseChannel):
244
496
  "text": text,
245
497
  "sender_id": sender_id,
246
498
  "sender_name": sender_name,
499
+ "profile_id": profile_id,
500
+ "profile_label": profile_label,
247
501
  }
248
502
 
249
503
  return {
@@ -260,6 +514,8 @@ class GenericRelayChannel(BaseChannel):
260
514
  "direct_id": direct_id or None,
261
515
  "mentioned": mentioned,
262
516
  "is_command": is_command,
517
+ "profile_id": profile_id,
518
+ "profile_label": profile_label,
263
519
  "created_at": utc_now(),
264
520
  "raw_event": payload,
265
521
  }
@@ -267,9 +523,13 @@ class GenericRelayChannel(BaseChannel):
267
523
  def bind_conversation(self, conversation_id: str, quest_id: str) -> dict[str, Any]:
268
524
  bindings = read_json(self.bindings_path, {"bindings": {}})
269
525
  binding_map = dict(bindings.get("bindings") or {})
526
+ parsed = parse_conversation_id(conversation_id)
527
+ profile_id = str((parsed or {}).get("profile_id") or "").strip() or None
270
528
  binding_map[conversation_id] = {
271
529
  "quest_id": quest_id,
272
530
  "updated_at": utc_now(),
531
+ "profile_id": profile_id,
532
+ "profile_label": self._profile_label(profile_id),
273
533
  }
274
534
  bindings["bindings"] = binding_map
275
535
  write_json(self.bindings_path, bindings)
@@ -278,6 +538,8 @@ class GenericRelayChannel(BaseChannel):
278
538
  updated_at=str(binding_map[conversation_id].get("updated_at") or utc_now()),
279
539
  source="quest_binding",
280
540
  quest_id=quest_id,
541
+ profile_id=profile_id,
542
+ profile_label=str(binding_map[conversation_id].get("profile_label") or "").strip() or None,
281
543
  )
282
544
  return binding_map[conversation_id]
283
545
 
@@ -308,20 +570,31 @@ class GenericRelayChannel(BaseChannel):
308
570
  for conversation_id, payload in sorted((bindings.get("bindings") or {}).items()):
309
571
  if not isinstance(payload, dict):
310
572
  continue
311
- items.append({"conversation_id": conversation_id, **payload})
573
+ parsed = parse_conversation_id(conversation_id)
574
+ profile_id = str((parsed or {}).get("profile_id") or payload.get("profile_id") or "").strip() or None
575
+ items.append(
576
+ {
577
+ "conversation_id": conversation_id,
578
+ "profile_id": profile_id,
579
+ "profile_label": str(payload.get("profile_label") or "").strip() or self._profile_label(profile_id),
580
+ **payload,
581
+ }
582
+ )
312
583
  return items
313
584
 
314
585
  def command_prefix(self) -> str:
315
586
  return str(self.config.get("command_prefix") or "/").strip() or "/"
316
587
 
317
- def _looks_like_mention(self, text: str) -> bool:
588
+ def _looks_like_mention(self, text: str, *, profile: dict[str, Any] | None = None) -> bool:
318
589
  lowered = (text or "").lower()
319
- bot_name = str(self.config.get("bot_name") or "DeepScientist").strip().lower()
590
+ profile_config = profile or {}
591
+ bot_name = str(profile_config.get("bot_name") or self.config.get("bot_name") or "DeepScientist").strip().lower()
320
592
  return f"@{bot_name}" in lowered
321
593
 
322
- def _strip_mention_prefix(self, text: str) -> str:
594
+ def _strip_mention_prefix(self, text: str, *, profile: dict[str, Any] | None = None) -> str:
323
595
  cleaned = str(text or "").strip()
324
- bot_name = str(self.config.get("bot_name") or "DeepScientist").strip()
596
+ profile_config = profile or {}
597
+ bot_name = str(profile_config.get("bot_name") or self.config.get("bot_name") or "DeepScientist").strip()
325
598
  prefix = f"@{bot_name}"
326
599
  if cleaned.startswith(prefix):
327
600
  return cleaned[len(prefix):].strip()
@@ -404,34 +677,18 @@ class GenericRelayChannel(BaseChannel):
404
677
  return normalized_sender in allow_from
405
678
 
406
679
  def _deliver(self, record: dict[str, Any]) -> dict[str, Any] | None:
680
+ delivery_config = self.config
681
+ parsed = parse_conversation_id(record.get("conversation_id"))
682
+ profile_id = str((parsed or {}).get("profile_id") or "").strip() or None
683
+ profile = self._profile(profile_id)
684
+ if profile is not None:
685
+ delivery_config = merge_connector_profile_config(self.name, self.config, profile)
407
686
  bridge = get_connector_bridge(self.name)
408
687
  if bridge is not None:
409
- delivery = bridge.deliver(record, self.config)
688
+ delivery = bridge.deliver(record, delivery_config)
410
689
  if delivery is not None:
411
690
  return delivery
412
- relay_url = str(self.config.get("relay_url") or "").strip()
413
- if not relay_url or self.config.get("mode", "relay") != "relay":
414
- return None
415
- body = json.dumps(record, ensure_ascii=False).encode("utf-8")
416
- request = Request(relay_url, data=body, method="POST")
417
- request.add_header("Content-Type", "application/json; charset=utf-8")
418
- token = str(self.config.get("relay_auth_token") or "").strip()
419
- if token:
420
- request.add_header("Authorization", f"Bearer {token}")
421
- try:
422
- with urlopen(request, timeout=5) as response: # noqa: S310
423
- response_text = response.read().decode("utf-8", errors="replace")
424
- return {
425
- "ok": 200 <= response.status < 300,
426
- "status_code": response.status,
427
- "response": response_text[:500],
428
- }
429
- except URLError as exc:
430
- return {
431
- "ok": False,
432
- "status_code": None,
433
- "error": str(exc),
434
- }
691
+ return None
435
692
 
436
693
  def _read_state(self) -> dict[str, Any]:
437
694
  payload = read_json(self.state_path, {})
@@ -478,6 +735,8 @@ class GenericRelayChannel(BaseChannel):
478
735
  sender_name: str | None = None,
479
736
  quest_id: str | None = None,
480
737
  message_id: str | None = None,
738
+ profile_id: str | None = None,
739
+ profile_label: str | None = None,
481
740
  ) -> None:
482
741
  entry = self._build_recent_conversation_entry(
483
742
  conversation_id=conversation_id,
@@ -487,6 +746,8 @@ class GenericRelayChannel(BaseChannel):
487
746
  sender_name=sender_name,
488
747
  quest_id=quest_id,
489
748
  message_id=message_id,
749
+ profile_id=profile_id,
750
+ profile_label=profile_label,
490
751
  )
491
752
  if entry is None:
492
753
  return
@@ -500,6 +761,7 @@ class GenericRelayChannel(BaseChannel):
500
761
  "recent_conversations": [entry, *list(state.get("recent_conversations") or [])],
501
762
  }
502
763
  )
764
+ state["known_targets"] = self._upsert_known_targets(state, entry)
503
765
  self._write_state(state)
504
766
 
505
767
  def _build_recent_conversation_entry(
@@ -512,6 +774,8 @@ class GenericRelayChannel(BaseChannel):
512
774
  sender_name: str | None = None,
513
775
  quest_id: str | None = None,
514
776
  message_id: str | None = None,
777
+ profile_id: str | None = None,
778
+ profile_label: str | None = None,
515
779
  ) -> dict[str, Any] | None:
516
780
  parsed = parse_conversation_id(conversation_id)
517
781
  if parsed is None:
@@ -526,6 +790,12 @@ class GenericRelayChannel(BaseChannel):
526
790
  "updated_at": updated_at,
527
791
  "source": source,
528
792
  }
793
+ resolved_profile_id = str(profile_id or parsed.get("profile_id") or "").strip() or None
794
+ resolved_profile_label = str(profile_label or "").strip() or self._profile_label(resolved_profile_id)
795
+ if resolved_profile_id:
796
+ payload["profile_id"] = resolved_profile_id
797
+ if resolved_profile_label:
798
+ payload["profile_label"] = resolved_profile_label
529
799
  if sender_id:
530
800
  payload["sender_id"] = sender_id
531
801
  if sender_name:
@@ -536,6 +806,69 @@ class GenericRelayChannel(BaseChannel):
536
806
  payload["message_id"] = message_id
537
807
  return payload
538
808
 
809
+ def _known_targets(self, state: dict[str, Any]) -> list[dict[str, Any]]:
810
+ items = state.get("known_targets")
811
+ if not isinstance(items, list):
812
+ return []
813
+ merged: dict[str, dict[str, Any]] = {}
814
+ for raw in items:
815
+ if not isinstance(raw, dict):
816
+ continue
817
+ conversation_id = str(raw.get("conversation_id") or "").strip()
818
+ if not conversation_id:
819
+ continue
820
+ identity = conversation_identity_key(conversation_id)
821
+ current = dict(raw)
822
+ current["conversation_id"] = conversation_id
823
+ existing = merged.get(identity)
824
+ if existing is None:
825
+ merged[identity] = current
826
+ continue
827
+ merged_entry = {**existing, **current}
828
+ merged_entry["first_seen_at"] = (
829
+ str(existing.get("first_seen_at") or "").strip()
830
+ or str(current.get("first_seen_at") or "").strip()
831
+ or str(existing.get("updated_at") or "").strip()
832
+ or str(current.get("updated_at") or "").strip()
833
+ )
834
+ if str(existing.get("updated_at") or "") > str(current.get("updated_at") or ""):
835
+ merged_entry["updated_at"] = existing.get("updated_at")
836
+ merged[identity] = merged_entry
837
+ return sorted(
838
+ merged.values(),
839
+ key=lambda item: (str(item.get("updated_at") or ""), str(item.get("conversation_id") or "")),
840
+ reverse=True,
841
+ )
842
+
843
+ def _upsert_known_targets(self, state: dict[str, Any], entry: dict[str, Any]) -> list[dict[str, Any]]:
844
+ identity = conversation_identity_key(entry.get("conversation_id"))
845
+ items = list(state.get("known_targets") or [])
846
+ next_items: list[dict[str, Any]] = []
847
+ replaced = False
848
+ for raw in items:
849
+ if not isinstance(raw, dict):
850
+ continue
851
+ if conversation_identity_key(raw.get("conversation_id")) != identity:
852
+ next_items.append(dict(raw))
853
+ continue
854
+ merged = {**raw, **entry}
855
+ merged["first_seen_at"] = (
856
+ str(raw.get("first_seen_at") or "").strip()
857
+ or str(entry.get("first_seen_at") or "").strip()
858
+ or str(raw.get("updated_at") or "").strip()
859
+ or str(entry.get("updated_at") or "").strip()
860
+ )
861
+ next_items.append(merged)
862
+ replaced = True
863
+ if not replaced:
864
+ next_items.append(
865
+ {
866
+ **entry,
867
+ "first_seen_at": str(entry.get("updated_at") or "").strip() or utc_now(),
868
+ }
869
+ )
870
+ return self._known_targets({"known_targets": next_items})
871
+
539
872
  @staticmethod
540
873
  def _conversation_label(*, chat_type: str, chat_id: str, sender_name: str | None = None) -> str:
541
874
  if sender_name and str(chat_type or "").strip().lower() == "direct":
@@ -596,44 +929,50 @@ class GenericRelayChannel(BaseChannel):
596
929
  return normalized
597
930
  return f"{normalized[: max(limit - 1, 0)].rstrip()}…"
598
931
 
599
- def _connection_state(self, transport: str, last_conversation_id: str | None) -> str:
600
- if not bool(self.config.get("enabled", False)):
932
+ def _connection_state(self, transport: str, last_conversation_id: str | None, *, config: dict[str, Any] | None = None) -> str:
933
+ payload = config or self.config
934
+ if not bool(payload.get("enabled", False)):
601
935
  return "disabled"
602
- if transport == "relay":
603
- return "relay_configured" if str(self.config.get("relay_url") or "").strip() else "awaiting_relay"
604
- if self._has_runtime_credentials(transport):
936
+ if self._has_runtime_credentials(transport, config=payload):
605
937
  return "ready" if last_conversation_id else "configured"
606
938
  return "needs_credentials"
607
939
 
608
- def _auth_state(self, transport: str) -> str:
609
- if not bool(self.config.get("enabled", False)):
940
+ def _auth_state(self, transport: str, *, config: dict[str, Any] | None = None) -> str:
941
+ payload = config or self.config
942
+ if not bool(payload.get("enabled", False)):
610
943
  return "disabled"
611
- if transport == "relay":
612
- return "ready" if str(self.config.get("relay_url") or "").strip() else "missing_configuration"
613
- return "ready" if self._has_runtime_credentials(transport) else "missing_credentials"
944
+ return "ready" if self._has_runtime_credentials(transport, config=payload) else "missing_credentials"
614
945
 
615
- def _has_runtime_credentials(self, transport: str) -> bool:
946
+ def _has_runtime_credentials(self, transport: str, *, config: dict[str, Any] | None = None) -> bool:
947
+ payload = config or self.config
616
948
  if self.name == "telegram":
617
- return bool(self._secret("bot_token", "bot_token_env"))
949
+ return bool(self._secret("bot_token", "bot_token_env", config=payload))
618
950
  if self.name == "discord":
619
- return bool(self._secret("bot_token", "bot_token_env"))
951
+ return bool(self._secret("bot_token", "bot_token_env", config=payload))
620
952
  if self.name == "slack":
621
953
  if transport == "socket_mode":
622
- return bool(self._secret("bot_token", "bot_token_env") and self._secret("app_token", "app_token_env"))
623
- return bool(self._secret("bot_token", "bot_token_env"))
954
+ return bool(
955
+ self._secret("bot_token", "bot_token_env", config=payload)
956
+ and self._secret("app_token", "app_token_env", config=payload)
957
+ )
958
+ return bool(self._secret("bot_token", "bot_token_env", config=payload))
624
959
  if self.name == "feishu":
625
- return bool(str(self.config.get("app_id") or "").strip() and self._secret("app_secret", "app_secret_env"))
960
+ return bool(str(payload.get("app_id") or "").strip() and self._secret("app_secret", "app_secret_env", config=payload))
626
961
  if self.name == "whatsapp":
627
962
  if transport == "local_session":
628
- return bool(str(self.config.get("session_dir") or "").strip())
629
- return bool(self._secret("access_token", "access_token_env") and str(self.config.get("phone_number_id") or "").strip())
963
+ return bool(str(payload.get("session_dir") or "").strip())
964
+ return bool(
965
+ self._secret("access_token", "access_token_env", config=payload)
966
+ and str(payload.get("phone_number_id") or "").strip()
967
+ )
630
968
  return False
631
969
 
632
- def _secret(self, key: str, env_key: str) -> str:
633
- direct = str(self.config.get(key) or "").strip()
970
+ def _secret(self, key: str, env_key: str, *, config: dict[str, Any] | None = None) -> str:
971
+ payload = config or self.config
972
+ direct = str(payload.get(key) or "").strip()
634
973
  if direct:
635
974
  return direct
636
- env_name = str(self.config.get(env_key) or "").strip()
975
+ env_name = str(payload.get(env_key) or "").strip()
637
976
  if not env_name:
638
977
  return ""
639
978
  return str(os.environ.get(env_name) or "").strip()