@researai/deepscientist 1.5.9 → 1.5.12

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 (165) hide show
  1. package/README.md +112 -99
  2. package/assets/branding/connector-qq.png +0 -0
  3. package/assets/branding/connector-rokid.png +0 -0
  4. package/assets/branding/connector-weixin.png +0 -0
  5. package/assets/branding/projects.png +0 -0
  6. package/bin/ds.js +519 -63
  7. package/docs/assets/branding/projects.png +0 -0
  8. package/docs/en/00_QUICK_START.md +338 -68
  9. package/docs/en/01_SETTINGS_REFERENCE.md +14 -0
  10. package/docs/en/02_START_RESEARCH_GUIDE.md +180 -4
  11. package/docs/en/04_LINGZHU_CONNECTOR_GUIDE.md +62 -179
  12. package/docs/en/09_DOCTOR.md +66 -5
  13. package/docs/en/10_WEIXIN_CONNECTOR_GUIDE.md +137 -0
  14. package/docs/en/11_LICENSE_AND_RISK.md +256 -0
  15. package/docs/en/12_GUIDED_WORKFLOW_TOUR.md +446 -0
  16. package/docs/en/13_CORE_ARCHITECTURE_GUIDE.md +297 -0
  17. package/docs/en/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +506 -0
  18. package/docs/en/15_CODEX_PROVIDER_SETUP.md +284 -0
  19. package/docs/en/99_ACKNOWLEDGEMENTS.md +4 -1
  20. package/docs/en/README.md +83 -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 +345 -72
  28. package/docs/zh/01_SETTINGS_REFERENCE.md +14 -0
  29. package/docs/zh/02_START_RESEARCH_GUIDE.md +181 -3
  30. package/docs/zh/04_LINGZHU_CONNECTOR_GUIDE.md +62 -193
  31. package/docs/zh/09_DOCTOR.md +68 -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 +442 -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/15_CODEX_PROVIDER_SETUP.md +285 -0
  38. package/docs/zh/99_ACKNOWLEDGEMENTS.md +4 -1
  39. package/docs/zh/README.md +129 -0
  40. package/install.sh +0 -34
  41. package/package.json +2 -2
  42. package/pyproject.toml +1 -1
  43. package/src/deepscientist/__init__.py +1 -1
  44. package/src/deepscientist/annotations.py +343 -0
  45. package/src/deepscientist/artifact/arxiv.py +484 -37
  46. package/src/deepscientist/artifact/service.py +574 -108
  47. package/src/deepscientist/arxiv_library.py +275 -0
  48. package/src/deepscientist/bash_exec/monitor.py +7 -5
  49. package/src/deepscientist/bash_exec/service.py +93 -21
  50. package/src/deepscientist/bridges/builtins.py +2 -0
  51. package/src/deepscientist/bridges/connectors.py +447 -0
  52. package/src/deepscientist/channels/__init__.py +2 -0
  53. package/src/deepscientist/channels/builtins.py +3 -1
  54. package/src/deepscientist/channels/local.py +3 -3
  55. package/src/deepscientist/channels/qq.py +8 -8
  56. package/src/deepscientist/channels/qq_gateway.py +1 -1
  57. package/src/deepscientist/channels/relay.py +14 -8
  58. package/src/deepscientist/channels/weixin.py +59 -0
  59. package/src/deepscientist/channels/weixin_ilink.py +388 -0
  60. package/src/deepscientist/config/models.py +23 -2
  61. package/src/deepscientist/config/service.py +539 -67
  62. package/src/deepscientist/connector/__init__.py +4 -0
  63. package/src/deepscientist/connector/connector_profiles.py +481 -0
  64. package/src/deepscientist/connector/lingzhu_support.py +668 -0
  65. package/src/deepscientist/connector/qq_profiles.py +206 -0
  66. package/src/deepscientist/connector/weixin_support.py +663 -0
  67. package/src/deepscientist/connector_profiles.py +1 -374
  68. package/src/deepscientist/connector_runtime.py +2 -0
  69. package/src/deepscientist/daemon/api/handlers.py +165 -5
  70. package/src/deepscientist/daemon/api/router.py +13 -1
  71. package/src/deepscientist/daemon/app.py +1444 -67
  72. package/src/deepscientist/doctor.py +4 -5
  73. package/src/deepscientist/gitops/diff.py +120 -29
  74. package/src/deepscientist/lingzhu_support.py +1 -182
  75. package/src/deepscientist/mcp/server.py +135 -7
  76. package/src/deepscientist/prompts/builder.py +128 -11
  77. package/src/deepscientist/qq_profiles.py +1 -196
  78. package/src/deepscientist/quest/node_traces.py +23 -0
  79. package/src/deepscientist/quest/service.py +359 -74
  80. package/src/deepscientist/quest/stage_views.py +71 -5
  81. package/src/deepscientist/runners/codex.py +170 -19
  82. package/src/deepscientist/runners/runtime_overrides.py +6 -0
  83. package/src/deepscientist/shared.py +33 -14
  84. package/src/deepscientist/weixin_support.py +1 -0
  85. package/src/prompts/connectors/lingzhu.md +3 -1
  86. package/src/prompts/connectors/qq.md +2 -1
  87. package/src/prompts/connectors/weixin.md +231 -0
  88. package/src/prompts/contracts/shared_interaction.md +4 -1
  89. package/src/prompts/system.md +61 -9
  90. package/src/skills/analysis-campaign/SKILL.md +46 -6
  91. package/src/skills/analysis-campaign/references/campaign-plan-template.md +21 -8
  92. package/src/skills/baseline/SKILL.md +1 -1
  93. package/src/skills/decision/SKILL.md +1 -1
  94. package/src/skills/experiment/SKILL.md +1 -1
  95. package/src/skills/finalize/SKILL.md +1 -1
  96. package/src/skills/idea/SKILL.md +1 -1
  97. package/src/skills/intake-audit/SKILL.md +1 -1
  98. package/src/skills/rebuttal/SKILL.md +74 -1
  99. package/src/skills/rebuttal/references/response-letter-template.md +55 -11
  100. package/src/skills/review/SKILL.md +118 -1
  101. package/src/skills/review/references/experiment-todo-template.md +23 -0
  102. package/src/skills/review/references/review-report-template.md +16 -0
  103. package/src/skills/review/references/revision-log-template.md +4 -0
  104. package/src/skills/scout/SKILL.md +1 -1
  105. package/src/skills/write/SKILL.md +168 -7
  106. package/src/skills/write/references/paper-experiment-matrix-template.md +131 -0
  107. package/src/tui/package.json +1 -1
  108. package/src/ui/dist/assets/{AiManusChatView-BKZ103sn.js → AiManusChatView-CnJcXynW.js} +156 -48
  109. package/src/ui/dist/assets/{AnalysisPlugin-mTTzGAlK.js → AnalysisPlugin-DeyzPEhV.js} +1 -1
  110. package/src/ui/dist/assets/{CliPlugin-BH58n3GY.js → CliPlugin-CB1YODQn.js} +164 -9
  111. package/src/ui/dist/assets/{CodeEditorPlugin-BKGRUH7e.js → CodeEditorPlugin-B-xicq1e.js} +8 -8
  112. package/src/ui/dist/assets/{CodeViewerPlugin-BMADwFWJ.js → CodeViewerPlugin-DT54ysXa.js} +5 -5
  113. package/src/ui/dist/assets/{DocViewerPlugin-ZOnTIHLN.js → DocViewerPlugin-DQtKT-VD.js} +3 -3
  114. package/src/ui/dist/assets/{GitDiffViewerPlugin-CQ7h1Djm.js → GitDiffViewerPlugin-hqHbCfnv.js} +20 -21
  115. package/src/ui/dist/assets/{ImageViewerPlugin-GVS5MsnC.js → ImageViewerPlugin-OcVo33jV.js} +5 -5
  116. package/src/ui/dist/assets/{LabCopilotPanel-BZNv1JML.js → LabCopilotPanel-DdGwhEUV.js} +11 -11
  117. package/src/ui/dist/assets/{LabPlugin-TWcJsdQA.js → LabPlugin-Ciz1gDaX.js} +2 -1
  118. package/src/ui/dist/assets/{LatexPlugin-DIjHiR2x.js → LatexPlugin-BhmjNQRC.js} +37 -11
  119. package/src/ui/dist/assets/{MarkdownViewerPlugin-D3ooGAH0.js → MarkdownViewerPlugin-BzdVH9Bx.js} +4 -4
  120. package/src/ui/dist/assets/{MarketplacePlugin-DfVfE9hN.js → MarketplacePlugin-DmyHspXt.js} +3 -3
  121. package/src/ui/dist/assets/{NotebookEditor-DDl0_Mc0.js → NotebookEditor-BMXKrDRk.js} +1 -1
  122. package/src/ui/dist/assets/{NotebookEditor-s8JhzuX1.js → NotebookEditor-BTVYRGkm.js} +12 -12
  123. package/src/ui/dist/assets/{PdfLoader-C2Sf6SJM.js → PdfLoader-CvcjJHXv.js} +14 -7
  124. package/src/ui/dist/assets/{PdfMarkdownPlugin-CXFLoIsa.js → PdfMarkdownPlugin-DW2ej8Vk.js} +73 -6
  125. package/src/ui/dist/assets/{PdfViewerPlugin-BYTmz2fK.js → PdfViewerPlugin-CmlDxbhU.js} +103 -34
  126. package/src/ui/dist/assets/PdfViewerPlugin-DQ11QcSf.css +3627 -0
  127. package/src/ui/dist/assets/{SearchPlugin-CjWBI1O9.js → SearchPlugin-DAjQZPSv.js} +1 -1
  128. package/src/ui/dist/assets/{TextViewerPlugin-DdOBU3-S.js → TextViewerPlugin-C-nVAZb_.js} +5 -4
  129. package/src/ui/dist/assets/{VNCViewer-B8HGgLwQ.js → VNCViewer-D7-dIYon.js} +10 -10
  130. package/src/ui/dist/assets/bot-C_G4WtNI.js +21 -0
  131. package/src/ui/dist/assets/branding/logo-rokid.png +0 -0
  132. package/src/ui/dist/assets/browser-BAcuE0Xj.js +2895 -0
  133. package/src/ui/dist/assets/{code-BWAY76JP.js → code-Cd7WfiWq.js} +1 -1
  134. package/src/ui/dist/assets/{file-content-C1NwU5oQ.js → file-content-B57zsL9y.js} +1 -1
  135. package/src/ui/dist/assets/{file-diff-panel-CywslwB9.js → file-diff-panel-DVoheLFq.js} +1 -1
  136. package/src/ui/dist/assets/{file-socket-B4kzuOBQ.js → file-socket-B5kXFxZP.js} +1 -1
  137. package/src/ui/dist/assets/{image-D-NZM-6P.js → image-LLOjkMHF.js} +1 -1
  138. package/src/ui/dist/assets/{index-DGIYDuTv.css → index-BQG-1s2o.css} +40 -13
  139. package/src/ui/dist/assets/{index-DHZJ_0TI.js → index-C3r2iGrp.js} +12 -12
  140. package/src/ui/dist/assets/{index-7Chr1g9c.js → index-CLQauncb.js} +15050 -9561
  141. package/src/ui/dist/assets/index-Dxa2eYMY.js +25 -0
  142. package/src/ui/dist/assets/{index-BdM1Gqfr.js → index-hOUOWbW2.js} +2 -2
  143. package/src/ui/dist/assets/{monaco-Cb2uKKe6.js → monaco-BGGAEii3.js} +1 -1
  144. package/src/ui/dist/assets/{pdf-effect-queue-DSw_D3RV.js → pdf-effect-queue-DlEr1_y5.js} +16 -1
  145. package/src/ui/dist/assets/pdf.worker.min-yatZIOMy.mjs +21 -0
  146. package/src/ui/dist/assets/{popover-Bg72DGgT.js → popover-CWJbJuYY.js} +1 -1
  147. package/src/ui/dist/assets/{project-sync-Ce_0BglY.js → project-sync-CRJiucYO.js} +18 -77
  148. package/src/ui/dist/assets/select-CoHB7pvH.js +1690 -0
  149. package/src/ui/dist/assets/{sigma-DPaACDrh.js → sigma-D5aJWR8J.js} +1 -1
  150. package/src/ui/dist/assets/{index-CDxNdQdz.js → square-check-big-DUK_mnkS.js} +2 -13
  151. package/src/ui/dist/assets/{trash-BvTgE5__.js → trash-ChU3SEE3.js} +1 -1
  152. package/src/ui/dist/assets/{useCliAccess-CgPeMOwP.js → useCliAccess-BrJBV3tY.js} +1 -1
  153. package/src/ui/dist/assets/{useFileDiffOverlay-xPhz7P5B.js → useFileDiffOverlay-C2OQaVWc.js} +1 -1
  154. package/src/ui/dist/assets/{wrap-text-C3Un3YQr.js → wrap-text-C7Qqh-om.js} +1 -1
  155. package/src/ui/dist/assets/{zoom-out-BgxLa0Ri.js → zoom-out-rtX0FKya.js} +1 -1
  156. package/src/ui/dist/index.html +2 -2
  157. package/src/ui/dist/assets/AutoFigurePlugin-BGxN8Umr.css +0 -3056
  158. package/src/ui/dist/assets/AutoFigurePlugin-C_wWw4AP.js +0 -8149
  159. package/src/ui/dist/assets/PdfViewerPlugin-BJXtIwj_.css +0 -260
  160. package/src/ui/dist/assets/Stepper-B0Dd8CxK.js +0 -158
  161. package/src/ui/dist/assets/bibtex-CKaefIN2.js +0 -189
  162. package/src/ui/dist/assets/file-utils-H2fjA46S.js +0 -109
  163. package/src/ui/dist/assets/message-square-BzjLiXir.js +0 -16
  164. package/src/ui/dist/assets/pdfjs-DU1YE8WO.js +0 -3
  165. package/src/ui/dist/assets/tooltip-C_mA6R0w.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,58 @@ 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
