@researai/deepscientist 1.5.8 → 1.5.11

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 (148) hide show
  1. package/LICENSE +186 -21
  2. package/README.md +108 -95
  3. package/assets/branding/connector-qq.png +0 -0
  4. package/assets/branding/connector-rokid.png +0 -0
  5. package/assets/branding/connector-weixin.png +0 -0
  6. package/assets/branding/projects.png +0 -0
  7. package/bin/ds.js +172 -13
  8. package/docs/assets/branding/projects.png +0 -0
  9. package/docs/en/00_QUICK_START.md +308 -70
  10. package/docs/en/01_SETTINGS_REFERENCE.md +3 -0
  11. package/docs/en/02_START_RESEARCH_GUIDE.md +112 -0
  12. package/docs/en/04_LINGZHU_CONNECTOR_GUIDE.md +62 -179
  13. package/docs/en/09_DOCTOR.md +41 -5
  14. package/docs/en/10_WEIXIN_CONNECTOR_GUIDE.md +137 -0
  15. package/docs/en/11_LICENSE_AND_RISK.md +256 -0
  16. package/docs/en/12_GUIDED_WORKFLOW_TOUR.md +427 -0
  17. package/docs/en/13_CORE_ARCHITECTURE_GUIDE.md +297 -0
  18. package/docs/en/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +506 -0
  19. package/docs/en/99_ACKNOWLEDGEMENTS.md +4 -1
  20. package/docs/en/README.md +79 -0
  21. package/docs/images/lingzhu/rokid-agent-platform-create.png +0 -0
  22. package/docs/images/weixin/weixin-plugin-entry.png +0 -0
  23. package/docs/images/weixin/weixin-plugin-entry.svg +33 -0
  24. package/docs/images/weixin/weixin-qr-confirm.svg +30 -0
  25. package/docs/images/weixin/weixin-quest-media-flow.svg +44 -0
  26. package/docs/images/weixin/weixin-settings-bind.svg +57 -0
  27. package/docs/zh/00_QUICK_START.md +315 -74
  28. package/docs/zh/01_SETTINGS_REFERENCE.md +3 -0
  29. package/docs/zh/02_START_RESEARCH_GUIDE.md +112 -0
  30. package/docs/zh/04_LINGZHU_CONNECTOR_GUIDE.md +62 -193
  31. package/docs/zh/09_DOCTOR.md +41 -5
  32. package/docs/zh/10_WEIXIN_CONNECTOR_GUIDE.md +144 -0
  33. package/docs/zh/11_LICENSE_AND_RISK.md +256 -0
  34. package/docs/zh/12_GUIDED_WORKFLOW_TOUR.md +423 -0
  35. package/docs/zh/13_CORE_ARCHITECTURE_GUIDE.md +296 -0
  36. package/docs/zh/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +506 -0
  37. package/docs/zh/99_ACKNOWLEDGEMENTS.md +4 -1
  38. package/docs/zh/README.md +126 -0
  39. package/install.sh +0 -34
  40. package/package.json +3 -3
  41. package/pyproject.toml +2 -2
  42. package/src/deepscientist/__init__.py +1 -1
  43. package/src/deepscientist/annotations.py +343 -0
  44. package/src/deepscientist/artifact/arxiv.py +484 -37
  45. package/src/deepscientist/artifact/metrics.py +1 -3
  46. package/src/deepscientist/artifact/service.py +1347 -111
  47. package/src/deepscientist/arxiv_library.py +275 -0
  48. package/src/deepscientist/bash_exec/service.py +9 -0
  49. package/src/deepscientist/bridges/builtins.py +2 -0
  50. package/src/deepscientist/bridges/connectors.py +447 -0
  51. package/src/deepscientist/channels/__init__.py +2 -0
  52. package/src/deepscientist/channels/builtins.py +3 -1
  53. package/src/deepscientist/channels/qq.py +1 -1
  54. package/src/deepscientist/channels/qq_gateway.py +1 -1
  55. package/src/deepscientist/channels/relay.py +7 -1
  56. package/src/deepscientist/channels/weixin.py +59 -0
  57. package/src/deepscientist/channels/weixin_ilink.py +317 -0
  58. package/src/deepscientist/config/models.py +22 -2
  59. package/src/deepscientist/config/service.py +431 -60
  60. package/src/deepscientist/connector/__init__.py +4 -0
  61. package/src/deepscientist/connector/connector_profiles.py +481 -0
  62. package/src/deepscientist/connector/lingzhu_support.py +668 -0
  63. package/src/deepscientist/connector/qq_profiles.py +206 -0
  64. package/src/deepscientist/connector/weixin_support.py +663 -0
  65. package/src/deepscientist/connector_profiles.py +1 -374
  66. package/src/deepscientist/connector_runtime.py +2 -0
  67. package/src/deepscientist/daemon/api/handlers.py +295 -5
  68. package/src/deepscientist/daemon/api/router.py +16 -1
  69. package/src/deepscientist/daemon/app.py +1130 -61
  70. package/src/deepscientist/doctor.py +5 -2
  71. package/src/deepscientist/gitops/diff.py +120 -29
  72. package/src/deepscientist/lingzhu_support.py +1 -182
  73. package/src/deepscientist/mcp/server.py +14 -5
  74. package/src/deepscientist/prompts/builder.py +29 -1
  75. package/src/deepscientist/qq_profiles.py +1 -196
  76. package/src/deepscientist/quest/node_traces.py +152 -2
  77. package/src/deepscientist/quest/service.py +169 -43
  78. package/src/deepscientist/quest/stage_views.py +172 -9
  79. package/src/deepscientist/registries/baseline.py +56 -4
  80. package/src/deepscientist/runners/codex.py +55 -3
  81. package/src/deepscientist/weixin_support.py +1 -0
  82. package/src/prompts/connectors/lingzhu.md +3 -1
  83. package/src/prompts/connectors/weixin.md +230 -0
  84. package/src/prompts/system.md +9 -0
  85. package/src/skills/idea/SKILL.md +16 -0
  86. package/src/skills/idea/references/literature-survey-template.md +24 -0
  87. package/src/skills/idea/references/related-work-playbook.md +4 -0
  88. package/src/skills/idea/references/selection-gate.md +9 -0
  89. package/src/skills/write/SKILL.md +1 -1
  90. package/src/tui/package.json +1 -1
  91. package/src/ui/dist/assets/{AiManusChatView-m2FNtwbn.js → AiManusChatView-D0mTXG4-.js} +156 -48
  92. package/src/ui/dist/assets/{AnalysisPlugin-BMTF8EGL.js → AnalysisPlugin-Db0cTXxm.js} +1 -1
  93. package/src/ui/dist/assets/{CliPlugin-BEOWgxCI.js → CliPlugin-DrV8je02.js} +164 -9
  94. package/src/ui/dist/assets/{CodeEditorPlugin-BCXvjqmb.js → CodeEditorPlugin-QXMSCH71.js} +8 -8
  95. package/src/ui/dist/assets/{CodeViewerPlugin-DaJcy3nD.js → CodeViewerPlugin-7hhtWj_E.js} +5 -5
  96. package/src/ui/dist/assets/{DocViewerPlugin-ByfeIq4K.js → DocViewerPlugin-BWMSnRJe.js} +3 -3
  97. package/src/ui/dist/assets/{GitDiffViewerPlugin-Cksf3VZ-.js → GitDiffViewerPlugin-7J9h9Vy_.js} +20 -21
  98. package/src/ui/dist/assets/{ImageViewerPlugin-CFz-OsTS.js → ImageViewerPlugin-CHJl_0lr.js} +5 -5
  99. package/src/ui/dist/assets/{LabCopilotPanel-CJ1cJzoX.js → LabCopilotPanel-1qSow1es.js} +11 -11
  100. package/src/ui/dist/assets/{LabPlugin-BF3dVJwa.js → LabPlugin-eQpPPCEp.js} +2 -1
  101. package/src/ui/dist/assets/{LatexPlugin-DDkwZ6Sj.js → LatexPlugin-BwRfi89Z.js} +7 -7
  102. package/src/ui/dist/assets/{MarkdownViewerPlugin-HAuvurcT.js → MarkdownViewerPlugin-836PVQWV.js} +4 -4
  103. package/src/ui/dist/assets/{MarketplacePlugin-BtoTYy2C.js → MarketplacePlugin-C2y_556i.js} +3 -3
  104. package/src/ui/dist/assets/{NotebookEditor-CSJYx7b-.js → NotebookEditor-BRzJbGsn.js} +12 -12
  105. package/src/ui/dist/assets/{NotebookEditor-DQgRezm_.js → NotebookEditor-DIX7Mlzu.js} +1 -1
  106. package/src/ui/dist/assets/{PdfLoader-DPa_-fv6.js → PdfLoader-DzRaTAlq.js} +14 -7
  107. package/src/ui/dist/assets/{PdfMarkdownPlugin-BZpXOEjm.js → PdfMarkdownPlugin-DZUfIUnp.js} +73 -6
  108. package/src/ui/dist/assets/{PdfViewerPlugin-BT8a6wGR.js → PdfViewerPlugin-BwtICzue.js} +103 -34
  109. package/src/ui/dist/assets/PdfViewerPlugin-DQ11QcSf.css +3627 -0
  110. package/src/ui/dist/assets/{SearchPlugin-D_blveZi.js → SearchPlugin-DHeIAMsx.js} +1 -1
  111. package/src/ui/dist/assets/{TextViewerPlugin-Btx0M3hX.js → TextViewerPlugin-C3tCmFox.js} +5 -4
  112. package/src/ui/dist/assets/{VNCViewer-DImJO4rO.js → VNCViewer-CQsKVm3t.js} +10 -10
  113. package/src/ui/dist/assets/bot-BEA2vWuK.js +21 -0
  114. package/src/ui/dist/assets/branding/logo-rokid.png +0 -0
  115. package/src/ui/dist/assets/browser-BAcuE0Xj.js +2895 -0
  116. package/src/ui/dist/assets/{code-BUfXGJSl.js → code-XfbSR8K2.js} +1 -1
  117. package/src/ui/dist/assets/{file-content-VqamwI3X.js → file-content-BjxNaIfy.js} +1 -1
  118. package/src/ui/dist/assets/{file-diff-panel-C_wOoS7a.js → file-diff-panel-D_lLVQk0.js} +1 -1
  119. package/src/ui/dist/assets/{file-socket-D2bTuMVP.js → file-socket-D9x_5vlY.js} +1 -1
  120. package/src/ui/dist/assets/{image-BZkGJ4mM.js → image-BhWT33W1.js} +1 -1
  121. package/src/ui/dist/assets/{index-DdRW6RMJ.js → index--c4iXtuy.js} +12 -12
  122. package/src/ui/dist/assets/{index-CxkvSeKw.js → index-BDxipwrC.js} +2 -2
  123. package/src/ui/dist/assets/{index-DjggJovS.js → index-DZTZ8mWP.js} +14934 -9613
  124. package/src/ui/dist/assets/{index-DXZ1daiJ.css → index-Dqj-Mjb4.css} +2 -13
  125. package/src/ui/dist/assets/index-PJbSbPTy.js +25 -0
  126. package/src/ui/dist/assets/{monaco-DHMc7kKM.js → monaco-K8izTGgo.js} +1 -1
  127. package/src/ui/dist/assets/{pdf-effect-queue-DSw_D3RV.js → pdf-effect-queue-DfBors6y.js} +16 -1
  128. package/src/ui/dist/assets/pdf.worker.min-yatZIOMy.mjs +21 -0
  129. package/src/ui/dist/assets/{popover-B85oCgCS.js → popover-yFK1J4fL.js} +1 -1
  130. package/src/ui/dist/assets/{project-sync-DOMCcPac.js → project-sync-PENr2zcz.js} +1 -74
  131. package/src/ui/dist/assets/select-CAbJDfYv.js +1690 -0
  132. package/src/ui/dist/assets/{sigma-BO2rQrl3.js → sigma-DEuYJqTl.js} +1 -1
  133. package/src/ui/dist/assets/{index-D9QIGcmc.js → square-check-big-omoSUmcd.js} +2 -13
  134. package/src/ui/dist/assets/{trash-BsVEH_dV.js → trash--F119N47.js} +1 -1
  135. package/src/ui/dist/assets/{useCliAccess-b8L6JuZm.js → useCliAccess-D31UR23I.js} +1 -1
  136. package/src/ui/dist/assets/{useFileDiffOverlay-BY7uA9hV.js → useFileDiffOverlay-BH6KcMzq.js} +1 -1
  137. package/src/ui/dist/assets/{wrap-text-BwyVuUIK.js → wrap-text-CZ613PM5.js} +1 -1
  138. package/src/ui/dist/assets/{zoom-out-RDpLugQP.js → zoom-out-BgDLAv3z.js} +1 -1
  139. package/src/ui/dist/index.html +2 -2
  140. package/src/ui/dist/assets/AutoFigurePlugin-BGxN8Umr.css +0 -3056
  141. package/src/ui/dist/assets/AutoFigurePlugin-DxPdMUNb.js +0 -8149
  142. package/src/ui/dist/assets/PdfViewerPlugin-BJXtIwj_.css +0 -260
  143. package/src/ui/dist/assets/Stepper-DH2k75Vo.js +0 -158
  144. package/src/ui/dist/assets/bibtex-B-Hqu0Sg.js +0 -189
  145. package/src/ui/dist/assets/file-utils--zJCPN1i.js +0 -109
  146. package/src/ui/dist/assets/message-square-FUIPIhU2.js +0 -16
  147. package/src/ui/dist/assets/pdfjs-DU1YE8WO.js +0 -3
  148. package/src/ui/dist/assets/tooltip-B1OspAkx.js +0 -108