+ previous = self.load_named_normalized(name) if name in CONFIG_NAMES and self.path_for(name).exists() else default_payload(name, self.home)
176
+ result = self.save_named_text(name, self.render_named_payload(name, prepared))
177
+ if result.get("ok") and name == "runners":
178
+ self._invalidate_codex_bootstrap_state_if_runner_changed(previous, self.load_named_normalized("runners"))
179
+ return result
180
+
181
+ def _prepare_payload_for_save(self, name: str, payload: dict) -> dict:
182
+ prepared = deepcopy(payload) if isinstance(payload, dict) else {}
183
+ if name != "connectors":
184
+ return prepared
185
+ lingzhu = prepared.get("lingzhu")
186
+ if not isinstance(lingzhu, dict):
187
+ return prepared
188
+ enabled = self._coerce_bool(lingzhu.get("enabled"), default=False)
189
+ raw_public_base_url = str(lingzhu.get("public_base_url") or "").strip()
190
+ direct_auth_ak = str(lingzhu.get("auth_ak") or "").strip()
191
+ if lingzhu_auth_ak_needs_rotation(direct_auth_ak):
192
+ lingzhu["auth_ak"] = generate_lingzhu_auth_ak()
193
+ elif (enabled or raw_public_base_url) and not self._has_secret(lingzhu, "auth_ak", "auth_ak_env"):
194
+ lingzhu["auth_ak"] = generate_lingzhu_auth_ak()
195
+ prepared["lingzhu"] = lingzhu
196
+ return prepared
197
+
198
+ def _invalidate_codex_bootstrap_state_if_runner_changed(self, previous: dict, current: dict) -> None:
199
+ previous_codex = previous.get("codex") if isinstance(previous.get("codex"), dict) else {}
200
+ current_codex = current.get("codex") if isinstance(current.get("codex"), dict) else {}
201
+ tracked_keys = (
202
+ "binary",
203
+ "config_dir",
204
+ "profile",
205
+ "model",
206
+ "model_reasoning_effort",
207
+ "approval_policy",
208
+ "sandbox_mode",
209
+ "env",
210
+ )
211
+ if all(previous_codex.get(key) == current_codex.get(key) for key in tracked_keys):
212
+ return
213
+ config = self.load_named_normalized("config")
214
+ bootstrap = config.get("bootstrap") if isinstance(config.get("bootstrap"), dict) else {}
215
+ bootstrap["codex_ready"] = False
216
+ bootstrap["codex_last_checked_at"] = utc_now()
217
+ bootstrap["codex_last_result"] = {
218
+ "ok": False,
219
+ "summary": "Codex runner configuration changed. A new startup probe is required.",
220
+ "warnings": [],
221
+ "errors": [],
222
+ "guidance": [],
223
+ }
224
+ config["bootstrap"] = bootstrap
225
+ self.save_named_text("config", self.render_named_payload("config", config))
167
226
 
168
227
  def bind_qq_main_chat(self, *, profile_id: str | None = None, chat_id: str) -> dict:
169
228
  normalized_chat_id = str(chat_id or "").strip()
@@ -283,7 +342,7 @@ This page edits `~/DeepScientist/config/connectors.yaml` directly.
283
342
 
284
343
  ## What this page is for
285
344
 
286
- - connect Telegram, Discord, Slack, Feishu, WhatsApp, or QQ
345
+ - connect Weixin, Telegram, Discord, Slack, Feishu, WhatsApp, or QQ
287
346
  - choose one preferred connector for proactive artifact updates
288
347
  - decide whether artifact updates fan out or stay focused
289
348
  - use the built-in direct runtime for each connector
@@ -291,7 +350,7 @@ This page edits `~/DeepScientist/config/connectors.yaml` directly.
291
350
 
292
351
  ## Recommended order
293
352
 
294
- 1. enable only one connector first
353
+ 1. configure one connector first
295
354
  2. fill the required token or secret fields
296
355
  3. click **Validate**
297
356
  4. click **Test**
@@ -299,6 +358,7 @@ This page edits `~/DeepScientist/config/connectors.yaml` directly.
299
358
 
300
359
  ## Preferred transports
301
360
 
361
+ - Weixin: `ilink_long_poll`
302
362
  - Telegram: `polling`
303
363
  - Slack: `socket_mode`
304
364
  - Discord: `gateway`
@@ -321,6 +381,13 @@ This page edits `~/DeepScientist/config/connectors.yaml` directly.
321
381
  - prefer `transport: socket_mode`
322
382
  - readiness test uses `auth.test`
323
383
 
384
+ ### Weixin
385
+
386
+ - scan the QR code first so DeepScientist can persist `bot_token` and `account_id`
387
+ - keep `transport: ilink_long_poll`
388
+ - every reply depends on the latest inbound `context_token`
389
+ - media send uses the built-in AES + CDN upload path for image, video, and file attachments
390
+
324
391
  ### Discord
325
392
 
326
393
  - set `bot_token`
@@ -354,12 +421,12 @@ This page edits `~/DeepScientist/config/connectors.yaml` directly.
354
421
 
355
422
  ### Lingzhu
356
423
 