@@ -8,27 +8,33 @@ from pathlib import Path
8
8
  from urllib.error import URLError
9
9
  from urllib.request import Request
10
10
 
11
- from ..connector_profiles import PROFILEABLE_CONNECTOR_NAMES, normalize_connector_config
12
- from ..connector_runtime import infer_connector_transport
11
+ from ..connector.connector_profiles import PROFILEABLE_CONNECTOR_NAMES, list_connector_profiles, normalize_connector_config
12
+ from ..connector_runtime import build_discovered_target, infer_connector_transport
13
13
  from ..home import repo_root
14
- from ..lingzhu_support import (
14
+ from ..connector.lingzhu_support import (
15
+ generate_lingzhu_auth_ak,
16
+ lingzhu_auth_ak_needs_rotation,
15
17
  lingzhu_agent_id,
16
18
  lingzhu_generated_curl,
17
19
  lingzhu_generated_openclaw_config_text,
18
20
  lingzhu_gateway_port,
19
21
  lingzhu_health_url,
22
+ lingzhu_is_passive_conversation_id,
20
23
  lingzhu_local_base_url,
24
+ lingzhu_passive_conversation_id,
21
25
  lingzhu_probe_payload,
22
26
  lingzhu_public_base_url,
23
27
  lingzhu_sse_url,
24
28
  lingzhu_supported_commands,
29
+ public_base_url_looks_public,
25
30
  )
26
- from ..qq_profiles import (
31
+ from ..connector.qq_profiles import (
27
32
  find_qq_profile,
28
33
  list_qq_profiles,
29
34
  normalize_qq_connector_config,
30
35
  qq_profile_label,
31
36
  )
37
+ from ..connector.weixin_support import normalize_weixin_base_url, normalize_weixin_cdn_base_url
32
38
  from ..network import urlopen_with_proxy as urlopen
33
39
  from ..runners.runtime_overrides import apply_codex_runtime_overrides, apply_runners_runtime_overrides
34
40
  from ..shared import read_json, read_text, read_yaml, resolve_runner_binary, run_command, sha256_text, utc_now, which, write_text, write_yaml
@@ -104,7 +110,7 @@ class ConfigManager:
104
110
  connectors = config.get("connectors") if isinstance(config.get("connectors"), dict) else {}
105
111
  system_enabled = connectors.get("system_enabled") if isinstance(connectors.get("system_enabled"), dict) else {}
106
112
  return {
107
- name: self._coerce_bool(system_enabled.get(name), default=name == "qq")
113
+ name: self._coerce_bool(system_enabled.get(name), default=name in {"qq", "weixin"})
108
114
  for name in SYSTEM_CONNECTOR_NAMES
109
115
  }
110
116
 
@@ -118,6 +124,8 @@ class ConfigManager:
118
124
  return False
119
125
  if normalized == "local":
120
126
  return True
127
+ if normalized == "lingzhu":
128
+ return True
121
129
  gates = self.system_connector_gates()
122
130
  if normalized in gates:
123
131
  return gates[normalized]
@@ -163,7 +171,25 @@ class ConfigManager:
163
171
  return self.validate_named_text(name, self.render_named_payload(name, payload))
164
172
 
165
173
  def save_named_payload(self, name: str, payload: dict) -> dict:
166
- return self.save_named_text(name, self.render_named_payload(name, payload))
174
+ prepared = self._prepare_payload_for_save(name, payload)
175
+ return self.save_named_text(name, self.render_named_payload(name, prepared))
176
+
177
+ def _prepare_payload_for_save(self, name: str, payload: dict) -> dict:
178
+ prepared = deepcopy(payload) if isinstance(payload, dict) else {}
179
+ if name != "connectors":
180
+ return prepared
181
+ lingzhu = prepared.get("lingzhu")
182
+ if not isinstance(lingzhu, dict):
183
+ return prepared
184
+ enabled = self._coerce_bool(lingzhu.get("enabled"), default=False)
185
+ raw_public_base_url = str(lingzhu.get("public_base_url") or "").strip()
186
+ direct_auth_ak = str(lingzhu.get("auth_ak") or "").strip()
187
+ if lingzhu_auth_ak_needs_rotation(direct_auth_ak):
188
+ lingzhu["auth_ak"] = generate_lingzhu_auth_ak()
189
+ elif (enabled or raw_public_base_url) and not self._has_secret(lingzhu, "auth_ak", "auth_ak_env"):
190
+ lingzhu["auth_ak"] = generate_lingzhu_auth_ak()
191
+ prepared["lingzhu"] = lingzhu
192
+ return prepared
167
193
 
168
194
  def bind_qq_main_chat(self, *, profile_id: str | None = None, chat_id: str) -> dict:
169
195
  normalized_chat_id = str(chat_id or "").strip()
@@ -283,7 +309,7 @@ This page edits `~/DeepScientist/config/connectors.yaml` directly.
283
309
 
284
310
  ## What this page is for
285
311
 
286
- - connect Telegram, Discord, Slack, Feishu, WhatsApp, or QQ
312
+ - connect Weixin, Telegram, Discord, Slack, Feishu, WhatsApp, or QQ
287
313
  - choose one preferred connector for proactive artifact updates
288
314
  - decide whether artifact updates fan out or stay focused
289
315
  - use the built-in direct runtime for each connector
@@ -291,7 +317,7 @@ This page edits `~/DeepScientist/config/connectors.yaml` directly.
291
317
 
292
318
  ## Recommended order
293
319
 
294
- 1. enable only one connector first
320
+ 1. configure one connector first
295
321
  2. fill the required token or secret fields
296
322
  3. click **Validate**
297
323
  4. click **Test**
@@ -299,6 +325,7 @@ This page edits `~/DeepScientist/config/connectors.yaml` directly.
299
325
 
300
326
  ## Preferred transports
301
327
 
328
+ - Weixin: `ilink_long_poll`
302
329
  - Telegram: `polling`
303
330
  - Slack: `socket_mode`
304
331
  - Discord: `gateway`
@@ -321,6 +348,13 @@ This page edits `~/DeepScientist/config/connectors.yaml` directly.
321
348
  - prefer `transport: socket_mode`
322
349
  - readiness test uses `auth.test`
323
350
 
351
+ ### Weixin
352
+
353
+ - scan the QR code first so DeepScientist can persist `bot_token` and `account_id`
354
+ - keep `transport: ilink_long_poll`
355
+ - every reply depends on the latest inbound `context_token`
356
+ - media send uses the built-in AES + CDN upload path for image, video, and file attachments
357
+
324
358
  ### Discord
325
359
 
326
360
  - set `bot_token`
@@ -354,12 +388,12 @@ This page edits `~/DeepScientist/config/connectors.yaml` directly.
354
388
 
355
389
  ### Lingzhu
356
390
 
357
- - Lingzhu is configured as an OpenClaw companion endpoint, not as a full DeepScientist chat bridge
391
+ - Lingzhu is hosted directly by DeepScientist on `/metis/agent/api`
358
392
  - keep `transport: openclaw_sse`
359
- - set `gateway_port` to the OpenClaw HTTP gateway port, usually `18789`
360
- - generate and save `auth_ak`, then fill the same Bearer token into the Lingzhu platform
361
- - set `public_base_url` to a public IP or public domain before binding a real Rokid device
362
- - readiness test first probes `GET /metis/agent/api/health`, then runs a minimal SSE smoke request against `POST /metis/agent/api/sse`
393
+ - use the same public DeepScientist origin and port that the browser is already serving on
394
+ - save once so DeepScientist can persist `auth_ak`; Rokid must use the same Bearer token
395
+ - `public_base_url` must be a public IP or public domain; loopback and private addresses are invalid for Rokid
396
+ - new Lingzhu tasks must start with `我现在的任务是`; other requests are treated as reconnect or progress polling
363
397
 
364
398
  ## Safety
365
399
 
@@ -680,9 +714,10 @@ Use **Test** when the file exposes runtime dependencies.
680
714
  continue
681
715
  config = raw_config
682
716
  enabled = bool(config.get("enabled", False))
683
- if not enabled:
717
+ if not self._should_validate_connector(str(name), config):
684
718
  continue
685
- enabled_connectors.append(str(name))
719
+ if enabled:
720
+ enabled_connectors.append(str(name))
686
721
 
687
722
  if name == "qq":
688
723
  profiles = list_qq_profiles(config)
@@ -746,6 +781,15 @@ Use **Test** when the file exposes runtime dependencies.
746
781
  errors.append("slack: `transport: socket_mode` requires `bot_token` or `bot_token_env`.")
747
782
  if not has_app_token:
748
783
  errors.append("slack: `transport: socket_mode` requires `app_token` or `app_token_env`.")
784
+ elif name == "weixin":
785
+ if transport != "ilink_long_poll":
786
+ errors.append("weixin: `transport` must stay `ilink_long_poll`.")
787
+ if not self._has_secret(config, "bot_token", "bot_token_env"):
788
+ errors.append("weixin: requires `bot_token` or `bot_token_env` after QR login.")
789
+ if not str(config.get("account_id") or "").strip():
790
+ errors.append("weixin: requires `account_id` after QR login.")
791
+ if not str(config.get("login_user_id") or "").strip():
792
+ warnings.append("weixin: `login_user_id` is empty. Save the scanner user id after QR login for easier diagnostics.")
749
793
  elif name == "feishu":
750
794
  has_app_id = bool(str(config.get("app_id") or "").strip())
751
795
  has_app_secret = self._has_secret(config, "app_secret", "app_secret_env")
@@ -767,6 +811,8 @@ Use **Test** when the file exposes runtime dependencies.
767
811
  warnings.append("lingzhu: `local_host` is empty; DeepScientist will fall back to `127.0.0.1`.")
768
812
  if not self._has_secret(config, "auth_ak", "auth_ak_env"):
769
813
  errors.append("lingzhu: requires `auth_ak` for Bearer authentication.")
814
+ elif lingzhu_auth_ak_needs_rotation(self._secret(config, "auth_ak", "auth_ak_env")):
815
+ errors.append("lingzhu: `auth_ak` is still using the bundled example token; generate a new random AK before binding Rokid.")
770
816
  raw_gateway_port = str(config.get("gateway_port") or "").strip()
771
817
  normalized_port = lingzhu_gateway_port(config)
772
818
  if raw_gateway_port and str(normalized_port) != raw_gateway_port:
@@ -775,6 +821,10 @@ Use **Test** when the file exposes runtime dependencies.
775
821
  public_base_url = lingzhu_public_base_url(config)
776
822
  if raw_public_base_url and public_base_url is None:
777
823
  errors.append("lingzhu: `public_base_url` must be a valid `http://` or `https://` URL when set.")
824
+ elif raw_public_base_url and not public_base_url_looks_public(raw_public_base_url):
825
+ errors.append(
826
+ "lingzhu: `public_base_url` must be a public IP or public domain. `127.0.0.1`, `localhost`, and private network addresses cannot be registered on Rokid."
827
+ )
778
828
  raw_visible_progress_heartbeat_sec = str(
779
829
  config.get("visible_progress_heartbeat_sec") or ""
780
830
  ).strip()
@@ -817,14 +867,14 @@ Use **Test** when the file exposes runtime dependencies.
817
867
  if not isinstance(raw_config, dict):
818
868
  continue
819
869
  config = raw_config
820
- if not config.get("enabled", False):
870
+ if not self._should_validate_connector(str(name), config):
821
871
  continue
822
872
  target = (delivery_targets or {}).get(name)
823
873
  items.append(self._test_single_connector(name, config, live=live, delivery_target=target if isinstance(target, dict) else None))
824
874
  return {
825
875
  "ok": all(item["ok"] for item in items) if items else True,
826
876
  "name": "connectors",
827
- "summary": "Connector test completed." if items else "No enabled connectors to test.",
877
+ "summary": "Connector test completed." if items else "No configured connectors to test.",
828
878
  "warnings": [],
829
879
  "errors": [],
830
880
  "items": items,
@@ -963,6 +1013,25 @@ Use **Test** when the file exposes runtime dependencies.
963
1013
  details["identity"] = profile_results[0].get("app_id")
964
1014
  details["gateway_url"] = profile_results[0].get("gateway_url")
965
1015
  details["token_expires_in"] = profile_results[0].get("token_expires_in")
1016
+ elif name == "weixin":
1017
+ details.update(
1018
+ {
1019
+ "base_url": normalize_weixin_base_url(config.get("base_url")),
1020
+ "cdn_base_url": normalize_weixin_cdn_base_url(config.get("cdn_base_url")),
1021
+ "account_id": str(config.get("account_id") or "").strip() or None,
1022
+ "login_user_id": str(config.get("login_user_id") or "").strip() or None,
1023
+ }
1024
+ )
1025
+ if transport != "ilink_long_poll":
1026
+ errors.append("Weixin transport must stay `ilink_long_poll`.")
1027
+ if not self._has_secret(config, "bot_token", "bot_token_env"):
1028
+ errors.append("Weixin requires `bot_token` after QR login.")
1029
+ if not str(config.get("account_id") or "").strip():
1030
+ errors.append("Weixin requires `account_id` after QR login.")
1031
+ if live and not errors:
1032
+ warnings.append(
1033
+ "Weixin readiness is credential-based. Send one inbound Weixin message to populate `context_token` before testing outbound delivery."
1034
+ )
966
1035
  elif name == "lingzhu":
967
1036
  details.update(self._lingzhu_snapshot_details(config))
968
1037
  auth_ak = self._secret(config, "auth_ak", "auth_ak_env")
@@ -1070,11 +1139,85 @@ Use **Test** when the file exposes runtime dependencies.
1070
1139
  "If you received it, the connector binding and outbound delivery path are working."
1071
1140
  )
1072
1141
 
1142
+ @staticmethod
1143
+ def _codex_should_inherit_model(model: object) -> bool:
1144
+ normalized = str(model or "").strip().lower()
1145
+ return normalized in {"", "inherit", "default", "codex-default"}
1146
+
1147
+ @staticmethod
1148
+ def _codex_requested_model(config: dict) -> str:
1149
+ raw_model = config.get("model")
1150
+ if raw_model is None:
1151
+ return "gpt-5.4"
1152
+ return str(raw_model).strip()
1153
+
1154
+ @staticmethod
1155
+ def _codex_model_unavailable(stdout_text: str, stderr_text: str) -> bool:
1156
+ haystack = f"{stdout_text}\n{stderr_text}".lower()
1157
+ markers = [
1158
+ "unknown model",
1159
+ "invalid model",
1160
+ "model not found",
1161
+ "unsupported model",
1162
+ "model is not available",
1163
+ "not authorized to use model",
1164
+ "you do not have access",
1165
+ "access to model",
1166
+ "model access",
1167
+ "unrecognized model",
1168
+ ]
1169
+ return any(marker in haystack for marker in markers)
1170
+
1171
+ def _build_codex_probe_command(
1172
+ self,
1173
+ *,
1174
+ resolved_binary: str,
1175
+ requested_model: str,
1176
+ approval_policy: str,
1177
+ reasoning_effort: str | None,
1178
+ sandbox_mode: str,
1179
+ ) -> list[str]:
1180
+ command = [
1181
+ resolved_binary,
1182
+ "--search",
1183
+ "exec",
1184
+ "--json",
1185
+ "--cd",
1186
+ str(repo_root()),
1187
+ "--skip-git-repo-check",
1188
+ ]
1189
+ if not self._codex_should_inherit_model(requested_model):
1190
+ command.extend(["--model", requested_model])
1191
+ if approval_policy:
1192
+ command.extend(["-c", f'approval_policy="{approval_policy}"'])
1193
+ if reasoning_effort:
1194
+ command.extend(["-c", f'model_reasoning_effort="{reasoning_effort}"'])
1195
+ if sandbox_mode:
1196
+ command.extend(["--sandbox", sandbox_mode])
1197
+ command.append("-")
1198
+ return command
1199
+
1200
+ def _persist_codex_model_inherit(self, requested_model: object) -> None:
1201
+ normalized_requested_model = str(requested_model or "").strip()
1202
+ if not normalized_requested_model:
1203
+ return
1204
+ runners = self.load_named("runners")
1205
+ codex = runners.get("codex") if isinstance(runners.get("codex"), dict) else None
1206
+ if not isinstance(codex, dict):
1207
+ return
1208
+ current_model = str(codex.get("model") or "").strip()
1209
+ if current_model != normalized_requested_model:
1210
+ return
1211
+ codex["model"] = "inherit"
1212
+ runners["codex"] = codex
1213
+ self.save_named_payload("runners", runners)
1214
+
1073
1215
  def _probe_codex_runner(self, config: dict) -> dict:
1074
1216
  config = apply_codex_runtime_overrides(config)
1075
1217
  checked_at = utc_now()
1076
1218
  binary = str(config.get("binary") or "codex").strip() or "codex"
1077
1219
  resolved_binary = resolve_runner_binary(binary, runner_name="codex")
1220
+ requested_model = self._codex_requested_model(config)
1078
1221
  raw_reasoning_effort = config.get("model_reasoning_effort")
1079
1222
  reasoning_effort = (
1080
1223
  str(raw_reasoning_effort).strip()
@@ -1085,10 +1228,14 @@ Use **Test** when the file exposes runtime dependencies.
1085
1228
  "binary": binary,
1086
1229
  "resolved_binary": resolved_binary,
1087
1230
  "config_dir": str(config.get("config_dir") or "~/.codex"),
1088
- "model": str(config.get("model") or "gpt-5.4"),
1231
+ "model": requested_model or "inherit",
1232
+ "requested_model": requested_model or "inherit",
1233
+ "effective_model": requested_model or "inherit",
1089
1234
  "approval_policy": str(config.get("approval_policy") or "on-request"),
1090
1235
  "sandbox_mode": str(config.get("sandbox_mode") or "workspace-write"),
1091
1236
  "reasoning_effort": reasoning_effort,
1237
+ "model_fallback_attempted": False,
1238
+ "model_fallback_used": False,
1092
1239
  "checked_at": checked_at,
1093
1240
  }
1094
1241
  if not resolved_binary:
@@ -1103,30 +1250,14 @@ Use **Test** when the file exposes runtime dependencies.
1103
1250
  "details": details,
1104
1251
  "guidance": [
1105
1252
  "Run `npm install -g @researai/deepscientist` again so the bundled Codex dependency is installed.",
1253
+ "If `codex` is still missing, install it explicitly with `npm install -g @openai/codex`.",
1254
+ "Run `codex --login` (or `codex`) once and finish authentication before starting DeepScientist.",
1106
1255
  "If you use a custom Codex path, set `runners.codex.binary` to that absolute executable path.",
1107
1256
  ],
1108
1257
  }
1109
1258
 
1110
- command = [
1111
- resolved_binary,
1112
- "--search",
1113
- "exec",
1114
- "--json",
1115
- "--cd",
1116
- str(repo_root()),
1117
- "--skip-git-repo-check",
1118
- "--model",
1119
- str(config.get("model") or "gpt-5.4"),
1120
- ]
1121
1259
  approval_policy = str(config.get("approval_policy") or "on-request").strip()
1122
- if approval_policy:
1123
- command.extend(["-c", f'approval_policy="{approval_policy}"'])
1124
- if reasoning_effort:
1125
- command.extend(["-c", f'model_reasoning_effort="{reasoning_effort}"'])
1126
1260
  sandbox_mode = str(config.get("sandbox_mode") or "workspace-write").strip()
1127
- if sandbox_mode:
1128
- command.extend(["--sandbox", sandbox_mode])
1129
- command.append("-")
1130
1261
 
1131
1262
  env = os.environ.copy()
1132
1263
  config_dir = str(config.get("config_dir") or "~/.codex").strip()
@@ -1134,23 +1265,36 @@ Use **Test** when the file exposes runtime dependencies.
1134
1265
  env["CODEX_HOME"] = str(Path(config_dir).expanduser())
1135
1266
  prompt = "Reply with exactly HELLO."
1136
1267
 
1137
- try:
1138
- result = subprocess.run(
1139
- command,
1140
- input=prompt,
1141
- cwd=str(repo_root()),
1142
- env=env,
1143
- text=True,
1144
- capture_output=True,
1145
- timeout=90,
1146
- check=False,
1268
+ def run_probe_once(model_for_command: str) -> tuple[list[str], subprocess.CompletedProcess[str] | None, subprocess.TimeoutExpired | None]:
1269
+ command = self._build_codex_probe_command(
1270
+ resolved_binary=resolved_binary,
1271
+ requested_model=model_for_command,
1272
+ approval_policy=approval_policy,
1273
+ reasoning_effort=reasoning_effort,
1274
+ sandbox_mode=sandbox_mode,
1147
1275
  )
1148
- except subprocess.TimeoutExpired as exc:
1276
+ try:
1277
+ result = subprocess.run(
1278
+ command,
1279
+ input=prompt,
1280
+ cwd=str(repo_root()),
1281
+ env=env,
1282
+ text=True,
1283
+ capture_output=True,
1284
+ timeout=90,
1285
+ check=False,
1286
+ )
1287
+ except subprocess.TimeoutExpired as exc:
1288
+ return command, None, exc
1289
+ return command, result, None
1290
+
1291
+ command, result, timeout_error = run_probe_once(requested_model)
1292
+ if timeout_error is not None:
1149
1293
  details.update(
1150
1294
  {
1151
1295
  "exit_code": None,
1152
- "stdout_excerpt": self._compact_probe_text(exc.stdout or ""),
1153
- "stderr_excerpt": self._compact_probe_text(exc.stderr or ""),
1296
+ "stdout_excerpt": self._compact_probe_text(timeout_error.stdout or ""),
1297
+ "stderr_excerpt": self._compact_probe_text(timeout_error.stderr or ""),
1154
1298
  "probe_command": command,
1155
1299
  }
1156
1300
  )
@@ -1160,19 +1304,73 @@ Use **Test** when the file exposes runtime dependencies.
1160
1304
  "warnings": [],
1161
1305
  "errors": [
1162
1306
  "Codex did not answer the startup hello probe within 90 seconds.",
1163
- "Run `codex` manually, complete login, then retry DeepScientist.",
1307
+ "Run `codex --login` (or `codex`) manually, complete login, then retry DeepScientist.",
1164
1308
  ],
1165
1309
  "details": details,
1166
1310
  "guidance": [
1167
- "Run `codex` manually to finish interactive login or first-run setup.",
1168
- "Confirm the configured model is available to your account.",
1311
+ "Run `codex --login` (or `codex`) manually to finish interactive login or first-run setup.",
1312
+ "If `codex` is missing on PATH, install it explicitly with `npm install -g @openai/codex`.",
1313
+ "Confirm the configured model is available to your account. DeepScientist currently probes Codex with the configured runner model.",
1314
+ "Run `ds doctor` after login, then start DeepScientist again.",
1169
1315
  ],
1170
1316
  }
1171
1317
 
1318
+ assert result is not None
1172
1319
  stdout_text = (result.stdout or "").strip()
1173
1320
  stderr_text = (result.stderr or "").strip()
1174
1321
  hello_seen = "HELLO" in stdout_text.upper()
1175
1322
  ok = result.returncode == 0 and hello_seen
1323
+ fallback_warning: str | None = None
1324
+ if (
1325
+ not ok
1326
+ and not self._codex_should_inherit_model(requested_model)
1327
+ and self._codex_model_unavailable(stdout_text, stderr_text)
1328
+ ):
1329
+ details["model_fallback_attempted"] = True
1330
+ fallback_command, fallback_result, fallback_timeout = run_probe_once("inherit")
1331
+ details["initial_probe_command"] = command
1332
+ details["initial_exit_code"] = result.returncode
1333
+ details["initial_stdout_excerpt"] = self._compact_probe_text(stdout_text)
1334
+ details["initial_stderr_excerpt"] = self._compact_probe_text(stderr_text)
1335
+ details["fallback_probe_command"] = fallback_command
1336
+ if fallback_timeout is None and fallback_result is not None:
1337
+ fallback_stdout_text = (fallback_result.stdout or "").strip()
1338
+ fallback_stderr_text = (fallback_result.stderr or "").strip()
1339
+ fallback_hello_seen = "HELLO" in fallback_stdout_text.upper()
1340
+ fallback_ok = fallback_result.returncode == 0 and fallback_hello_seen
1341
+ details["fallback_exit_code"] = fallback_result.returncode
1342
+ details["fallback_stdout_excerpt"] = self._compact_probe_text(fallback_stdout_text)
1343
+ details["fallback_stderr_excerpt"] = self._compact_probe_text(fallback_stderr_text)
1344
+ if fallback_ok:
1345
+ details.update(
1346
+ {
1347
+ "exit_code": fallback_result.returncode,
1348
+ "stdout_excerpt": self._compact_probe_text(fallback_stdout_text),
1349
+ "stderr_excerpt": self._compact_probe_text(fallback_stderr_text),
1350
+ "probe_command": fallback_command,
1351
+ "effective_model": "inherit",
1352
+ "model_fallback_used": True,
1353
+ }
1354
+ )
1355
+ fallback_warning = (
1356
+ f"Configured Codex model `{requested_model}` is not available. "
1357
+ "DeepScientist fell back to the current Codex default model."
1358
+ )
1359
+ return {
1360
+ "ok": True,
1361
+ "summary": "Codex startup probe completed with Codex default model fallback.",
1362
+ "warnings": [fallback_warning],
1363
+ "errors": [],
1364
+ "details": details,
1365
+ "guidance": [
1366
+ "DeepScientist switched the Codex runner model to `inherit` so future runs keep using the current Codex default model.",
1367
+ ],
1368
+ }
1369
+ else:
1370
+ details["fallback_exit_code"] = None
1371
+ details["fallback_stdout_excerpt"] = self._compact_probe_text((fallback_timeout.stdout if fallback_timeout else "") or "")
1372
+ details["fallback_stderr_excerpt"] = self._compact_probe_text((fallback_timeout.stderr if fallback_timeout else "") or "")
1373
+
1176
1374
  details.update(
1177
1375
  {
1178
1376
  "exit_code": result.returncode,
@@ -1189,7 +1387,9 @@ Use **Test** when the file exposes runtime dependencies.
1189
1387
  errors.append("Codex responded, but the reply did not contain the expected `HELLO` marker.")
1190
1388
  if stderr_text:
1191
1389
  warnings.append("Codex returned stderr during the startup probe.")
1192
- errors.append("Run `codex` once and complete login before starting DeepScientist.")
1390
+ if details.get("model_fallback_attempted") and not details.get("model_fallback_used"):
1391
+ warnings.append("DeepScientist also tried the current Codex default model, but that fallback probe did not succeed.")
1392
+ errors.append("Run `codex --login` (or `codex`) once and complete login before starting DeepScientist.")
1193
1393
  return {
1194
1394
  "ok": ok,
1195
1395
  "summary": "Codex startup probe completed." if ok else "Codex startup probe failed.",
@@ -1197,8 +1397,10 @@ Use **Test** when the file exposes runtime dependencies.
1197
1397
  "errors": errors,
1198
1398
  "details": details,
1199
1399
  "guidance": [] if ok else [
1200
- "Run `codex` in a terminal and complete login or first-run setup.",
1201
- "Then start DeepScientist again.",
1400
+ "Run `codex --login` (or `codex`) in a terminal and complete login or first-run setup.",
1401
+ "If `codex` is missing, install it explicitly with `npm install -g @openai/codex`.",
1402
+ "If the configured model is not available to your Codex account, update `~/DeepScientist/config/runners.yaml` and try again.",
1403
+ "Then run `ds doctor` and start DeepScientist again.",
1202
1404
  ],
1203
1405
  }
1204
1406
 
@@ -1217,6 +1419,10 @@ Use **Test** when the file exposes runtime dependencies.
1217
1419
  "binary": details.get("binary"),
1218
1420
  "resolved_binary": details.get("resolved_binary"),
1219
1421
  "model": details.get("model"),
1422
+ "requested_model": details.get("requested_model"),
1423
+ "effective_model": details.get("effective_model"),
1424
+ "model_fallback_attempted": bool(details.get("model_fallback_attempted")),
1425
+ "model_fallback_used": bool(details.get("model_fallback_used")),
1220
1426
  "approval_policy": details.get("approval_policy"),
1221
1427
  "sandbox_mode": details.get("sandbox_mode"),
1222
1428
  "reasoning_effort": details.get("reasoning_effort"),
@@ -1226,6 +1432,8 @@ Use **Test** when the file exposes runtime dependencies.
1226
1432
  }
1227
1433
  config["bootstrap"] = bootstrap
1228
1434
  self.save_named_payload("config", config)
1435
+ if bool(result.get("ok")) and bool(details.get("model_fallback_used")):
1436
+ self._persist_codex_model_inherit(details.get("requested_model"))
1229
1437
 
1230
1438
  @staticmethod
1231
1439
  def _compact_probe_text(value: str, *, limit: int = 1200) -> str:
@@ -1236,6 +1444,41 @@ Use **Test** when the file exposes runtime dependencies.
1236
1444
 
1237
1445
  def lingzhu_snapshot(self, config: dict | None = None) -> dict:
1238
1446
  resolved = dict(config or self.load_named_normalized("connectors").get("lingzhu") or {})
1447
+ state = self._lingzhu_runtime_state()
1448
+ raw_bindings = self._lingzhu_bindings()
1449
+ last_real_conversation_id = str(state.get("last_conversation_id") or "").strip() or None
1450
+ has_auth_ak = bool(self._secret(resolved, "auth_ak", "auth_ak_env"))
1451
+ passive_conversation_id = (
1452
+ lingzhu_passive_conversation_id(resolved)
1453
+ if bool(resolved.get("enabled", False)) and has_auth_ak
1454
+ else None
1455
+ )
1456
+ effective_binding = (
1457
+ self._lingzhu_effective_binding(raw_bindings, passive_conversation_id)
1458
+ if passive_conversation_id
1459
+ else None
1460
+ )
1461
+ bindings = [effective_binding] if isinstance(effective_binding, dict) and effective_binding.get("quest_id") else []
1462
+ default_target = (
1463
+ {
1464
+ **(
1465
+ build_discovered_target(
1466
+ passive_conversation_id,
1467
+ source="passive_binding",
1468
+ is_default=True,
1469
+ label="Passive binding",
1470
+ quest_id=str((effective_binding or {}).get("quest_id") or "").strip() or None,
1471
+ updated_at=str((effective_binding or {}).get("updated_at") or "").strip() or None,
1472
+ )
1473
+ or {}
1474
+ ),
1475
+ "selectable": True,
1476
+ "is_passive": True,
1477
+ }
1478
+ if passive_conversation_id
1479
+ else None
1480
+ )
1481
+ discovered_targets = [default_target] if isinstance(default_target, dict) and default_target else []
1239
1482
  snapshot: dict[str, object] = {
1240
1483
  "name": "lingzhu",
1241
1484
  "display_mode": "companion_config",
@@ -1243,22 +1486,27 @@ Use **Test** when the file exposes runtime dependencies.
1243
1486
  "transport": "openclaw_sse",
1244
1487
  "enabled": bool(resolved.get("enabled", False)),
1245
1488
  "main_chat_id": None,
1246
- "last_conversation_id": None,
1489
+ "last_conversation_id": passive_conversation_id,
1247
1490
  "inbox_count": 0,
1248
1491
  "outbox_count": 0,
1249
1492
  "ignored_count": 0,
1250
- "binding_count": 0,
1251
- "target_count": 0,
1493
+ "binding_count": len(bindings),
1494
+ "bindings": bindings,
1495
+ "target_count": len(discovered_targets),
1252
1496
  "recent_conversations": [],
1253
1497
  "recent_events": [],
1254
- "discovered_targets": [],
1498
+ "known_targets": [],
1499
+ "discovered_targets": discovered_targets,
1500
+ "default_target": default_target,
1255
1501
  "details": self._lingzhu_snapshot_details(resolved),
1256
1502
  }
1503
+ snapshot["details"]["last_real_conversation_id"] = last_real_conversation_id
1504
+ snapshot["details"]["historical_target_count"] = len(self._lingzhu_recent_conversations(state))
1257
1505
  if not snapshot["enabled"]:
1258
1506
  snapshot["connection_state"] = "disabled"
1259
1507
  snapshot["auth_state"] = "disabled"
1260
1508
  return snapshot
1261
- snapshot["auth_state"] = "ready" if self._secret(resolved, "auth_ak", "auth_ak_env") else "missing_auth_ak"
1509
+ snapshot["auth_state"] = "ready" if has_auth_ak else "missing_auth_ak"
1262
1510
  health_probe = self._probe_lingzhu_health(resolved, timeout=1.5)
1263
1511
  snapshot["details"]["health_probe"] = health_probe
1264
1512
  if health_probe.get("ok", False):
@@ -1269,6 +1517,80 @@ Use **Test** when the file exposes runtime dependencies.
1269
1517
  snapshot["last_error"] = health_probe.get("message")
1270
1518
  return snapshot
1271
1519
 
1520
+ def _lingzhu_runtime_state(self) -> dict[str, object]:
1521
+ payload = read_json(self.home / "logs" / "connectors" / "lingzhu" / "state.json", {})
1522
+ return payload if isinstance(payload, dict) else {}
1523
+
1524
+ def _lingzhu_bindings(self) -> list[dict[str, object]]:
1525
+ payload = read_json(self.home / "logs" / "connectors" / "lingzhu" / "bindings.json", {"bindings": {}})
1526
+ raw_bindings = payload.get("bindings") if isinstance(payload, dict) else {}
1527
+ if not isinstance(raw_bindings, dict):
1528
+ return []
1529
+ items: list[dict[str, object]] = []
1530
+ for conversation_id, binding in sorted(raw_bindings.items()):
1531
+ if not isinstance(binding, dict):
1532
+ continue
1533
+ normalized_conversation_id = str(conversation_id or "").strip()
1534
+ if not normalized_conversation_id:
1535
+ continue
1536
+ items.append(
1537
+ {
1538
+ "conversation_id": normalized_conversation_id,
1539
+ "quest_id": str(binding.get("quest_id") or "").strip() or None,
1540
+ "updated_at": str(binding.get("updated_at") or "").strip() or None,
1541
+ "profile_id": None,
1542
+ "profile_label": None,
1543
+ "is_passive": lingzhu_is_passive_conversation_id(normalized_conversation_id),
1544
+ }
1545
+ )
1546
+ return items
1547
+
1548
+ @staticmethod
1549
+ def _lingzhu_effective_binding(
1550
+ bindings: list[dict[str, object]],
1551
+ passive_conversation_id: str | None,
1552
+ ) -> dict[str, object] | None:
1553
+ normalized_passive_conversation_id = str(passive_conversation_id or "").strip()
1554
+ if not normalized_passive_conversation_id:
1555
+ return None
1556
+ candidate_bindings = [
1557
+ dict(item)
1558
+ for item in bindings
1559
+ if isinstance(item, dict) and str(item.get("quest_id") or "").strip()
1560
+ ]
1561
+ if not candidate_bindings:
1562
+ return None
1563
+ selected = max(
1564
+ candidate_bindings,
1565
+ key=lambda item: (
1566
+ str(item.get("updated_at") or ""),
1567
+ str(item.get("quest_id") or ""),
1568
+ str(item.get("conversation_id") or ""),
1569
+ ),
1570
+ )
1571
+ return {
1572
+ "conversation_id": normalized_passive_conversation_id,
1573
+ "quest_id": str(selected.get("quest_id") or "").strip() or None,
1574
+ "updated_at": str(selected.get("updated_at") or "").strip() or None,
1575
+ "profile_id": None,
1576
+ "profile_label": None,
1577
+ "is_passive": True,
1578
+ }
1579
+
1580
+ @staticmethod
1581
+ def _lingzhu_recent_conversations(state: dict[str, object]) -> list[dict[str, object]]:
1582
+ items = state.get("recent_conversations")
1583
+ if not isinstance(items, list):
1584
+ return []
1585
+ return [dict(item) for item in items if isinstance(item, dict)]
1586
+
1587
+ @staticmethod
1588
+ def _lingzhu_known_targets(state: dict[str, object]) -> list[dict[str, object]]:
1589
+ items = state.get("known_targets")
1590
+ if not isinstance(items, list):
1591
+ return []
1592
+ return [dict(item) for item in items if isinstance(item, dict)]
1593
+
1272
1594
  def _lingzhu_snapshot_details(self, config: dict) -> dict:
1273
1595
  auth_ak = self._secret(config, "auth_ak", "auth_ak_env")
1274
1596
  return {
@@ -1395,8 +1717,18 @@ Use **Test** when the file exposes runtime dependencies.
1395
1717
  if connector_name in PROFILEABLE_CONNECTOR_NAMES:
1396
1718
  normalized[connector_name] = normalize_connector_config(connector_name, {**base, **sanitized_payload})
1397
1719
  continue
1720
+ elif connector_name == "weixin":
1721
+ sanitized_payload["transport"] = "ilink_long_poll"
1722
+ merged = {**base, **sanitized_payload}
1723
+ merged["enabled"] = self._weixin_auto_enabled(merged)
1724
+ normalized[connector_name] = merged
1725
+ continue
1398
1726
  elif connector_name == "lingzhu":
1399
1727
  sanitized_payload["transport"] = "openclaw_sse"
1728
+ merged = {**base, **sanitized_payload}
1729
+ merged["enabled"] = self._lingzhu_auto_enabled(merged)
1730
+ normalized[connector_name] = merged
1731
+ continue
1400
1732
  elif "transport" not in sanitized_payload:
1401
1733
  inferred_transport = infer_connector_transport(connector_name, {**base, **sanitized_payload})
1402
1734
  if inferred_transport:
@@ -1481,6 +1813,45 @@ Use **Test** when the file exposes runtime dependencies.
1481
1813
  return False
1482
1814
  return bool(value)
1483
1815
 
1816
+ @staticmethod
1817
+ def _connector_has_secret(payload: dict[str, object], direct_key: str, env_key: str) -> bool:
1818
+ return bool(str(payload.get(direct_key) or "").strip() or str(payload.get(env_key) or "").strip())
1819
+
1820
+ def _weixin_auto_enabled(self, payload: dict[str, object]) -> bool:
1821
+ return self._connector_has_secret(payload, "bot_token", "bot_token_env") and bool(
1822
+ str(payload.get("account_id") or "").strip()
1823
+ )
1824
+
1825
+ def _lingzhu_auto_enabled(self, payload: dict[str, object]) -> bool:
1826
+ auth_ready = self._connector_has_secret(payload, "auth_ak", "auth_ak_env")
1827
+ public_base_url = str(payload.get("public_base_url") or "").strip()
1828
+ if not auth_ready or not public_base_url:
1829
+ return False
1830
+ normalized_public_base_url = lingzhu_public_base_url(payload)
1831
+ if normalized_public_base_url is None:
1832
+ return False
1833
+ return public_base_url_looks_public(normalized_public_base_url)
1834
+
1835
+ def _connector_has_user_config(self, name: str, config: dict[str, object]) -> bool:
1836
+ if name == "qq":
1837
+ return bool(list_qq_profiles(config))
1838
+ if name in PROFILEABLE_CONNECTOR_NAMES:
1839
+ return bool(list_connector_profiles(name, config))
1840
+ if name == "weixin":
1841
+ return any(
1842
+ str(config.get(key) or "").strip()
1843
+ for key in ("bot_token", "bot_token_env", "account_id", "login_user_id", "route_tag")
1844
+ )
1845
+ if name == "lingzhu":
1846
+ return any(
1847
+ str(config.get(key) or "").strip()
1848
+ for key in ("auth_ak", "auth_ak_env", "public_base_url")
1849
+ )
1850
+ return False
1851
+
1852
+ def _should_validate_connector(self, name: str, config: dict[str, object]) -> bool:
1853
+ return bool(config.get("enabled", False)) or self._connector_has_user_config(name, config)
1854
+
1484
1855
  def _normalize_plugins_payload(self, payload: dict) -> dict:
1485
1856
  normalized = deepcopy(payload)
1486
1857
  if "load_paths" not in normalized and isinstance(normalized.get("search_paths"), list):