357
- - Lingzhu is configured as an OpenClaw companion endpoint, not as a full DeepScientist chat bridge
424
+ - Lingzhu is hosted directly by DeepScientist on `/metis/agent/api`
358
425
  - 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`
426
+ - use the same public DeepScientist origin and port that the browser is already serving on
427
+ - save once so DeepScientist can persist `auth_ak`; Rokid must use the same Bearer token
428
+ - `public_base_url` must be a public IP or public domain; loopback and private addresses are invalid for Rokid
429
+ - new Lingzhu tasks must start with `我现在的任务是`; other requests are treated as reconnect or progress polling
363
430
 
364
431
  ## Safety
365
432
 
@@ -417,6 +484,8 @@ This page edits `{home_text}/config/runners.yaml`.
417
484
  - keep `codex.enabled: true`
418
485
  - keep `claude.enabled: false`
419
486
  - `claude` remains TODO / reserved in the current open-source release and is not runnable yet
487
+ - set `codex.profile` only when your Codex CLI uses a named provider profile such as `m27`
488
+ - when you launch DeepScientist ad hoc with a provider profile, you can also use `ds --codex-profile <name>`
420
489
  - keep `codex.model_reasoning_effort: xhigh` unless you explicitly want a lighter default
421
490
  - keep `codex.retry_on_failure: true` so transient Codex failures can resume automatically
422
491
  - keep retry timing near `10s / 6x / 1800s max` so Codex backs off exponentially and the last retry waits about 30 minutes
@@ -428,7 +497,7 @@ The **Test** button checks:
428
497
 
429
498
  - whether the configured runner binaries are on PATH
430
499
  - whether disabled runners are intentionally skipped
431
- - for Codex, it also runs a real hello probe so login and first-run setup problems surface before quest execution
500
+ - for Codex, it also runs a real hello probe so login problems, profile misconfiguration, and first-run setup issues surface before quest execution
432
501
  - it does not simulate the full failure/retry loop, so use quest runtime logs when debugging recovery behavior
433
502
  """
434
503
  if name == "plugins":
@@ -680,9 +749,10 @@ Use **Test** when the file exposes runtime dependencies.
680
749
  continue
681
750
  config = raw_config
682
751
  enabled = bool(config.get("enabled", False))
683
- if not enabled:
752
+ if not self._should_validate_connector(str(name), config):
684
753
  continue
685
- enabled_connectors.append(str(name))
754
+ if enabled:
755
+ enabled_connectors.append(str(name))
686
756
 
687
757
  if name == "qq":
688
758
  profiles = list_qq_profiles(config)
@@ -746,6 +816,15 @@ Use **Test** when the file exposes runtime dependencies.
746
816
  errors.append("slack: `transport: socket_mode` requires `bot_token` or `bot_token_env`.")
747
817
  if not has_app_token:
748
818
  errors.append("slack: `transport: socket_mode` requires `app_token` or `app_token_env`.")
819
+ elif name == "weixin":
820
+ if transport != "ilink_long_poll":
821
+ errors.append("weixin: `transport` must stay `ilink_long_poll`.")
822
+ if not self._has_secret(config, "bot_token", "bot_token_env"):
823
+ errors.append("weixin: requires `bot_token` or `bot_token_env` after QR login.")
824
+ if not str(config.get("account_id") or "").strip():
825
+ errors.append("weixin: requires `account_id` after QR login.")
826
+ if not str(config.get("login_user_id") or "").strip():
827
+ warnings.append("weixin: `login_user_id` is empty. Save the scanner user id after QR login for easier diagnostics.")
749
828
  elif name == "feishu":
750
829
  has_app_id = bool(str(config.get("app_id") or "").strip())
751
830
  has_app_secret = self._has_secret(config, "app_secret", "app_secret_env")
@@ -767,6 +846,8 @@ Use **Test** when the file exposes runtime dependencies.
767
846
  warnings.append("lingzhu: `local_host` is empty; DeepScientist will fall back to `127.0.0.1`.")
768
847
  if not self._has_secret(config, "auth_ak", "auth_ak_env"):
769
848
  errors.append("lingzhu: requires `auth_ak` for Bearer authentication.")
849
+ elif lingzhu_auth_ak_needs_rotation(self._secret(config, "auth_ak", "auth_ak_env")):
850
+ errors.append("lingzhu: `auth_ak` is still using the bundled example token; generate a new random AK before binding Rokid.")
770
851
  raw_gateway_port = str(config.get("gateway_port") or "").strip()
771
852
  normalized_port = lingzhu_gateway_port(config)
772
853
  if raw_gateway_port and str(normalized_port) != raw_gateway_port:
@@ -775,6 +856,10 @@ Use **Test** when the file exposes runtime dependencies.
775
856
  public_base_url = lingzhu_public_base_url(config)
776
857
  if raw_public_base_url and public_base_url is None:
777
858
  errors.append("lingzhu: `public_base_url` must be a valid `http://` or `https://` URL when set.")
859
+ elif raw_public_base_url and not public_base_url_looks_public(raw_public_base_url):
860
+ errors.append(
861
+ "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."
862
+ )
778
863
  raw_visible_progress_heartbeat_sec = str(
779
864
  config.get("visible_progress_heartbeat_sec") or ""
780
865
  ).strip()
@@ -817,14 +902,14 @@ Use **Test** when the file exposes runtime dependencies.
817
902
  if not isinstance(raw_config, dict):
818
903
  continue
819
904
  config = raw_config
820
- if not config.get("enabled", False):
905
+ if not self._should_validate_connector(str(name), config):
821
906
  continue
822
907
  target = (delivery_targets or {}).get(name)
823
908
  items.append(self._test_single_connector(name, config, live=live, delivery_target=target if isinstance(target, dict) else None))
824
909
  return {
825
910
  "ok": all(item["ok"] for item in items) if items else True,
826
911
  "name": "connectors",
827
- "summary": "Connector test completed." if items else "No enabled connectors to test.",
912
+ "summary": "Connector test completed." if items else "No configured connectors to test.",
828
913
  "warnings": [],
829
914
  "errors": [],
830
915
  "items": items,
@@ -963,6 +1048,25 @@ Use **Test** when the file exposes runtime dependencies.
963
1048
  details["identity"] = profile_results[0].get("app_id")
964
1049
  details["gateway_url"] = profile_results[0].get("gateway_url")
965
1050
  details["token_expires_in"] = profile_results[0].get("token_expires_in")
1051
+ elif name == "weixin":
1052
+ details.update(
1053
+ {
1054
+ "base_url": normalize_weixin_base_url(config.get("base_url")),
1055
+ "cdn_base_url": normalize_weixin_cdn_base_url(config.get("cdn_base_url")),
1056
+ "account_id": str(config.get("account_id") or "").strip() or None,
1057
+ "login_user_id": str(config.get("login_user_id") or "").strip() or None,
1058
+ }
1059
+ )
1060
+ if transport != "ilink_long_poll":
1061
+ errors.append("Weixin transport must stay `ilink_long_poll`.")
1062
+ if not self._has_secret(config, "bot_token", "bot_token_env"):
1063
+ errors.append("Weixin requires `bot_token` after QR login.")
1064
+ if not str(config.get("account_id") or "").strip():
1065
+ errors.append("Weixin requires `account_id` after QR login.")
1066
+ if live and not errors:
1067
+ warnings.append(
1068
+ "Weixin readiness is credential-based. Send one inbound Weixin message to populate `context_token` before testing outbound delivery."
1069
+ )
966
1070
  elif name == "lingzhu":
967
1071
  details.update(self._lingzhu_snapshot_details(config))
968
1072
  auth_ak = self._secret(config, "auth_ak", "auth_ak_env")
@@ -1070,11 +1174,157 @@ Use **Test** when the file exposes runtime dependencies.
1070
1174
  "If you received it, the connector binding and outbound delivery path are working."
1071
1175
  )
1072
1176
 
1177
+ @staticmethod
1178
+ def _codex_should_inherit_model(model: object) -> bool:
1179
+ normalized = str(model or "").strip().lower()
1180
+ return normalized in {"", "inherit", "default", "codex-default"}
1181
+
1182
+ @staticmethod
1183
+ def _codex_requested_model(config: dict) -> str:
1184
+ raw_model = config.get("model")
1185
+ if raw_model is None:
1186
+ return "gpt-5.4"
1187
+ return str(raw_model).strip()
1188
+
1189
+ @staticmethod
1190
+ def _codex_profile_name(config: dict) -> str:
1191
+ raw_profile = config.get("profile")
1192
+ if raw_profile is None:
1193
+ return ""
1194
+ return str(raw_profile).strip()
1195
+
1196
+ @staticmethod
1197
+ def _codex_runner_env(config: dict) -> dict[str, str]:
1198
+ raw_env = config.get("env")
1199
+ if not isinstance(raw_env, dict):
1200
+ return {}
1201
+ resolved: dict[str, str] = {}
1202
+ for key, value in raw_env.items():
1203
+ env_key = str(key or "").strip()
1204
+ if not env_key or value is None:
1205
+ continue
1206
+ resolved[env_key] = str(value)
1207
+ return resolved
1208
+
1209
+ def _codex_missing_binary_guidance(self, config: dict) -> list[str]:
1210
+ profile = self._codex_profile_name(config)
1211
+ guidance = [
1212
+ "Run `npm install -g @researai/deepscientist` again so the bundled Codex dependency is installed.",
1213
+ "If `codex` is still missing, install it explicitly with `npm install -g @openai/codex`.",
1214
+ ]
1215
+ if profile:
1216
+ guidance.extend(
1217
+ [
1218
+ f"Then verify `codex --profile {profile}` works from a terminal before starting DeepScientist.",
1219
+ "If that profile uses a custom provider, make sure its API key and Base URL are configured in Codex first.",
1220
+ ]
1221
+ )
1222
+ else:
1223
+ guidance.append("Run `codex --login` (or `codex`) once and finish authentication before starting DeepScientist.")
1224
+ guidance.append("If you use a custom Codex path, set `runners.codex.binary` to that absolute executable path.")
1225
+ return guidance
1226
+
1227
+ def _codex_probe_failure_guidance(self, config: dict) -> tuple[list[str], list[str]]:
1228
+ profile = self._codex_profile_name(config)
1229
+ if profile:
1230
+ return (
1231
+ [
1232
+ f"Codex profile `{profile}` did not complete the startup hello probe successfully.",
1233
+ ],
1234
+ [
1235
+ f"Run `codex --profile {profile}` in a terminal and confirm that profile can start normally.",
1236
+ "If the profile uses a custom provider, make sure its API key, Base URL, and model configuration are available to Codex.",
1237
+ "If the provider expects the model from the Codex profile itself, set `model: inherit` in `~/DeepScientist/config/runners.yaml`.",
1238
+ "Then run `ds doctor` and start DeepScientist again.",
1239
+ ],
1240
+ )
1241
+ return (
1242
+ [
1243
+ "Run `codex --login` (or `codex`) once and complete login before starting DeepScientist.",
1244
+ ],
1245
+ [
1246
+ "Run `codex --login` (or `codex`) in a terminal and complete login or first-run setup.",
1247
+ "If `codex` is missing, install it explicitly with `npm install -g @openai/codex`.",
1248
+ "If the configured model is not available to your Codex account, update `~/DeepScientist/config/runners.yaml` and try again.",
1249
+ "Then run `ds doctor` and start DeepScientist again.",
1250
+ ],
1251
+ )
1252
+
1253
+ @staticmethod
1254
+ def _codex_model_unavailable(stdout_text: str, stderr_text: str) -> bool:
1255
+ haystack = f"{stdout_text}\n{stderr_text}".lower()
1256
+ markers = [
1257
+ "unknown model",
1258
+ "invalid model",
1259
+ "model not found",
1260
+ "unsupported model",
1261
+ "model is not available",
1262
+ "not authorized to use model",
1263
+ "you do not have access",
1264
+ "access to model",
1265
+ "model access",
1266
+ "unrecognized model",
1267
+ ]
1268
+ return any(marker in haystack for marker in markers)
1269
+
1270
+ def _build_codex_probe_command(
1271
+ self,
1272
+ *,
1273
+ resolved_binary: str,
1274
+ profile: str,
1275
+ requested_model: str,
1276
+ approval_policy: str,
1277
+ reasoning_effort: str | None,
1278
+ sandbox_mode: str,
1279
+ ) -> list[str]:
1280
+ command = [
1281
+ resolved_binary,
1282
+ "--search",
1283
+ ]
1284
+ if profile:
1285
+ command.extend(["--profile", profile])
1286
+ command.extend(
1287
+ [
1288
+ "exec",
1289
+ "--json",
1290
+ "--cd",
1291
+ str(repo_root()),
1292
+ "--skip-git-repo-check",
1293
+ ]
1294
+ )
1295
+ if not self._codex_should_inherit_model(requested_model):
1296
+ command.extend(["--model", requested_model])
1297
+ if approval_policy:
1298
+ command.extend(["-c", f'approval_policy="{approval_policy}"'])
1299
+ if reasoning_effort:
1300
+ command.extend(["-c", f'model_reasoning_effort="{reasoning_effort}"'])
1301
+ if sandbox_mode:
1302
+ command.extend(["--sandbox", sandbox_mode])
1303
+ command.append("-")
1304
+ return command
1305
+
1306
+ def _persist_codex_model_inherit(self, requested_model: object) -> None:
1307
+ normalized_requested_model = str(requested_model or "").strip()
1308
+ if not normalized_requested_model:
1309
+ return
1310
+ runners = self.load_named("runners")
1311
+ codex = runners.get("codex") if isinstance(runners.get("codex"), dict) else None
1312
+ if not isinstance(codex, dict):
1313
+ return
1314
+ current_model = str(codex.get("model") or "").strip()
1315
+ if current_model != normalized_requested_model:
1316
+ return
1317
+ codex["model"] = "inherit"
1318
+ runners["codex"] = codex
1319
+ self.save_named_payload("runners", runners)
1320
+
1073
1321
  def _probe_codex_runner(self, config: dict) -> dict:
1074
1322
  config = apply_codex_runtime_overrides(config)
1075
1323
  checked_at = utc_now()
1076
1324
  binary = str(config.get("binary") or "codex").strip() or "codex"
1077
1325
  resolved_binary = resolve_runner_binary(binary, runner_name="codex")
1326
+ profile = self._codex_profile_name(config)
1327
+ requested_model = self._codex_requested_model(config)
1078
1328
  raw_reasoning_effort = config.get("model_reasoning_effort")
1079
1329
  reasoning_effort = (
1080
1330
  str(raw_reasoning_effort).strip()
@@ -1085,10 +1335,15 @@ Use **Test** when the file exposes runtime dependencies.
1085
1335
  "binary": binary,
1086
1336
  "resolved_binary": resolved_binary,
1087
1337
  "config_dir": str(config.get("config_dir") or "~/.codex"),
1088
- "model": str(config.get("model") or "gpt-5.4"),
1338
+ "profile": profile,
1339
+ "model": requested_model or "inherit",
1340
+ "requested_model": requested_model or "inherit",
1341
+ "effective_model": requested_model or "inherit",
1089
1342
  "approval_policy": str(config.get("approval_policy") or "on-request"),
1090
1343
  "sandbox_mode": str(config.get("sandbox_mode") or "workspace-write"),
1091
1344
  "reasoning_effort": reasoning_effort,
1345
+ "model_fallback_attempted": False,
1346
+ "model_fallback_used": False,
1092
1347
  "checked_at": checked_at,
1093
1348
  }
1094
1349
  if not resolved_binary:
@@ -1101,56 +1356,50 @@ Use **Test** when the file exposes runtime dependencies.
1101
1356
  "DeepScientist could not resolve the bundled or configured `codex` CLI.",
1102
1357
  ],
1103
1358
  "details": details,
1104
- "guidance": [
1105
- "Run `npm install -g @researai/deepscientist` again so the bundled Codex dependency is installed.",
1106
- "If you use a custom Codex path, set `runners.codex.binary` to that absolute executable path.",
1107
- ],
1359
+ "guidance": self._codex_missing_binary_guidance(config),
1108
1360
  }
1109
1361
 
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
1362
  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
1363
  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
1364
 
1131
1365
  env = os.environ.copy()
1366
+ env.update(self._codex_runner_env(config))
1132
1367
  config_dir = str(config.get("config_dir") or "~/.codex").strip()
1133
1368
  if config_dir:
1134
1369
  env["CODEX_HOME"] = str(Path(config_dir).expanduser())
1135
1370
  prompt = "Reply with exactly HELLO."
1136
1371
 
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,
1372
+ def run_probe_once(model_for_command: str) -> tuple[list[str], subprocess.CompletedProcess[str] | None, subprocess.TimeoutExpired | None]:
1373
+ command = self._build_codex_probe_command(
1374
+ resolved_binary=resolved_binary,
1375
+ profile=profile,
1376
+ requested_model=model_for_command,
1377
+ approval_policy=approval_policy,
1378
+ reasoning_effort=reasoning_effort,
1379
+ sandbox_mode=sandbox_mode,
1147
1380
  )
1148
- except subprocess.TimeoutExpired as exc:
1381
+ try:
1382
+ result = subprocess.run(
1383
+ command,
1384
+ input=prompt,
1385
+ cwd=str(repo_root()),
1386
+ env=env,
1387
+ text=True,
1388
+ capture_output=True,
1389
+ timeout=90,
1390
+ check=False,
1391
+ )
1392
+ except subprocess.TimeoutExpired as exc:
1393
+ return command, None, exc
1394
+ return command, result, None
1395
+
1396
+ command, result, timeout_error = run_probe_once(requested_model)
1397
+ if timeout_error is not None:
1149
1398
  details.update(
1150
1399
  {
1151
1400
  "exit_code": None,
1152
- "stdout_excerpt": self._compact_probe_text(exc.stdout or ""),
1153
- "stderr_excerpt": self._compact_probe_text(exc.stderr or ""),
1401
+ "stdout_excerpt": self._compact_probe_text(timeout_error.stdout or ""),
1402
+ "stderr_excerpt": self._compact_probe_text(timeout_error.stderr or ""),
1154
1403
  "probe_command": command,
1155
1404
  }
1156
1405
  )
@@ -1160,19 +1409,72 @@ Use **Test** when the file exposes runtime dependencies.
1160
1409
  "warnings": [],
1161
1410
  "errors": [
1162
1411
  "Codex did not answer the startup hello probe within 90 seconds.",
1163
- "Run `codex` manually, complete login, then retry DeepScientist.",
1412
+ *self._codex_probe_failure_guidance(config)[0],
1164
1413
  ],
1165
1414
  "details": details,
1166
1415
  "guidance": [
1167
- "Run `codex` manually to finish interactive login or first-run setup.",
1168
- "Confirm the configured model is available to your account.",
1416
+ *self._codex_probe_failure_guidance(config)[1],
1417
+ "If `codex` is missing on PATH, install it explicitly with `npm install -g @openai/codex`.",
1418
+ "Confirm the configured model is available to your Codex setup. DeepScientist currently probes Codex with the configured runner model first.",
1169
1419
  ],
1170
1420
  }
1171
1421
 
1422
+ assert result is not None
1172
1423
  stdout_text = (result.stdout or "").strip()
1173
1424
  stderr_text = (result.stderr or "").strip()
1174
1425
  hello_seen = "HELLO" in stdout_text.upper()
1175
1426
  ok = result.returncode == 0 and hello_seen
1427
+ fallback_warning: str | None = None
1428
+ if (
1429
+ not ok
1430
+ and not self._codex_should_inherit_model(requested_model)
1431
+ and self._codex_model_unavailable(stdout_text, stderr_text)
1432
+ ):
1433
+ details["model_fallback_attempted"] = True
1434
+ fallback_command, fallback_result, fallback_timeout = run_probe_once("inherit")
1435
+ details["initial_probe_command"] = command
1436
+ details["initial_exit_code"] = result.returncode
1437
+ details["initial_stdout_excerpt"] = self._compact_probe_text(stdout_text)
1438
+ details["initial_stderr_excerpt"] = self._compact_probe_text(stderr_text)
1439
+ details["fallback_probe_command"] = fallback_command
1440
+ if fallback_timeout is None and fallback_result is not None:
1441
+ fallback_stdout_text = (fallback_result.stdout or "").strip()
1442
+ fallback_stderr_text = (fallback_result.stderr or "").strip()
1443
+ fallback_hello_seen = "HELLO" in fallback_stdout_text.upper()
1444
+ fallback_ok = fallback_result.returncode == 0 and fallback_hello_seen
1445
+ details["fallback_exit_code"] = fallback_result.returncode
1446
+ details["fallback_stdout_excerpt"] = self._compact_probe_text(fallback_stdout_text)
1447
+ details["fallback_stderr_excerpt"] = self._compact_probe_text(fallback_stderr_text)
1448
+ if fallback_ok:
1449
+ details.update(
1450
+ {
1451
+ "exit_code": fallback_result.returncode,
1452
+ "stdout_excerpt": self._compact_probe_text(fallback_stdout_text),
1453
+ "stderr_excerpt": self._compact_probe_text(fallback_stderr_text),
1454
+ "probe_command": fallback_command,
1455
+ "effective_model": "inherit",
1456
+ "model_fallback_used": True,
1457
+ }
1458
+ )
1459
+ fallback_warning = (
1460
+ f"Configured Codex model `{requested_model}` is not available. "
1461
+ "DeepScientist fell back to the current Codex default model."
1462
+ )
1463
+ return {
1464
+ "ok": True,
1465
+ "summary": "Codex startup probe completed with Codex default model fallback.",
1466
+ "warnings": [fallback_warning],
1467
+ "errors": [],
1468
+ "details": details,
1469
+ "guidance": [
1470
+ "DeepScientist switched the Codex runner model to `inherit` so future runs keep using the current Codex default model.",
1471
+ ],
1472
+ }
1473
+ else:
1474
+ details["fallback_exit_code"] = None
1475
+ details["fallback_stdout_excerpt"] = self._compact_probe_text((fallback_timeout.stdout if fallback_timeout else "") or "")
1476
+ details["fallback_stderr_excerpt"] = self._compact_probe_text((fallback_timeout.stderr if fallback_timeout else "") or "")
1477
+
1176
1478
  details.update(
1177
1479
  {
1178
1480
  "exit_code": result.returncode,
@@ -1189,17 +1491,17 @@ Use **Test** when the file exposes runtime dependencies.
1189
1491
  errors.append("Codex responded, but the reply did not contain the expected `HELLO` marker.")
1190
1492
  if stderr_text:
1191
1493
  warnings.append("Codex returned stderr during the startup probe.")
1192
- errors.append("Run `codex` once and complete login before starting DeepScientist.")
1494
+ if details.get("model_fallback_attempted") and not details.get("model_fallback_used"):
1495
+ warnings.append("DeepScientist also tried the current Codex default model, but that fallback probe did not succeed.")
1496
+ errors.extend(self._codex_probe_failure_guidance(config)[0])
1497
+ failure_guidance = self._codex_probe_failure_guidance(config)[1]
1193
1498
  return {
1194
1499
  "ok": ok,
1195
1500
  "summary": "Codex startup probe completed." if ok else "Codex startup probe failed.",
1196
1501
  "warnings": warnings,
1197
1502
  "errors": errors,
1198
1503
  "details": details,
1199
- "guidance": [] if ok else [
1200
- "Run `codex` in a terminal and complete login or first-run setup.",
1201
- "Then start DeepScientist again.",
1202
- ],
1504
+ "guidance": [] if ok else failure_guidance,
1203
1505
  }
1204
1506
 
1205
1507
  def _persist_codex_bootstrap_result(self, result: dict) -> None:
@@ -1216,7 +1518,12 @@ Use **Test** when the file exposes runtime dependencies.
1216
1518
  "guidance": list(result.get("guidance") or []),
1217
1519
  "binary": details.get("binary"),
1218
1520
  "resolved_binary": details.get("resolved_binary"),
1521
+ "profile": details.get("profile"),
1219
1522
  "model": details.get("model"),
1523
+ "requested_model": details.get("requested_model"),
1524
+ "effective_model": details.get("effective_model"),
1525
+ "model_fallback_attempted": bool(details.get("model_fallback_attempted")),
1526
+ "model_fallback_used": bool(details.get("model_fallback_used")),
1220
1527
  "approval_policy": details.get("approval_policy"),
1221
1528
  "sandbox_mode": details.get("sandbox_mode"),
1222
1529
  "reasoning_effort": details.get("reasoning_effort"),
@@ -1226,6 +1533,8 @@ Use **Test** when the file exposes runtime dependencies.
1226
1533
  }
1227
1534
  config["bootstrap"] = bootstrap
1228
1535
  self.save_named_payload("config", config)
1536
+ if bool(result.get("ok")) and bool(details.get("model_fallback_used")):
1537
+ self._persist_codex_model_inherit(details.get("requested_model"))
1229
1538
 
1230
1539
  @staticmethod
1231
1540
  def _compact_probe_text(value: str, *, limit: int = 1200) -> str:
@@ -1236,6 +1545,41 @@ Use **Test** when the file exposes runtime dependencies.
1236
1545
 
1237
1546
  def lingzhu_snapshot(self, config: dict | None = None) -> dict:
1238
1547
  resolved = dict(config or self.load_named_normalized("connectors").get("lingzhu") or {})
1548
+ state = self._lingzhu_runtime_state()
1549
+ raw_bindings = self._lingzhu_bindings()
1550
+ last_real_conversation_id = str(state.get("last_conversation_id") or "").strip() or None
1551
+ has_auth_ak = bool(self._secret(resolved, "auth_ak", "auth_ak_env"))
1552
+ passive_conversation_id = (
1553
+ lingzhu_passive_conversation_id(resolved)
1554
+ if bool(resolved.get("enabled", False)) and has_auth_ak
1555
+ else None
1556
+ )
1557
+ effective_binding = (
1558
+ self._lingzhu_effective_binding(raw_bindings, passive_conversation_id)
1559
+ if passive_conversation_id
1560
+ else None
1561
+ )
1562
+ bindings = [effective_binding] if isinstance(effective_binding, dict) and effective_binding.get("quest_id") else []
1563
+ default_target = (
1564
+ {
1565
+ **(
1566
+ build_discovered_target(
1567
+ passive_conversation_id,
1568
+ source="passive_binding",
1569
+ is_default=True,
1570
+ label="Passive binding",
1571
+ quest_id=str((effective_binding or {}).get("quest_id") or "").strip() or None,
1572
+ updated_at=str((effective_binding or {}).get("updated_at") or "").strip() or None,
1573
+ )
1574
+ or {}
1575
+ ),
1576
+ "selectable": True,
1577
+ "is_passive": True,
1578
+ }
1579
+ if passive_conversation_id
1580
+ else None
1581
+ )
1582
+ discovered_targets = [default_target] if isinstance(default_target, dict) and default_target else []
1239
1583
  snapshot: dict[str, object] = {
1240
1584
  "name": "lingzhu",
1241
1585
  "display_mode": "companion_config",
@@ -1243,22 +1587,27 @@ Use **Test** when the file exposes runtime dependencies.
1243
1587
  "transport": "openclaw_sse",
1244
1588
  "enabled": bool(resolved.get("enabled", False)),
1245
1589
  "main_chat_id": None,
1246
- "last_conversation_id": None,
1590
+ "last_conversation_id": passive_conversation_id,
1247
1591
  "inbox_count": 0,
1248
1592
  "outbox_count": 0,
1249
1593
  "ignored_count": 0,
1250
- "binding_count": 0,
1251
- "target_count": 0,
1594
+ "binding_count": len(bindings),
1595
+ "bindings": bindings,
1596
+ "target_count": len(discovered_targets),
1252
1597
  "recent_conversations": [],
1253
1598
  "recent_events": [],
1254
- "discovered_targets": [],
1599
+ "known_targets": [],
1600
+ "discovered_targets": discovered_targets,
1601
+ "default_target": default_target,
1255
1602
  "details": self._lingzhu_snapshot_details(resolved),
1256
1603
  }
1604
+ snapshot["details"]["last_real_conversation_id"] = last_real_conversation_id
1605
+ snapshot["details"]["historical_target_count"] = len(self._lingzhu_recent_conversations(state))
1257
1606
  if not snapshot["enabled"]:
1258
1607
  snapshot["connection_state"] = "disabled"
1259
1608
  snapshot["auth_state"] = "disabled"
1260
1609
  return snapshot
1261
- snapshot["auth_state"] = "ready" if self._secret(resolved, "auth_ak", "auth_ak_env") else "missing_auth_ak"
1610
+ snapshot["auth_state"] = "ready" if has_auth_ak else "missing_auth_ak"
1262
1611
  health_probe = self._probe_lingzhu_health(resolved, timeout=1.5)
1263
1612
  snapshot["details"]["health_probe"] = health_probe
1264
1613
  if health_probe.get("ok", False):
@@ -1269,6 +1618,80 @@ Use **Test** when the file exposes runtime dependencies.
1269
1618
  snapshot["last_error"] = health_probe.get("message")
1270
1619
  return snapshot
1271
1620
 
1621
+ def _lingzhu_runtime_state(self) -> dict[str, object]:
1622
+ payload = read_json(self.home / "logs" / "connectors" / "lingzhu" / "state.json", {})
1623
+ return payload if isinstance(payload, dict) else {}
1624
+
1625
+ def _lingzhu_bindings(self) -> list[dict[str, object]]:
1626
+ payload = read_json(self.home / "logs" / "connectors" / "lingzhu" / "bindings.json", {"bindings": {}})
1627
+ raw_bindings = payload.get("bindings") if isinstance(payload, dict) else {}
1628
+ if not isinstance(raw_bindings, dict):
1629
+ return []
1630
+ items: list[dict[str, object]] = []
1631
+ for conversation_id, binding in sorted(raw_bindings.items()):
1632
+ if not isinstance(binding, dict):
1633
+ continue
1634
+ normalized_conversation_id = str(conversation_id or "").strip()
1635
+ if not normalized_conversation_id:
1636
+ continue
1637
+ items.append(
1638
+ {
1639
+ "conversation_id": normalized_conversation_id,
1640
+ "quest_id": str(binding.get("quest_id") or "").strip() or None,
1641
+ "updated_at": str(binding.get("updated_at") or "").strip() or None,
1642
+ "profile_id": None,
1643
+ "profile_label": None,
1644
+ "is_passive": lingzhu_is_passive_conversation_id(normalized_conversation_id),
1645
+ }
1646
+ )
1647
+ return items
1648
+
1649
+ @staticmethod
1650
+ def _lingzhu_effective_binding(
1651
+ bindings: list[dict[str, object]],
1652
+ passive_conversation_id: str | None,
1653
+ ) -> dict[str, object] | None:
1654
+ normalized_passive_conversation_id = str(passive_conversation_id or "").strip()
1655
+ if not normalized_passive_conversation_id:
1656
+ return None
1657
+ candidate_bindings = [
1658
+ dict(item)
1659
+ for item in bindings
1660
+ if isinstance(item, dict) and str(item.get("quest_id") or "").strip()
1661
+ ]
1662
+ if not candidate_bindings:
1663
+ return None
1664
+ selected = max(
1665
+ candidate_bindings,
1666
+ key=lambda item: (
1667
+ str(item.get("updated_at") or ""),
1668
+ str(item.get("quest_id") or ""),
1669
+ str(item.get("conversation_id") or ""),
1670
+ ),
1671
+ )
1672
+ return {
1673
+ "conversation_id": normalized_passive_conversation_id,
1674
+ "quest_id": str(selected.get("quest_id") or "").strip() or None,
1675
+ "updated_at": str(selected.get("updated_at") or "").strip() or None,
1676
+ "profile_id": None,
1677
+ "profile_label": None,
1678
+ "is_passive": True,
1679
+ }
1680
+
1681
+ @staticmethod
1682
+ def _lingzhu_recent_conversations(state: dict[str, object]) -> list[dict[str, object]]:
1683
+ items = state.get("recent_conversations")
1684
+ if not isinstance(items, list):
1685
+ return []
1686
+ return [dict(item) for item in items if isinstance(item, dict)]
1687
+
1688
+ @staticmethod
1689
+ def _lingzhu_known_targets(state: dict[str, object]) -> list[dict[str, object]]:
1690
+ items = state.get("known_targets")
1691
+ if not isinstance(items, list):
1692
+ return []
1693
+ return [dict(item) for item in items if isinstance(item, dict)]
1694
+
1272
1695
  def _lingzhu_snapshot_details(self, config: dict) -> dict:
1273
1696
  auth_ak = self._secret(config, "auth_ak", "auth_ak_env")
1274
1697
  return {
@@ -1395,8 +1818,18 @@ Use **Test** when the file exposes runtime dependencies.
1395
1818
  if connector_name in PROFILEABLE_CONNECTOR_NAMES:
1396
1819
  normalized[connector_name] = normalize_connector_config(connector_name, {**base, **sanitized_payload})
1397
1820
  continue
1821
+ elif connector_name == "weixin":
1822
+ sanitized_payload["transport"] = "ilink_long_poll"
1823
+ merged = {**base, **sanitized_payload}
1824
+ merged["enabled"] = self._weixin_auto_enabled(merged)
1825
+ normalized[connector_name] = merged
1826
+ continue
1398
1827
  elif connector_name == "lingzhu":
1399
1828
  sanitized_payload["transport"] = "openclaw_sse"
1829
+ merged = {**base, **sanitized_payload}
1830
+ merged["enabled"] = self._lingzhu_auto_enabled(merged)
1831
+ normalized[connector_name] = merged
1832
+ continue
1400
1833
  elif "transport" not in sanitized_payload:
1401
1834
  inferred_transport = infer_connector_transport(connector_name, {**base, **sanitized_payload})
1402
1835
  if inferred_transport:
@@ -1481,6 +1914,45 @@ Use **Test** when the file exposes runtime dependencies.
1481
1914
  return False
1482
1915
  return bool(value)
1483
1916
 
1917
+ @staticmethod
1918
+ def _connector_has_secret(payload: dict[str, object], direct_key: str, env_key: str) -> bool:
1919
+ return bool(str(payload.get(direct_key) or "").strip() or str(payload.get(env_key) or "").strip())
1920
+
1921
+ def _weixin_auto_enabled(self, payload: dict[str, object]) -> bool:
1922
+ return self._connector_has_secret(payload, "bot_token", "bot_token_env") and bool(
1923
+ str(payload.get("account_id") or "").strip()
1924
+ )
1925
+
1926
+ def _lingzhu_auto_enabled(self, payload: dict[str, object]) -> bool:
1927
+ auth_ready = self._connector_has_secret(payload, "auth_ak", "auth_ak_env")
1928
+ public_base_url = str(payload.get("public_base_url") or "").strip()
1929
+ if not auth_ready or not public_base_url:
1930
+ return False
1931
+ normalized_public_base_url = lingzhu_public_base_url(payload)
1932
+ if normalized_public_base_url is None:
1933
+ return False
1934
+ return public_base_url_looks_public(normalized_public_base_url)
1935
+
1936
+ def _connector_has_user_config(self, name: str, config: dict[str, object]) -> bool:
1937
+ if name == "qq":
1938
+ return bool(list_qq_profiles(config))
1939
+ if name in PROFILEABLE_CONNECTOR_NAMES:
1940
+ return bool(list_connector_profiles(name, config))
1941
+ if name == "weixin":
1942
+ return any(
1943
+ str(config.get(key) or "").strip()
1944
+ for key in ("bot_token", "bot_token_env", "account_id", "login_user_id", "route_tag")
1945
+ )
1946
+ if name == "lingzhu":
1947
+ return any(
1948
+ str(config.get(key) or "").strip()
1949
+ for key in ("auth_ak", "auth_ak_env", "public_base_url")
1950
+ )
1951
+ return False
1952
+
1953
+ def _should_validate_connector(self, name: str, config: dict[str, object]) -> bool:
1954
+ return bool(config.get("enabled", False)) or self._connector_has_user_config(name, config)
1955
+
1484
1956
  def _normalize_plugins_payload(self, payload: dict) -> dict:
1485
1957
  normalized = deepcopy(payload)
1486
1958
  if "load_paths" not in normalized and isinstance(normalized.get("search_paths"), list):