@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
@@ -1,13 +1,19 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import base64
4
+ from collections import deque
5
+ import faulthandler
4
6
  import json
5
7
  import mimetypes
6
8
  import os
9
+ import re
10
+ import signal
7
11
  import shutil
8
12
  import subprocess
13
+ import sys
9
14
  import threading
10
15
  import time
16
+ import traceback
11
17
  from datetime import UTC, datetime, timedelta
12
18
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
13
19
  from pathlib import Path
@@ -16,6 +22,7 @@ from urllib.parse import parse_qs, urlencode, urlparse
16
22
  from urllib.request import Request
17
23
 
18
24
  from .. import __version__
25
+ from ..annotations import AnnotationService
19
26
  from ..artifact import ArtifactService
20
27
  from ..bash_exec import BashExecService
21
28
  from ..bash_exec.runtime import TerminalClient
@@ -27,9 +34,10 @@ from ..channels.feishu_long_connection import FeishuLongConnectionService
27
34
  from ..channels.qq_gateway import QQGatewayService
28
35
  from ..channels.slack_socket import SlackSocketModeService
29
36
  from ..channels.telegram_polling import TelegramPollingService
37
+ from ..channels.weixin_ilink import WeixinIlinkService
30
38
  from ..channels.whatsapp_local_session import WhatsAppLocalSessionService
31
39
  from ..cloud import CloudLinkService
32
- from ..connector_profiles import (
40
+ from ..connector.connector_profiles import (
33
41
  CONNECTOR_PROFILE_SPECS,
34
42
  PROFILEABLE_CONNECTOR_NAMES,
35
43
  connector_profile_label,
@@ -44,15 +52,36 @@ from ..home import repo_root
44
52
  from ..memory import MemoryService
45
53
  from ..network import urlopen_with_proxy as urlopen
46
54
  from ..latex_runtime import QuestLatexService
55
+ from ..connector.lingzhu_support import (
56
+ lingzhu_detect_tool_call_from_text,
57
+ lingzhu_extract_task_text,
58
+ lingzhu_extract_user_text,
59
+ lingzhu_health_payload,
60
+ lingzhu_is_passive_conversation_id,
61
+ lingzhu_passive_conversation_id,
62
+ lingzhu_request_conversation_id,
63
+ lingzhu_request_sender_id,
64
+ lingzhu_sse_answer,
65
+ lingzhu_sse_tool_call,
66
+ lingzhu_surface_action_tool_call,
67
+ lingzhu_verify_auth_header,
68
+ )
47
69
  from ..prompts import PromptBuilder
48
70
  from ..prompts.builder import STANDARD_SKILLS
49
- from ..qq_profiles import list_qq_profiles, merge_qq_profile_config, normalize_qq_connector_config
71
+ from ..connector.qq_profiles import list_qq_profiles, merge_qq_profile_config, normalize_qq_connector_config
50
72
  from ..quest import QuestService
51
73
  from ..runners import CodexRunner, RunRequest, get_runner_factory, register_builtin_runners
52
74
  from ..runtime_logs import JsonlLogger
53
- from ..shared import append_jsonl, ensure_dir, generate_id, read_json, read_jsonl, read_text, resolve_within, run_command, slugify, utc_now, which, write_json
75
+ from ..shared import append_jsonl, ensure_dir, generate_id, iter_jsonl, read_json, read_jsonl, read_jsonl_tail, read_text, resolve_within, run_command, slugify, utc_now, which, write_json
54
76
  from ..skills import SkillInstaller
55
77
  from ..team import SingleTeamService
78
+ from ..connector.weixin_support import (
79
+ DEFAULT_WEIXIN_BOT_TYPE,
80
+ fetch_weixin_qrcode,
81
+ normalize_weixin_base_url,
82
+ normalize_weixin_cdn_base_url,
83
+ poll_weixin_qrcode_status,
84
+ )
56
85
  from .api import ApiHandlers, match_route
57
86
  from .sessions import SessionStore
58
87
  from websockets.datastructures import Headers
@@ -63,6 +92,7 @@ from websockets.sync.server import Server as WebSocketServer
63
92
  from websockets.sync.server import ServerConnection, serve as websocket_serve
64
93
 
65
94
  TERMINAL_STREAM_IDLE_SLEEP_SECONDS = 0.02
95
+ _AUTO_CONTINUE_DELAY_SECONDS = 0.2
66
96
  CODEX_RETRY_DEFAULT_MAX_ATTEMPTS = 5
67
97
  CODEX_RETRY_DEFAULT_INITIAL_BACKOFF_SEC = 10.0
68
98
  CODEX_RETRY_DEFAULT_BACKOFF_MULTIPLIER = 6.0
@@ -70,6 +100,25 @@ CODEX_RETRY_DEFAULT_MAX_BACKOFF_SEC = 1800.0
70
100
  LEGACY_CODEX_RETRY_INITIAL_BACKOFF_SEC = 1.0
71
101
  LEGACY_CODEX_RETRY_BACKOFF_MULTIPLIER = 2.0
72
102
  LEGACY_CODEX_RETRY_MAX_BACKOFF_SEC = 8.0
103
+ _CRASH_AUTO_RESUME_COOLDOWN = timedelta(minutes=10)
104
+ _CRASH_AUTO_RESUME_MAX_RECENT_ATTEMPTS = 2
105
+ _LINGZHU_SHORT_COMMAND_DIRECT_MAP = {
106
+ "帮助": "help",
107
+ "列表": "list",
108
+ "状态": "status",
109
+ "总结": "summary",
110
+ "图谱": "graph",
111
+ "指标": "metrics",
112
+ }
113
+ _LINGZHU_SHORT_COMMAND_PREFIX_MAP = {
114
+ "绑定": "use",
115
+ "新建": "new",
116
+ "删除": "delete",
117
+ "暂停": "stop",
118
+ "恢复": "resume",
119
+ }
120
+ _LINGZHU_SHORT_LATEST_ALIASES = {"latest", "newest", "最新"}
121
+ _LINGZHU_DELETE_CONFIRM_ALIASES = {"确认", "强制", "--yes", "-y"}
73
122
 
74
123
 
75
124
  class DaemonApp:
@@ -88,6 +137,7 @@ class DaemonApp:
88
137
  self.quest_service = QuestService(home, skill_installer=self.skill_installer)
89
138
  self.latex_service = QuestLatexService(self.quest_service)
90
139
  self.memory_service = MemoryService(home)
140
+ self.annotation_service = AnnotationService(home)
91
141
  self.artifact_service = ArtifactService(home)
92
142
  self.bash_exec_service = BashExecService(home)
93
143
  self.team_service = SingleTeamService(home)
@@ -127,6 +177,7 @@ class DaemonApp:
127
177
  }
128
178
  self.channels = {name: self._create_channel(name) for name in list_channel_names()}
129
179
  self.sessions = SessionStore()
180
+ self._canonicalize_lingzhu_binding_state()
130
181
  self._turn_lock = threading.Lock()
131
182
  self._turn_state: dict[str, dict[str, object]] = {}
132
183
  self._server: ThreadingHTTPServer | None = None
@@ -138,11 +189,16 @@ class DaemonApp:
138
189
  self._serve_port: int | None = None
139
190
  self._shutdown_requested = threading.Event()
140
191
  self._qq_gateways: dict[str, QQGatewayService] = {}
192
+ self._weixin_ilink: WeixinIlinkService | None = None
141
193
  self._telegram_polling: dict[str, TelegramPollingService] = {}
142
194
  self._slack_socket: dict[str, SlackSocketModeService] = {}
143
195
  self._discord_gateway: dict[str, DiscordGatewayService] = {}
144
196
  self._feishu_long_connection: dict[str, FeishuLongConnectionService] = {}
145
197
  self._whatsapp_local_session: dict[str, WhatsAppLocalSessionService] = {}
198
+ self._weixin_login_sessions: dict[str, dict[str, Any]] = {}
199
+ self._process_hooks_installed = False
200
+ self._faulthandler_stream = None
201
+ self._recovered_quest_ids: set[str] = set()
146
202
  self.handlers = ApiHandlers(self)
147
203
 
148
204
  def list_connector_statuses(self) -> list[dict[str, object]]:
@@ -150,10 +206,10 @@ class DaemonApp:
150
206
  items = [
151
207
  self._augment_connector_status(channel.status(), title_by_quest=title_by_quest)
152
208
  for name, channel in self.channels.items()
153
- if name == "local" or self._is_connector_system_enabled(name)
209
+ if name == "local" or (name != "lingzhu" and self._is_connector_system_enabled(name))
154
210
  ]
155
211
  lingzhu_config = self.connectors_config.get("lingzhu")
156
- if isinstance(lingzhu_config, dict) and self._is_connector_system_enabled("lingzhu"):
212
+ if isinstance(lingzhu_config, dict):
157
213
  items.append(self._augment_connector_status(self.config_manager.lingzhu_snapshot(lingzhu_config), title_by_quest=title_by_quest))
158
214
  return items
159
215
 
@@ -333,6 +389,262 @@ class DaemonApp:
333
389
  "available_connectors": available_connectors,
334
390
  }
335
391
 
392
+ def _log_unhandled_exception(
393
+ self,
394
+ *,
395
+ event_type: str,
396
+ exc_type: type[BaseException],
397
+ exc_value: BaseException,
398
+ exc_traceback: Any,
399
+ thread_name: str | None = None,
400
+ ) -> None:
401
+ try:
402
+ traceback_text = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback))
403
+ except Exception:
404
+ traceback_text = str(exc_value or "")
405
+ payload: dict[str, Any] = {
406
+ "exception_type": getattr(exc_type, "__name__", str(exc_type)),
407
+ "message": str(exc_value),
408
+ "traceback": traceback_text,
409
+ "pid": os.getpid(),
410
+ }
411
+ if thread_name:
412
+ payload["thread_name"] = thread_name
413
+ self.logger.log("error", event_type, **payload)
414
+
415
+ def _handle_process_signal(self, signame: str) -> None:
416
+ self.logger.log(
417
+ "warning",
418
+ "daemon.signal_received",
419
+ signal=signame,
420
+ pid=os.getpid(),
421
+ )
422
+ self.request_shutdown(source=f"signal:{str(signame).lower()}")
423
+
424
+ def _install_process_observability(self) -> None:
425
+ if self._process_hooks_installed:
426
+ return
427
+ self._process_hooks_installed = True
428
+ faulthandler_path = self.home / "logs" / "daemon-faulthandler.log"
429
+ try:
430
+ ensure_dir(faulthandler_path.parent)
431
+ self._faulthandler_stream = open(faulthandler_path, "a", encoding="utf-8")
432
+ faulthandler.enable(file=self._faulthandler_stream)
433
+ except Exception as exc:
434
+ self.logger.log("warning", "daemon.faulthandler_enable_failed", error=str(exc))
435
+
436
+ def _sys_excepthook(exc_type: type[BaseException], exc_value: BaseException, exc_traceback: Any) -> None:
437
+ self._log_unhandled_exception(
438
+ event_type="daemon.unhandled_exception",
439
+ exc_type=exc_type,
440
+ exc_value=exc_value,
441
+ exc_traceback=exc_traceback,
442
+ )
443
+
444
+ def _thread_excepthook(args: threading.ExceptHookArgs) -> None:
445
+ self._log_unhandled_exception(
446
+ event_type="daemon.thread_exception",
447
+ exc_type=args.exc_type,
448
+ exc_value=args.exc_value,
449
+ exc_traceback=args.exc_traceback,
450
+ thread_name=getattr(args.thread, "name", None),
451
+ )
452
+
453
+ sys.excepthook = _sys_excepthook
454
+ threading.excepthook = _thread_excepthook
455
+ for signame in ("SIGTERM", "SIGHUP", "SIGINT"):
456
+ signum = getattr(signal, signame, None)
457
+ if signum is None:
458
+ continue
459
+ try:
460
+ signal.signal(signum, lambda _signum, _frame, _signame=signame: self._handle_process_signal(_signame))
461
+ except Exception as exc:
462
+ self.logger.log(
463
+ "warning",
464
+ "daemon.signal_handler_install_failed",
465
+ signal=signame,
466
+ error=str(exc),
467
+ )
468
+ self.logger.log("info", "daemon.process_hooks_installed", pid=os.getpid())
469
+
470
+ def _resume_reconciled_quests(self) -> list[dict[str, Any]]:
471
+ resumed: list[dict[str, Any]] = []
472
+ for item in self.reconciled_quests:
473
+ if not isinstance(item, dict):
474
+ continue
475
+ quest_id = str(item.get("quest_id") or "").strip()
476
+ if not quest_id or quest_id in self._recovered_quest_ids:
477
+ continue
478
+ if not bool(item.get("recoverable")):
479
+ continue
480
+ recent_attempts = self._recent_crash_auto_resume_count(quest_id)
481
+ if recent_attempts >= _CRASH_AUTO_RESUME_MAX_RECENT_ATTEMPTS:
482
+ self._record_auto_resume_suppressed(
483
+ quest_id=quest_id,
484
+ previous_status=str(item.get("previous_status") or "").strip() or None,
485
+ abandoned_run_id=str(item.get("abandoned_run_id") or "").strip() or None,
486
+ last_transition_at=str(item.get("last_transition_at") or "").strip() or None,
487
+ recent_attempts=recent_attempts,
488
+ )
489
+ continue
490
+ try:
491
+ resume_payload = self.resume_quest(quest_id, source="auto:daemon-recovery")
492
+ snapshot = (
493
+ dict(resume_payload.get("snapshot") or {})
494
+ if isinstance(resume_payload.get("snapshot"), dict)
495
+ else self.quest_service.snapshot(quest_id)
496
+ )
497
+ reason = (
498
+ "queued_user_messages"
499
+ if int(snapshot.get("pending_user_message_count") or 0) > 0
500
+ else "auto_continue"
501
+ )
502
+ scheduled = self.schedule_turn(quest_id, reason=reason)
503
+ event = {
504
+ "event_id": generate_id("evt"),
505
+ "type": "quest.runtime_auto_resumed",
506
+ "quest_id": quest_id,
507
+ "previous_status": item.get("previous_status"),
508
+ "abandoned_run_id": item.get("abandoned_run_id"),
509
+ "last_transition_at": item.get("last_transition_at"),
510
+ "reason": reason,
511
+ "scheduled": bool(scheduled.get("scheduled")),
512
+ "started": bool(scheduled.get("started")),
513
+ "queued": bool(scheduled.get("queued")),
514
+ "created_at": utc_now(),
515
+ }
516
+ append_jsonl(self.home / "quests" / quest_id / ".ds" / "events.jsonl", event)
517
+ self.logger.log(
518
+ "warning",
519
+ "quest.runtime_auto_resumed",
520
+ quest_id=quest_id,
521
+ previous_status=item.get("previous_status"),
522
+ abandoned_run_id=item.get("abandoned_run_id"),
523
+ last_transition_at=item.get("last_transition_at"),
524
+ reason=reason,
525
+ scheduled=bool(scheduled.get("scheduled")),
526
+ started=bool(scheduled.get("started")),
527
+ queued=bool(scheduled.get("queued")),
528
+ )
529
+ self._recovered_quest_ids.add(quest_id)
530
+ resumed.append(
531
+ {
532
+ "quest_id": quest_id,
533
+ "previous_status": item.get("previous_status"),
534
+ "abandoned_run_id": item.get("abandoned_run_id"),
535
+ "last_transition_at": item.get("last_transition_at"),
536
+ "reason": reason,
537
+ "scheduled": dict(scheduled),
538
+ }
539
+ )
540
+ except Exception as exc:
541
+ self.logger.log(
542
+ "warning",
543
+ "quest.runtime_auto_resume_failed",
544
+ quest_id=quest_id,
545
+ previous_status=item.get("previous_status"),
546
+ abandoned_run_id=item.get("abandoned_run_id"),
547
+ error=str(exc),
548
+ )
549
+ return resumed
550
+
551
+ @staticmethod
552
+ def _parse_event_timestamp(value: Any) -> datetime | None:
553
+ normalized = str(value or "").strip()
554
+ if not normalized:
555
+ return None
556
+ candidate = normalized.replace("Z", "+00:00")
557
+ try:
558
+ parsed = datetime.fromisoformat(candidate)
559
+ except ValueError:
560
+ return None
561
+ if parsed.tzinfo is None:
562
+ parsed = parsed.replace(tzinfo=UTC)
563
+ return parsed.astimezone(UTC)
564
+
565
+ def _recent_crash_auto_resume_count(self, quest_id: str) -> int:
566
+ quest_root = self.home / "quests" / quest_id
567
+ events = read_jsonl_tail(quest_root / ".ds" / "events.jsonl", 400)
568
+ now = datetime.now(UTC)
569
+ count = 0
570
+ for event in reversed(events[-200:]):
571
+ if str(event.get("type") or "").strip() != "quest.runtime_auto_resumed":
572
+ continue
573
+ parsed = self._parse_event_timestamp(event.get("created_at"))
574
+ if parsed is None:
575
+ continue
576
+ if now - parsed > _CRASH_AUTO_RESUME_COOLDOWN:
577
+ continue
578
+ count += 1
579
+ return count
580
+
581
+ def _record_auto_resume_suppressed(
582
+ self,
583
+ *,
584
+ quest_id: str,
585
+ previous_status: str | None,
586
+ abandoned_run_id: str | None,
587
+ last_transition_at: str | None,
588
+ recent_attempts: int,
589
+ ) -> None:
590
+ summary = self._polite_copy(
591
+ zh=(
592
+ "检测到 quest 在短时间内连续异常恢复,系统已暂时停止自动继续运行,"
593
+ "以避免进入重复崩溃循环。请先检查运行环境或 runner 侧问题,再手动恢复。"
594
+ ),
595
+ en=(
596
+ "DeepScientist detected repeated crash recovery attempts in a short window, "
597
+ "so automatic continuation has been paused to avoid a crash loop. "
598
+ "Inspect the runtime or runner path before resuming manually."
599
+ ),
600
+ )
601
+ payload = {
602
+ "event_id": generate_id("evt"),
603
+ "type": "quest.runtime_auto_resume_suppressed",
604
+ "quest_id": quest_id,
605
+ "previous_status": previous_status,
606
+ "abandoned_run_id": abandoned_run_id,
607
+ "last_transition_at": last_transition_at,
608
+ "recent_attempts": recent_attempts,
609
+ "cooldown_minutes": int(_CRASH_AUTO_RESUME_COOLDOWN.total_seconds() // 60),
610
+ "summary": summary,
611
+ "created_at": utc_now(),
612
+ }
613
+ append_jsonl(self.home / "quests" / quest_id / ".ds" / "events.jsonl", payload)
614
+ self.logger.log(
615
+ "warning",
616
+ "quest.runtime_auto_resume_suppressed",
617
+ quest_id=quest_id,
618
+ previous_status=previous_status,
619
+ abandoned_run_id=abandoned_run_id,
620
+ last_transition_at=last_transition_at,
621
+ recent_attempts=recent_attempts,
622
+ cooldown_minutes=int(_CRASH_AUTO_RESUME_COOLDOWN.total_seconds() // 60),
623
+ )
624
+ self.quest_service.append_message(
625
+ quest_id,
626
+ role="assistant",
627
+ content=summary,
628
+ source="system-control",
629
+ )
630
+ self._relay_quest_message_to_bound_connectors(
631
+ quest_id,
632
+ message=summary,
633
+ kind="progress",
634
+ response_phase="control",
635
+ importance="warning",
636
+ attachments=[
637
+ {
638
+ "kind": "quest_control",
639
+ "action": "auto_resume_suppressed",
640
+ "previous_status": previous_status,
641
+ "abandoned_run_id": abandoned_run_id,
642
+ "recent_attempts": recent_attempts,
643
+ "cooldown_minutes": int(_CRASH_AUTO_RESUME_COOLDOWN.total_seconds() // 60),
644
+ }
645
+ ],
646
+ )
647
+
336
648
  def _normalize_requested_connector_bindings(
337
649
  self,
338
650
  requested_connector_bindings: list[dict[str, object]] | None,
@@ -858,13 +1170,15 @@ class DaemonApp:
858
1170
  return {
859
1171
  name
860
1172
  for name in SYSTEM_CONNECTOR_NAMES
861
- if bool(system_enabled.get(name, name == "qq"))
1173
+ if bool(system_enabled.get(name, name in {"qq", "weixin"}))
862
1174
  }
863
1175
 
864
1176
  def _is_connector_system_enabled(self, connector_name: str) -> bool:
865
1177
  normalized = str(connector_name or "").strip().lower()
866
1178
  if normalized == "local":
867
1179
  return True
1180
+ if normalized == "lingzhu":
1181
+ return True
868
1182
  enabled = self._system_enabled_connector_names()
869
1183
  if normalized in enabled:
870
1184
  return True
@@ -999,10 +1313,13 @@ class DaemonApp:
999
1313
  preferred_connector_conversation_id: str | None = None,
1000
1314
  requested_connector_bindings: list[dict[str, object]] | None = None,
1001
1315
  force_connector_rebind: bool = True,
1316
+ auto_bind_latest_connectors: bool = True,
1002
1317
  requested_baseline_ref: dict[str, object] | None = None,
1003
1318
  startup_contract: dict[str, object] | None = None,
1004
1319
  ) -> dict:
1005
1320
  normalized_requested_bindings = self._normalize_requested_connector_bindings(requested_connector_bindings)
1321
+ if len(normalized_requested_bindings) > 1:
1322
+ raise ValueError("A quest may bind at most one external connector target.")
1006
1323
  snapshot = self.quest_service.create(
1007
1324
  goal=goal,
1008
1325
  title=title,
@@ -1126,7 +1443,7 @@ class DaemonApp:
1126
1443
  ),
1127
1444
  }
1128
1445
  )
1129
- else:
1446
+ elif auto_bind_latest_connectors:
1130
1447
  self._auto_bind_connectors_to_latest_quest(
1131
1448
  snapshot["quest_id"],
1132
1449
  goal=goal,
@@ -1311,6 +1628,7 @@ class DaemonApp:
1311
1628
  status=str(snapshot.get("status") or next_status),
1312
1629
  interrupted=False,
1313
1630
  summary=summary,
1631
+ automated=source.startswith("auto:"),
1314
1632
  )
1315
1633
  notice = self._announce_control_state(
1316
1634
  quest_id,
@@ -1375,6 +1693,7 @@ class DaemonApp:
1375
1693
  message = self._control_notice_message(
1376
1694
  quest_id,
1377
1695
  action=action,
1696
+ source=source,
1378
1697
  snapshot=snapshot,
1379
1698
  interrupted=interrupted,
1380
1699
  cancelled_pending_user_message_count=cancelled_pending_user_message_count,
@@ -1435,6 +1754,7 @@ class DaemonApp:
1435
1754
  quest_id: str,
1436
1755
  *,
1437
1756
  action: str,
1757
+ source: str,
1438
1758
  snapshot: dict,
1439
1759
  interrupted: bool,
1440
1760
  cancelled_pending_user_message_count: int,
@@ -1453,6 +1773,13 @@ class DaemonApp:
1453
1773
  en="The current Git branch and worktree were kept intact, and the quest will continue from the existing research context.",
1454
1774
  ),
1455
1775
  ]
1776
+ if source.startswith("auto:daemon-recovery"):
1777
+ lines.append(
1778
+ self._polite_copy(
1779
+ zh="检测到 daemon 曾异常退出;当前 quest 已在自动恢复后继续运行。",
1780
+ en="The daemon exited unexpectedly before; this quest has now been recovered automatically and will continue.",
1781
+ )
1782
+ )
1456
1783
  elif action == "pause":
1457
1784
  lines = [
1458
1785
  self._polite_copy(
@@ -2106,8 +2433,12 @@ class DaemonApp:
2106
2433
  snapshot = self.quest_service.snapshot(quest_id)
2107
2434
  quest_root = Path(snapshot["quest_root"])
2108
2435
  workspace_root = Path(str(snapshot.get("current_workspace_root") or snapshot.get("quest_root") or quest_root))
2109
- quest_events = read_jsonl(quest_root / ".ds" / "events.jsonl")
2110
- run_events = [event for event in quest_events if str(event.get("run_id") or "").strip() == failed_run_id][-120:]
2436
+ run_events_window: deque[dict[str, Any]] = deque(maxlen=120)
2437
+ for event in iter_jsonl(quest_root / ".ds" / "events.jsonl"):
2438
+ if str(event.get("run_id") or "").strip() != failed_run_id:
2439
+ continue
2440
+ run_events_window.append(event)
2441
+ run_events = list(run_events_window)
2111
2442
 
2112
2443
  recent_messages: list[str] = []
2113
2444
  tool_progress: list[dict[str, str]] = []
@@ -2267,12 +2598,29 @@ class DaemonApp:
2267
2598
  if int(snapshot.get("pending_user_message_count") or 0) > 0:
2268
2599
  self.schedule_turn(quest_id, reason="queued_user_messages")
2269
2600
  else:
2270
- self.schedule_turn(quest_id, reason="auto_continue")
2601
+ self._schedule_turn_later(quest_id, reason="auto_continue", delay_seconds=_AUTO_CONTINUE_DELAY_SECONDS)
2271
2602
  return
2272
2603
  if int(snapshot.get("pending_user_message_count") or 0) > 0:
2273
2604
  self.schedule_turn(quest_id, reason="queued_user_messages")
2274
2605
  return
2275
- self.schedule_turn(quest_id, reason="auto_continue")
2606
+ self._schedule_turn_later(quest_id, reason="auto_continue", delay_seconds=_AUTO_CONTINUE_DELAY_SECONDS)
2607
+
2608
+ def _schedule_turn_later(self, quest_id: str, *, reason: str, delay_seconds: float) -> None:
2609
+ def _delayed() -> None:
2610
+ time.sleep(max(0.0, delay_seconds))
2611
+ if self._turn_stop_requested(quest_id):
2612
+ return
2613
+ snapshot = self.quest_service.snapshot(quest_id)
2614
+ status = str(snapshot.get("status") or snapshot.get("runtime_status") or "").strip().lower()
2615
+ if status in {"completed", "paused", "stopped", "error", "waiting_for_user"}:
2616
+ return
2617
+ self.schedule_turn(quest_id, reason=reason)
2618
+
2619
+ threading.Thread(
2620
+ target=_delayed,
2621
+ daemon=True,
2622
+ name=f"deepscientist-turn-delay-{quest_id}",
2623
+ ).start()
2276
2624
 
2277
2625
  def _relay_quest_message_to_bound_connectors(
2278
2626
  self,
@@ -2370,6 +2718,176 @@ class DaemonApp:
2370
2718
  channel = self._channel_with_bindings(connector_name)
2371
2719
  return channel.list_bindings()
2372
2720
 
2721
+ def start_weixin_login_qr(self, *, force: bool = False) -> dict[str, Any]:
2722
+ connectors = self.config_manager.load_named_normalized("connectors")
2723
+ weixin = connectors.get("weixin") if isinstance(connectors.get("weixin"), dict) else {}
2724
+ base_url = normalize_weixin_base_url(weixin.get("base_url"))
2725
+ bot_type = str(weixin.get("bot_type") or DEFAULT_WEIXIN_BOT_TYPE).strip() or DEFAULT_WEIXIN_BOT_TYPE
2726
+ route_tag = str(weixin.get("route_tag") or "").strip() or None
2727
+ qr_payload = fetch_weixin_qrcode(base_url=base_url, bot_type=bot_type, route_tag=route_tag)
2728
+ qrcode_token = str(qr_payload.get("qrcode") or "").strip()
2729
+ qrcode_content = str(qr_payload.get("qrcode_img_content") or qr_payload.get("url") or "").strip()
2730
+ if not qrcode_token or not qrcode_content:
2731
+ raise RuntimeError("Weixin QR login did not return a valid qrcode token or renderable content.")
2732
+ session_key = generate_id("wxqr")
2733
+ self._weixin_login_sessions[session_key] = {
2734
+ "session_key": session_key,
2735
+ "qrcode": qrcode_token,
2736
+ "qrcode_content": qrcode_content,
2737
+ "base_url": base_url,
2738
+ "bot_type": bot_type,
2739
+ "route_tag": route_tag,
2740
+ "started_at": time.time(),
2741
+ "refresh_count": 0,
2742
+ "force": bool(force),
2743
+ }
2744
+ return {
2745
+ "ok": True,
2746
+ "session_key": session_key,
2747
+ "qrcode_content": qrcode_content,
2748
+ "qrcode_url": qrcode_content,
2749
+ "message": "Weixin QR code is ready. Scan it with WeChat to connect DeepScientist.",
2750
+ }
2751
+
2752
+ def wait_weixin_login_qr(self, *, session_key: str, timeout_ms: int = 1_500) -> dict[str, Any]:
2753
+ normalized_session_key = str(session_key or "").strip()
2754
+ if not normalized_session_key:
2755
+ return {
2756
+ "ok": False,
2757
+ "connected": False,
2758
+ "message": "Weixin QR session key is required.",
2759
+ }
2760
+ session = self._weixin_login_sessions.get(normalized_session_key)
2761
+ if not isinstance(session, dict):
2762
+ return {
2763
+ "ok": False,
2764
+ "connected": False,
2765
+ "message": "Weixin QR session was not found. Start a new login first.",
2766
+ }
2767
+
2768
+ deadline = time.time() + max(int(timeout_ms or 1_500), 500) / 1000.0
2769
+ while time.time() < deadline:
2770
+ remaining = max(deadline - time.time(), 1.0)
2771
+ try:
2772
+ status = poll_weixin_qrcode_status(
2773
+ base_url=str(session.get("base_url") or ""),
2774
+ qrcode=str(session.get("qrcode") or ""),
2775
+ route_tag=str(session.get("route_tag") or "").strip() or None,
2776
+ timeout=min(remaining, 35.0),
2777
+ )
2778
+ except Exception as exc:
2779
+ message = str(exc or "").strip().lower()
2780
+ if isinstance(exc, TimeoutError) or "timed out" in message or "timeout" in message:
2781
+ break
2782
+ raise
2783
+ state = str(status.get("status") or "wait").strip().lower() or "wait"
2784
+ session["status"] = state
2785
+ if state == "confirmed":
2786
+ return self._persist_weixin_login_session(session, status)
2787
+ if state == "expired":
2788
+ refreshed = self._refresh_weixin_login_session(session)
2789
+ return {
2790
+ "ok": True,
2791
+ "connected": False,
2792
+ "status": "expired",
2793
+ "session_key": normalized_session_key,
2794
+ "qrcode_content": refreshed.get("qrcode_content"),
2795
+ "qrcode_url": refreshed.get("qrcode_content"),
2796
+ "message": "Weixin QR code expired and was refreshed automatically.",
2797
+ }
2798
+ if state in {"scaned", "scanned"}:
2799
+ return {
2800
+ "ok": True,
2801
+ "connected": False,
2802
+ "status": "scaned",
2803
+ "session_key": normalized_session_key,
2804
+ "qrcode_content": str(session.get("qrcode_content") or "").strip() or None,
2805
+ "qrcode_url": str(session.get("qrcode_content") or "").strip() or None,
2806
+ "message": "QR code scanned. Confirm the login inside WeChat.",
2807
+ }
2808
+ return {
2809
+ "ok": True,
2810
+ "connected": False,
2811
+ "status": str(session.get("status") or "wait").strip() or "wait",
2812
+ "session_key": normalized_session_key,
2813
+ "qrcode_content": str(session.get("qrcode_content") or "").strip() or None,
2814
+ "qrcode_url": str(session.get("qrcode_content") or "").strip() or None,
2815
+ "message": "Waiting for Weixin QR confirmation.",
2816
+ }
2817
+
2818
+ def _refresh_weixin_login_session(self, session: dict[str, Any]) -> dict[str, Any]:
2819
+ qr_payload = fetch_weixin_qrcode(
2820
+ base_url=str(session.get("base_url") or ""),
2821
+ bot_type=str(session.get("bot_type") or DEFAULT_WEIXIN_BOT_TYPE),
2822
+ route_tag=str(session.get("route_tag") or "").strip() or None,
2823
+ )
2824
+ session["qrcode"] = str(qr_payload.get("qrcode") or "").strip()
2825
+ session["qrcode_content"] = str(qr_payload.get("qrcode_img_content") or qr_payload.get("url") or "").strip()
2826
+ session["started_at"] = time.time()
2827
+ session["refresh_count"] = int(session.get("refresh_count") or 0) + 1
2828
+ return session
2829
+
2830
+ def _persist_weixin_login_session(self, session: dict[str, Any], status: dict[str, Any]) -> dict[str, Any]:
2831
+ bot_token = str(status.get("bot_token") or "").strip()
2832
+ account_id = str(status.get("ilink_bot_id") or "").strip()
2833
+ login_user_id = str(status.get("ilink_user_id") or "").strip() or None
2834
+ if not bot_token or not account_id:
2835
+ return {
2836
+ "ok": False,
2837
+ "connected": False,
2838
+ "status": "confirmed",
2839
+ "message": "Weixin QR login confirmed, but the platform did not return `bot_token` or `ilink_bot_id`.",
2840
+ }
2841
+ connectors = self.config_manager.load_named_normalized("connectors")
2842
+ weixin = connectors.get("weixin") if isinstance(connectors.get("weixin"), dict) else {}
2843
+ weixin.update(
2844
+ {
2845
+ "enabled": True,
2846
+ "transport": "ilink_long_poll",
2847
+ "base_url": normalize_weixin_base_url(status.get("baseurl") or session.get("base_url")),
2848
+ "cdn_base_url": normalize_weixin_cdn_base_url(weixin.get("cdn_base_url")),
2849
+ "bot_type": str(session.get("bot_type") or DEFAULT_WEIXIN_BOT_TYPE),
2850
+ "bot_token": bot_token,
2851
+ "account_id": account_id,
2852
+ "login_user_id": login_user_id,
2853
+ }
2854
+ )
2855
+ connectors["weixin"] = weixin
2856
+ save_result = self.config_manager.save_named_payload("connectors", connectors)
2857
+ if not bool(save_result.get("ok")):
2858
+ self.logger.log(
2859
+ "warning",
2860
+ "connector.weixin_qr_persist_failed",
2861
+ session_key=str(session.get("session_key") or ""),
2862
+ account_id=account_id,
2863
+ errors=save_result.get("errors") or [],
2864
+ warnings=save_result.get("warnings") or [],
2865
+ )
2866
+ return {
2867
+ "ok": False,
2868
+ "connected": False,
2869
+ "status": "confirmed",
2870
+ "errors": save_result.get("errors") or [],
2871
+ "warnings": save_result.get("warnings") or [],
2872
+ "message": "Weixin login succeeded, but DeepScientist could not persist the connector config.",
2873
+ }
2874
+ self.reload_connectors_config()
2875
+ self._weixin_login_sessions.pop(str(session.get("session_key") or ""), None)
2876
+ snapshot = next(
2877
+ (item for item in self.list_connector_statuses() if str(item.get("name") or "").strip().lower() == "weixin"),
2878
+ None,
2879
+ )
2880
+ return {
2881
+ "ok": True,
2882
+ "connected": True,
2883
+ "status": "confirmed",
2884
+ "account_id": account_id,
2885
+ "login_user_id": login_user_id,
2886
+ "base_url": str(weixin.get("base_url") or "").strip() or None,
2887
+ "snapshot": snapshot,
2888
+ "message": "Weixin login succeeded and the connector config was saved.",
2889
+ }
2890
+
2373
2891
  def delete_connector_profile(self, connector_name: str, profile_id: str) -> dict | tuple[int, dict]:
2374
2892
  normalized_connector = str(connector_name or "").strip().lower()
2375
2893
  normalized_profile_id = str(profile_id or "").strip()
@@ -2701,8 +3219,9 @@ class DaemonApp:
2701
3219
  connector_name = str(parsed.get("connector") or "").strip().lower()
2702
3220
  if not connector_name or connector_name == "local" or connector_name not in self.channels:
2703
3221
  return 400, {"ok": False, "message": f"Unknown connector `{connector_name}` for conversation `{normalized}`."}
3222
+ binding_conversation_id = self._logical_connector_binding_conversation(connector_name, normalized)
2704
3223
  channel = self._channel_with_bindings(connector_name)
2705
- conflicts = self._inspect_connector_binding_conflicts(quest_id, normalized)
3224
+ conflicts = self._inspect_connector_binding_conflicts(quest_id, binding_conversation_id)
2706
3225
  if conflicts and not force:
2707
3226
  return 409, {
2708
3227
  "ok": False,
@@ -2710,23 +3229,23 @@ class DaemonApp:
2710
3229
  "message": "Conversation is already bound to another quest.",
2711
3230
  "quest_id": quest_id,
2712
3231
  "connector": connector_name,
2713
- "conversation_id": normalized,
3232
+ "conversation_id": binding_conversation_id,
2714
3233
  "conflicts": conflicts,
2715
3234
  }
2716
- existing_bound = channel.resolve_bound_quest(normalized)
3235
+ existing_bound = channel.resolve_bound_quest(binding_conversation_id)
2717
3236
  for item in conflicts:
2718
3237
  other_id = str(item.get("quest_id") or "").strip()
2719
3238
  if other_id and other_id != quest_id:
2720
- self.quest_service.unbind_source(other_id, normalized)
2721
- self.sessions.unbind(other_id, normalized)
2722
- channel.bind_conversation(normalized, quest_id)
2723
- self.sessions.bind(quest_id, normalized)
3239
+ self.quest_service.unbind_source(other_id, binding_conversation_id)
3240
+ self.sessions.unbind(other_id, binding_conversation_id)
3241
+ channel.bind_conversation(binding_conversation_id, quest_id)
3242
+ self.sessions.bind(quest_id, binding_conversation_id)
2724
3243
  self.quest_service.bind_source(quest_id, "local:default")
2725
- self.quest_service.bind_source(quest_id, normalized)
3244
+ self.quest_service.bind_source(quest_id, binding_conversation_id)
2726
3245
  if clear_scope == "all_external":
2727
- removed = self._unbind_external_bindings(quest_id, preserve={normalized})
3246
+ removed = self._unbind_external_bindings(quest_id, preserve={binding_conversation_id})
2728
3247
  elif clear_scope == "connector":
2729
- removed = self._unbind_quest_connector_bindings(quest_id, connector_name, preserve={normalized})
3248
+ removed = self._unbind_quest_connector_bindings(quest_id, connector_name, preserve={binding_conversation_id})
2730
3249
  else:
2731
3250
  removed = []
2732
3251
  snapshot = self.quest_service.snapshot(quest_id)
@@ -2743,7 +3262,7 @@ class DaemonApp:
2743
3262
  "ok": True,
2744
3263
  "quest_id": quest_id,
2745
3264
  "connector": connector_name,
2746
- "conversation_id": normalized,
3265
+ "conversation_id": binding_conversation_id,
2747
3266
  "snapshot": snapshot,
2748
3267
  "removed_conversations": removed,
2749
3268
  "conflicts_resolved": [item.get("quest_id") for item in conflicts if item.get("quest_id")],
@@ -2790,7 +3309,7 @@ class DaemonApp:
2790
3309
  "message": f"Conversation `{normalized}` does not belong to connector `{normalized_connector}`.",
2791
3310
  }
2792
3311
 
2793
- return self._apply_conversation_binding(quest_id, normalized, force=force, clear_scope="connector")
3312
+ return self._apply_conversation_binding(quest_id, normalized, force=force, clear_scope="all_external")
2794
3313
 
2795
3314
  def update_quest_bindings(
2796
3315
  self,
@@ -2803,6 +3322,12 @@ class DaemonApp:
2803
3322
  if not quest_root.joinpath("quest.yaml").exists():
2804
3323
  return 404, {"ok": False, "message": f"Unknown quest `{quest_id}`."}
2805
3324
  normalized_bindings = self._normalize_requested_connector_bindings(requested_bindings)
3325
+ if len(normalized_bindings) > 1:
3326
+ return 400, {
3327
+ "ok": False,
3328
+ "message": "A quest may bind at most one external connector target.",
3329
+ "quest_id": quest_id,
3330
+ }
2806
3331
  conflicts = self.preview_connector_binding_conflicts(normalized_bindings, quest_id=quest_id)
2807
3332
  if conflicts and not force:
2808
3333
  return 409, {
@@ -2959,12 +3484,22 @@ class DaemonApp:
2959
3484
  channel = self._channel_with_bindings(connector_name)
2960
3485
  connector_label = self._connector_label(connector_name)
2961
3486
  conversation_id = str(message.get("conversation_id") or "")
3487
+ binding_conversation_id = self._logical_connector_binding_conversation(connector_name, conversation_id)
2962
3488
  text = str(message.get("text") or "").strip()
2963
3489
  command_prefix = channel.command_prefix()
2964
3490
  quest_id = channel.resolve_bound_quest(conversation_id)
2965
-
3491
+ if quest_id is None and str(connector_name or "").strip().lower() == "lingzhu":
3492
+ quest_id = self._resolve_lingzhu_bound_quest(conversation_id)
3493
+ command_name = ""
3494
+ args: list[str] = []
2966
3495
  if text.startswith(command_prefix):
2967
3496
  command_name, args = self._parse_prefixed_command(text, command_prefix)
3497
+ elif str(connector_name or "").strip().lower() == "lingzhu":
3498
+ parsed_lingzhu_command = self._parse_lingzhu_short_command(text)
3499
+ if parsed_lingzhu_command is not None:
3500
+ command_name, args = parsed_lingzhu_command
3501
+
3502
+ if command_name:
2968
3503
  if command_name == "help":
2969
3504
  return channel.send(
2970
3505
  {
@@ -3001,7 +3536,7 @@ class DaemonApp:
3001
3536
  announce_connector_binding=True,
3002
3537
  exclude_conversation_id=conversation_id,
3003
3538
  )
3004
- self.update_quest_binding(created["quest_id"], conversation_id, force=True)
3539
+ self.update_quest_binding(created["quest_id"], binding_conversation_id, force=True)
3005
3540
  self.submit_user_message(
3006
3541
  created["quest_id"],
3007
3542
  text=goal_text,
@@ -3051,7 +3586,23 @@ class DaemonApp:
3051
3586
  ),
3052
3587
  }
3053
3588
  )
3054
- self.update_quest_binding(target_quest, conversation_id, force=True)
3589
+ previous_external = self._quest_external_binding(target_quest)
3590
+ binding_result = self.update_quest_binding(target_quest, binding_conversation_id, force=True)
3591
+ if isinstance(binding_result, tuple):
3592
+ _status, payload = binding_result
3593
+ return channel.send(
3594
+ {
3595
+ "conversation_id": conversation_id,
3596
+ "kind": "ack",
3597
+ "message": str(payload.get("message") or "Unable to switch connector binding."),
3598
+ }
3599
+ )
3600
+ transition = self._binding_transition_summary(
3601
+ quest_id=target_quest,
3602
+ previous_conversation_id=previous_external,
3603
+ current_conversation_id=self._quest_external_binding(target_quest),
3604
+ )
3605
+ self._announce_binding_transition(transition, notify_new=False, notify_old=True)
3055
3606
  return channel.send(
3056
3607
  {
3057
3608
  "conversation_id": conversation_id,
@@ -3139,9 +3690,22 @@ class DaemonApp:
3139
3690
  }
3140
3691
  )
3141
3692
 
3142
- if quest_id is None and command_name and command_name not in {"help", "projects", "quests", "list", "new", "use", "delete"}:
3693
+ if quest_id is None and command_name not in {"help", "projects", "quests", "list", "new", "use", "delete"}:
3143
3694
  auto_bound = self._maybe_auto_bind_connector_conversation(connector_name, conversation_id)
3144
3695
  if auto_bound is not None:
3696
+ if bool(auto_bound.get("blocked")):
3697
+ return channel.send(
3698
+ {
3699
+ "conversation_id": conversation_id,
3700
+ "kind": "ack",
3701
+ "message": self._connector_switch_required_message(
3702
+ connector_name=connector_name,
3703
+ quest_id=str(auto_bound.get("quest_id") or "").strip(),
3704
+ current_conversation_id=str(auto_bound.get("current_conversation_id") or "").strip(),
3705
+ requested_conversation_id=str(auto_bound.get("requested_conversation_id") or "").strip(),
3706
+ ),
3707
+ }
3708
+ )
3145
3709
  quest_id = str(auto_bound.get("quest_id") or "").strip() or None
3146
3710
 
3147
3711
  if command_name in {"stop", "resume"}:
@@ -3172,7 +3736,7 @@ class DaemonApp:
3172
3736
  target_quest_id = str(target_quest_id or "").strip()
3173
3737
  bound_quest_id = str(channel.resolve_bound_quest(conversation_id) or "").strip() or None
3174
3738
  self.sessions.bind(target_quest_id, conversation_id)
3175
- self.quest_service.bind_source(target_quest_id, conversation_id)
3739
+ self.quest_service.bind_source(target_quest_id, binding_conversation_id)
3176
3740
  result = self.control_quest(
3177
3741
  target_quest_id,
3178
3742
  action=command_name,
@@ -3197,7 +3761,7 @@ class DaemonApp:
3197
3761
  )
3198
3762
 
3199
3763
  self.sessions.bind(quest_id, conversation_id)
3200
- self.quest_service.bind_source(quest_id, conversation_id)
3764
+ self.quest_service.bind_source(quest_id, binding_conversation_id)
3201
3765
  if command_name == "status":
3202
3766
  snapshot = self.quest_service.snapshot(quest_id)
3203
3767
  return channel.send(
@@ -3370,6 +3934,19 @@ class DaemonApp:
3370
3934
  if quest_id is None:
3371
3935
  auto_bound = self._maybe_auto_bind_connector_conversation(connector_name, conversation_id)
3372
3936
  if auto_bound is not None:
3937
+ if bool(auto_bound.get("blocked")):
3938
+ return channel.send(
3939
+ {
3940
+ "conversation_id": conversation_id,
3941
+ "kind": "ack",
3942
+ "message": self._connector_switch_required_message(
3943
+ connector_name=connector_name,
3944
+ quest_id=str(auto_bound.get("quest_id") or "").strip(),
3945
+ current_conversation_id=str(auto_bound.get("current_conversation_id") or "").strip(),
3946
+ requested_conversation_id=str(auto_bound.get("requested_conversation_id") or "").strip(),
3947
+ ),
3948
+ }
3949
+ )
3373
3950
  quest_id = str(auto_bound.get("quest_id") or "").strip() or None
3374
3951
 
3375
3952
  if quest_id is None:
@@ -3382,6 +3959,7 @@ class DaemonApp:
3382
3959
  )
3383
3960
 
3384
3961
  self.sessions.bind(quest_id, conversation_id)
3962
+ self.quest_service.bind_source(quest_id, binding_conversation_id)
3385
3963
  materialized_attachments = self._materialize_connector_attachments(
3386
3964
  quest_id=quest_id,
3387
3965
  connector_name=connector_name,
@@ -3500,13 +4078,35 @@ class DaemonApp:
3500
4078
  name = str(resolved.get("name") or "").strip()
3501
4079
  content_type = str(resolved.get("content_type") or "").strip()
3502
4080
  url = str(resolved.get("url") or "").strip()
4081
+ path = str(resolved.get("path") or "").strip()
3503
4082
  target_path = batch_root / self._connector_attachment_filename(index=index, name=name, content_type=content_type)
3504
4083
  resolved["manifest_path"] = str(batch_root / "manifest.json")
3505
4084
  resolved["batch_path"] = str(batch_root)
3506
- if not url:
3507
- resolved["materialized"] = False
3508
- resolved["download_error"] = "missing_download_url"
3509
- return resolved
4085
+ if path:
4086
+ try:
4087
+ source_path = Path(path).expanduser()
4088
+ if not source_path.is_absolute():
4089
+ source_path = (quest_root / source_path).resolve()
4090
+ else:
4091
+ source_path = source_path.resolve()
4092
+ if not source_path.exists():
4093
+ raise FileNotFoundError(f"attachment local path does not exist: {source_path}")
4094
+ size_bytes = self._copy_connector_attachment(
4095
+ source_path=source_path,
4096
+ target_path=target_path,
4097
+ )
4098
+ resolved["path"] = str(target_path)
4099
+ resolved["source_path"] = str(source_path)
4100
+ resolved["quest_relative_path"] = str(target_path.relative_to(quest_root))
4101
+ resolved["size_bytes"] = int(size_bytes)
4102
+ resolved["materialized"] = True
4103
+ resolved["downloaded_at"] = utc_now()
4104
+ return resolved
4105
+ except Exception as exc:
4106
+ if not url:
4107
+ resolved["materialized"] = False
4108
+ resolved["download_error"] = str(exc)
4109
+ return resolved
3510
4110
  try:
3511
4111
  size_bytes = self._download_connector_attachment(
3512
4112
  connector_name=connector_name,
@@ -3526,6 +4126,28 @@ class DaemonApp:
3526
4126
  resolved["download_error"] = str(exc)
3527
4127
  return resolved
3528
4128
 
4129
+ def _copy_connector_attachment(
4130
+ self,
4131
+ *,
4132
+ source_path: Path,
4133
+ target_path: Path,
4134
+ ) -> int:
4135
+ ensure_dir(target_path.parent)
4136
+ total = 0
4137
+ with source_path.open("rb") as source_handle:
4138
+ with target_path.open("wb") as target_handle:
4139
+ while True:
4140
+ chunk = source_handle.read(65536)
4141
+ if not chunk:
4142
+ break
4143
+ total += len(chunk)
4144
+ if total > self._MAX_INBOUND_ATTACHMENT_BYTES:
4145
+ raise ValueError(
4146
+ f"attachment exceeds max inbound size limit ({self._MAX_INBOUND_ATTACHMENT_BYTES} bytes)"
4147
+ )
4148
+ target_handle.write(chunk)
4149
+ return total
4150
+
3529
4151
  def _download_connector_attachment(
3530
4152
  self,
3531
4153
  *,
@@ -3611,37 +4233,37 @@ class DaemonApp:
3611
4233
  if not candidates:
3612
4234
  return []
3613
4235
 
3614
- bound_conversations: list[str] = []
3615
- seen_identity_keys: set[str] = set()
3616
- for connector_name, conversation_id in candidates:
3617
- original_identity = conversation_identity_key(conversation_id)
3618
- if original_identity in seen_identity_keys:
3619
- continue
3620
- result = self._apply_conversation_binding(quest_id, conversation_id, force=True, clear_scope="none")
3621
- if isinstance(result, tuple):
3622
- continue
3623
- bound_conversation = str(result.get("conversation_id") or "").strip() or conversation_id
3624
- identity_key = conversation_identity_key(bound_conversation)
3625
- if identity_key in seen_identity_keys:
3626
- continue
3627
- seen_identity_keys.add(identity_key)
3628
- bound_conversations.append(bound_conversation)
3629
- if announce:
3630
- channel = self._channel_with_bindings(connector_name)
3631
- channel.send(
3632
- {
3633
- "conversation_id": bound_conversation,
3634
- "quest_id": quest_id,
3635
- "kind": "ack",
3636
- "message": self._quest_created_connector_message(
3637
- connector_name,
3638
- quest_id=quest_id,
3639
- goal=goal,
3640
- previous_quest_id=str(result.get("previous_quest_id") or "").strip() or None,
3641
- ),
3642
- }
3643
- )
3644
- return bound_conversations
4236
+ preferred_conversation_id = str(self.connector_availability_summary().get("preferred_conversation_id") or "").strip()
4237
+ selected: tuple[str, str] | None = None
4238
+ if preferred_conversation_id:
4239
+ for item in candidates:
4240
+ if conversation_identity_key(item[1]) == conversation_identity_key(preferred_conversation_id):
4241
+ selected = item
4242
+ break
4243
+ if selected is None:
4244
+ selected = candidates[0]
4245
+
4246
+ connector_name, conversation_id = selected
4247
+ result = self._apply_conversation_binding(quest_id, conversation_id, force=True, clear_scope="none")
4248
+ if isinstance(result, tuple):
4249
+ return []
4250
+ bound_conversation = str(result.get("conversation_id") or "").strip() or conversation_id
4251
+ if announce:
4252
+ channel = self._channel_with_bindings(connector_name)
4253
+ channel.send(
4254
+ {
4255
+ "conversation_id": bound_conversation,
4256
+ "quest_id": quest_id,
4257
+ "kind": "ack",
4258
+ "message": self._quest_created_connector_message(
4259
+ connector_name,
4260
+ quest_id=quest_id,
4261
+ goal=goal,
4262
+ previous_quest_id=str(result.get("previous_quest_id") or "").strip() or None,
4263
+ ),
4264
+ }
4265
+ )
4266
+ return [bound_conversation]
3645
4267
 
3646
4268
  def _latest_connector_conversation_id(self, connector_name: str) -> str:
3647
4269
  candidates = self._latest_connector_conversation_ids(connector_name)
@@ -3801,12 +4423,24 @@ class DaemonApp:
3801
4423
  latest_quest_id = self._latest_quest_id()
3802
4424
  if latest_quest_id is None:
3803
4425
  return None
3804
- result = self.update_quest_binding(latest_quest_id, conversation_id, force=True)
4426
+ normalized_conversation_id = self._logical_connector_binding_conversation(connector_name, conversation_id)
4427
+ current_external = self._quest_external_binding(latest_quest_id)
4428
+ if current_external and conversation_identity_key(current_external) != conversation_identity_key(normalized_conversation_id):
4429
+ return {
4430
+ "ok": False,
4431
+ "blocked": True,
4432
+ "quest_id": latest_quest_id,
4433
+ "current_conversation_id": current_external,
4434
+ "requested_conversation_id": normalized_conversation_id,
4435
+ }
4436
+ result = self.update_quest_binding(latest_quest_id, normalized_conversation_id, force=True)
3805
4437
  if isinstance(result, tuple):
3806
4438
  return None
3807
4439
  return result
3808
4440
 
3809
4441
  def _connector_home_help(self, connector_name: str, *, message: dict) -> str:
4442
+ if str(connector_name or "").strip().lower() == "lingzhu":
4443
+ return self._with_qq_main_chat_notice(message, self._lingzhu_unbound_help_text())
3810
4444
  quests = self.quest_service.list_quests()
3811
4445
  latest = str(quests[0]["quest_id"]) if quests else "none"
3812
4446
  body = self._polite_copy(
@@ -3839,6 +4473,241 @@ class DaemonApp:
3839
4473
  )
3840
4474
  return self._with_qq_main_chat_notice(message, body)
3841
4475
 
4476
+ def _quest_external_binding(self, quest_id: str | None) -> str | None:
4477
+ normalized_quest_id = str(quest_id or "").strip()
4478
+ if not normalized_quest_id:
4479
+ return None
4480
+ for source in self.quest_service.binding_sources(normalized_quest_id):
4481
+ parsed = parse_conversation_id(source)
4482
+ if parsed is None:
4483
+ continue
4484
+ if str(parsed.get("connector") or "").strip().lower() == "local":
4485
+ continue
4486
+ return normalize_conversation_id(source)
4487
+ return None
4488
+
4489
+ def _lingzhu_passive_conversation_id(self) -> str | None:
4490
+ lingzhu_config = self.connectors_config.get("lingzhu")
4491
+ resolved = dict(lingzhu_config) if isinstance(lingzhu_config, dict) else {}
4492
+ auth_ak = self.config_manager._secret(resolved, "auth_ak", "auth_ak_env")
4493
+ if not auth_ak:
4494
+ return None
4495
+ return lingzhu_passive_conversation_id(resolved)
4496
+
4497
+ def _logical_connector_binding_conversation(self, connector_name: str, conversation_id: str | None) -> str:
4498
+ normalized_connector = str(connector_name or "").strip().lower()
4499
+ if normalized_connector == "lingzhu":
4500
+ passive_conversation_id = self._lingzhu_passive_conversation_id()
4501
+ if passive_conversation_id:
4502
+ return passive_conversation_id
4503
+ return normalize_conversation_id(conversation_id)
4504
+
4505
+ def _remove_connector_sources_from_quest(self, quest_id: str, connector_name: str) -> None:
4506
+ normalized_connector = str(connector_name or "").strip().lower()
4507
+ if not normalized_connector:
4508
+ return
4509
+ current_sources = self.quest_service.binding_sources(quest_id)
4510
+ filtered_sources = []
4511
+ for source in current_sources:
4512
+ parsed = parse_conversation_id(source)
4513
+ if parsed is not None and str(parsed.get("connector") or "").strip().lower() == normalized_connector:
4514
+ continue
4515
+ filtered_sources.append(source)
4516
+ self.quest_service.set_binding_sources(quest_id, filtered_sources or ["local:default"])
4517
+
4518
+ def _canonicalize_lingzhu_binding_state(self) -> None:
4519
+ passive_conversation_id = self._lingzhu_passive_conversation_id()
4520
+ if not passive_conversation_id:
4521
+ return
4522
+ try:
4523
+ channel = self._channel_with_bindings("lingzhu")
4524
+ except Exception:
4525
+ return
4526
+ bindings = [dict(item) for item in channel.list_bindings() if isinstance(item, dict)]
4527
+ selected_quest_id: str | None = None
4528
+ selected_updated_at = ""
4529
+ quests_with_lingzhu_sources: set[str] = set()
4530
+
4531
+ for item in bindings:
4532
+ quest_id = str(item.get("quest_id") or "").strip()
4533
+ updated_at = str(item.get("updated_at") or "").strip()
4534
+ if quest_id and (updated_at, quest_id) >= (selected_updated_at, str(selected_quest_id or "")):
4535
+ selected_quest_id = quest_id
4536
+ selected_updated_at = updated_at
4537
+
4538
+ for quest in self.quest_service.list_quests():
4539
+ quest_id = str(quest.get("quest_id") or "").strip()
4540
+ if not quest_id:
4541
+ continue
4542
+ sources = self.quest_service.binding_sources(quest_id)
4543
+ if any(
4544
+ (
4545
+ parsed := parse_conversation_id(source)
4546
+ ) is not None and str(parsed.get("connector") or "").strip().lower() == "lingzhu"
4547
+ for source in sources
4548
+ ):
4549
+ quests_with_lingzhu_sources.add(quest_id)
4550
+ if not selected_quest_id:
4551
+ selected_quest_id = quest_id
4552
+
4553
+ for item in bindings:
4554
+ conversation_id = str(item.get("conversation_id") or "").strip()
4555
+ quest_id = str(item.get("quest_id") or "").strip() or None
4556
+ if not conversation_id:
4557
+ continue
4558
+ channel.unbind_conversation(conversation_id, quest_id=quest_id)
4559
+ if quest_id:
4560
+ self.sessions.unbind(quest_id, conversation_id)
4561
+
4562
+ for quest_id in quests_with_lingzhu_sources:
4563
+ self._remove_connector_sources_from_quest(quest_id, "lingzhu")
4564
+
4565
+ if selected_quest_id:
4566
+ channel.bind_conversation(passive_conversation_id, selected_quest_id)
4567
+ self.quest_service.bind_source(selected_quest_id, "local:default")
4568
+ self.quest_service.bind_source(selected_quest_id, passive_conversation_id)
4569
+
4570
+ def _resolve_lingzhu_bound_quest(self, conversation_id: str) -> str | None:
4571
+ normalized_conversation_id = normalize_conversation_id(conversation_id)
4572
+ channel = self._channel_with_bindings("lingzhu")
4573
+ known_quest_id = str(channel.resolve_bound_quest(normalized_conversation_id) or "").strip() or None
4574
+ if known_quest_id:
4575
+ return known_quest_id
4576
+ passive_conversation_id = self._lingzhu_passive_conversation_id()
4577
+ passive_quest_id = str(channel.resolve_bound_quest(passive_conversation_id) or "").strip() or None
4578
+ if not passive_quest_id:
4579
+ return None
4580
+ return passive_quest_id
4581
+
4582
+ def _connector_target_label(self, conversation_id: str | None) -> str:
4583
+ normalized = normalize_conversation_id(conversation_id)
4584
+ parsed = parse_conversation_id(normalized)
4585
+ if parsed is None:
4586
+ return str(conversation_id or "unknown").strip() or "unknown"
4587
+ connector_label = self._connector_label(str(parsed.get("connector") or "").strip())
4588
+ profile_id = str(parsed.get("profile_id") or "").strip()
4589
+ if lingzhu_is_passive_conversation_id(normalized):
4590
+ agent_id = str(parsed.get("chat_id_raw") or parsed.get("chat_id") or "").strip() or "main"
4591
+ return f"{connector_label} · passive · {agent_id}"
4592
+ chat_id = str(parsed.get("chat_id_raw") or parsed.get("chat_id") or normalized).strip()
4593
+ if profile_id:
4594
+ return f"{connector_label} · {profile_id} · {chat_id}"
4595
+ return f"{connector_label} · {chat_id}"
4596
+
4597
+ def _binding_transition_summary(
4598
+ self,
4599
+ *,
4600
+ quest_id: str,
4601
+ previous_conversation_id: str | None,
4602
+ current_conversation_id: str | None,
4603
+ ) -> dict[str, Any]:
4604
+ previous = normalize_conversation_id(previous_conversation_id)
4605
+ current = normalize_conversation_id(current_conversation_id)
4606
+ if conversation_identity_key(previous) == conversation_identity_key(current):
4607
+ mode = "unchanged"
4608
+ elif previous and current:
4609
+ mode = "switch"
4610
+ elif current:
4611
+ mode = "bind"
4612
+ elif previous:
4613
+ mode = "disconnect"
4614
+ else:
4615
+ mode = "unchanged"
4616
+ return {
4617
+ "quest_id": quest_id,
4618
+ "mode": mode,
4619
+ "previous_conversation_id": previous or None,
4620
+ "previous_label": self._connector_target_label(previous) if previous else None,
4621
+ "current_conversation_id": current or None,
4622
+ "current_label": self._connector_target_label(current) if current else None,
4623
+ "changed": mode != "unchanged",
4624
+ }
4625
+
4626
+ def _announce_binding_transition(
4627
+ self,
4628
+ summary: dict[str, Any] | None,
4629
+ *,
4630
+ notify_new: bool,
4631
+ notify_old: bool,
4632
+ ) -> None:
4633
+ if not isinstance(summary, dict) or not bool(summary.get("changed")):
4634
+ return
4635
+ quest_id = str(summary.get("quest_id") or "").strip()
4636
+ previous_conversation_id = str(summary.get("previous_conversation_id") or "").strip() or None
4637
+ current_conversation_id = str(summary.get("current_conversation_id") or "").strip() or None
4638
+ previous_label = str(summary.get("previous_label") or "").strip() or None
4639
+ current_label = str(summary.get("current_label") or "").strip() or None
4640
+ mode = str(summary.get("mode") or "").strip()
4641
+
4642
+ if notify_old and previous_conversation_id and conversation_identity_key(previous_conversation_id) != conversation_identity_key(current_conversation_id):
4643
+ old_connector = str((parse_conversation_id(previous_conversation_id) or {}).get("connector") or "").strip().lower()
4644
+ if old_connector and old_connector in self.channels:
4645
+ channel = self._channel_with_bindings(old_connector)
4646
+ if mode == "disconnect":
4647
+ message = self._polite_copy(
4648
+ zh=f"当前已退出 Quest `{quest_id}`,项目已切换为仅本地。",
4649
+ en=f"This conversation is no longer bound to Quest `{quest_id}`. The project is now local only.",
4650
+ )
4651
+ else:
4652
+ message = self._polite_copy(
4653
+ zh=f"当前已退出 Quest `{quest_id}`,后续请在 {current_label} 查看进展。",
4654
+ en=f"This conversation is no longer bound to Quest `{quest_id}`. Continue from {current_label}.",
4655
+ )
4656
+ channel.send(
4657
+ {
4658
+ "conversation_id": previous_conversation_id,
4659
+ "quest_id": quest_id,
4660
+ "kind": "binding_notice",
4661
+ "message": message,
4662
+ }
4663
+ )
4664
+
4665
+ if notify_new and current_conversation_id:
4666
+ new_connector = str((parse_conversation_id(current_conversation_id) or {}).get("connector") or "").strip().lower()
4667
+ if new_connector and new_connector in self.channels:
4668
+ channel = self._channel_with_bindings(new_connector)
4669
+ if mode == "bind":
4670
+ message = self._polite_copy(
4671
+ zh=f"当前已绑定 Quest `{quest_id}`。",
4672
+ en=f"This conversation is now bound to Quest `{quest_id}`.",
4673
+ )
4674
+ elif mode == "switch":
4675
+ message = self._polite_copy(
4676
+ zh=f"当前已绑定 Quest `{quest_id}`,并已从 {previous_label} 切换到当前会话。",
4677
+ en=f"This conversation is now bound to Quest `{quest_id}`, replacing {previous_label}.",
4678
+ )
4679
+ else:
4680
+ message = ""
4681
+ if message:
4682
+ channel.send(
4683
+ {
4684
+ "conversation_id": current_conversation_id,
4685
+ "quest_id": quest_id,
4686
+ "kind": "binding_notice",
4687
+ "message": message,
4688
+ }
4689
+ )
4690
+
4691
+ def _connector_switch_required_message(
4692
+ self,
4693
+ *,
4694
+ connector_name: str,
4695
+ quest_id: str,
4696
+ current_conversation_id: str,
4697
+ requested_conversation_id: str,
4698
+ ) -> str:
4699
+ switch_command = f"绑定{quest_id}" if str(connector_name or "").strip().lower() == "lingzhu" else f"/use {quest_id}"
4700
+ return self._polite_copy(
4701
+ zh=(
4702
+ f"当前 Quest `{quest_id}` 已绑定 {self._connector_target_label(current_conversation_id)}。\n"
4703
+ f"如需切换到 {self._connector_target_label(requested_conversation_id)},请发送 `{switch_command}`,或在项目设置里保存切换。"
4704
+ ),
4705
+ en=(
4706
+ f"Quest `{quest_id}` is already bound to {self._connector_target_label(current_conversation_id)}.\n"
4707
+ f"To switch to {self._connector_target_label(requested_conversation_id)}, send `{switch_command}` or save the change from project settings."
4708
+ ),
4709
+ )
4710
+
3842
4711
  def _unbind_external_bindings(self, quest_id: str, *, preserve: set[str] | None = None) -> list[str]:
3843
4712
  preserve_keys = {conversation_identity_key(item) for item in (preserve or set()) if item}
3844
4713
  removed: list[str] = []
@@ -3924,6 +4793,46 @@ class DaemonApp:
3924
4793
  parts = stripped.split()
3925
4794
  return parts[0].lower(), parts[1:]
3926
4795
 
4796
+ @staticmethod
4797
+ def _parse_lingzhu_short_command(text: str) -> tuple[str, list[str]] | None:
4798
+ normalized = re.sub(r"\s+", " ", str(text or "").strip())
4799
+ if not normalized or normalized.startswith("/"):
4800
+ return None
4801
+ direct = _LINGZHU_SHORT_COMMAND_DIRECT_MAP.get(normalized)
4802
+ if direct:
4803
+ return direct, []
4804
+ for prefix, command_name in _LINGZHU_SHORT_COMMAND_PREFIX_MAP.items():
4805
+ if not normalized.startswith(prefix):
4806
+ continue
4807
+ remainder = normalized[len(prefix) :].strip().lstrip("::,,。.;;!!?? ")
4808
+ if command_name == "new":
4809
+ return command_name, [remainder] if remainder else []
4810
+ if command_name == "delete":
4811
+ matched = re.match(r"^(?P<target>\S+)?(?:\s+(?P<confirm>\S+))?$", remainder)
4812
+ target = str((matched.group("target") if matched else "") or "").strip()
4813
+ confirm = str((matched.group("confirm") if matched else "") or "").strip()
4814
+ args: list[str] = []
4815
+ if target:
4816
+ args.append("latest" if target in _LINGZHU_SHORT_LATEST_ALIASES else target)
4817
+ if confirm in _LINGZHU_DELETE_CONFIRM_ALIASES:
4818
+ args.append("--yes")
4819
+ return command_name, args
4820
+ if remainder:
4821
+ return command_name, ["latest" if remainder in _LINGZHU_SHORT_LATEST_ALIASES else remainder]
4822
+ return command_name, []
4823
+ return None
4824
+
4825
+ def _lingzhu_unbound_help_text(self) -> str:
4826
+ latest = str(self._latest_quest_id() or "none")
4827
+ return (
4828
+ "当前还没绑定 Quest。\n"
4829
+ "可直接说:帮助、列表、绑定025、绑定最新、新建 复现一个 baseline。\n"
4830
+ f"当前最新 Quest:`{latest}`。\n"
4831
+ "绑定后再说:我现在的任务是 ……\n"
4832
+ "查看进展可说:继续 或 汇报。\n"
4833
+ "快捷指令:状态、总结、暂停、恢复、删除025。"
4834
+ )
4835
+
3927
4836
  def _maybe_bind_qq_main_chat(self, message: dict) -> dict | None:
3928
4837
  chat_type = str(message.get("chat_type") or "").strip().lower()
3929
4838
  if chat_type != "direct":
@@ -4053,6 +4962,20 @@ class DaemonApp:
4053
4962
  )
4054
4963
  if gateway.start():
4055
4964
  self._qq_gateways[profile_id] = gateway
4965
+ weixin_config = self.connectors_config.get("weixin", {})
4966
+ if self._is_connector_system_enabled("weixin") and isinstance(weixin_config, dict) and self._weixin_ilink is None:
4967
+ weixin = WeixinIlinkService(
4968
+ home=self.home,
4969
+ config=weixin_config,
4970
+ on_event=lambda event: self.handle_connector_inbound("weixin", event),
4971
+ log=lambda level, message: self.logger.log(
4972
+ level,
4973
+ "connector.weixin_ilink",
4974
+ message=message,
4975
+ ),
4976
+ )
4977
+ if weixin.start():
4978
+ self._weixin_ilink = weixin
4056
4979
  if self._is_connector_system_enabled("telegram") and not self._telegram_polling:
4057
4980
  for profile_id, profile_label, profile_config in self._profiled_connector_configs("telegram"):
4058
4981
  polling = TelegramPollingService(
@@ -4149,6 +5072,10 @@ class DaemonApp:
4149
5072
  self._qq_gateways = {}
4150
5073
  for gateway in gateways:
4151
5074
  gateway.stop()
5075
+ weixin = self._weixin_ilink
5076
+ self._weixin_ilink = None
5077
+ if weixin is not None:
5078
+ weixin.stop()
4152
5079
  polling = list(self._telegram_polling.values())
4153
5080
  self._telegram_polling = {}
4154
5081
  for item in polling:
@@ -4263,6 +5190,435 @@ class DaemonApp:
4263
5190
  accept = str(headers.get("Accept") or headers.get("accept") or "").lower()
4264
5191
  return stream_value in {"1", "true", "yes", "stream"} or "text/event-stream" in accept
4265
5192
 
5193
+ def lingzhu_health_payload(self) -> dict[str, Any]:
5194
+ config = self.connectors_config.get("lingzhu")
5195
+ resolved = dict(config) if isinstance(config, dict) else {}
5196
+ return lingzhu_health_payload(resolved, chat_completions_enabled=True)
5197
+
5198
+ def _lingzhu_state_path(self) -> Path:
5199
+ return self.home / "logs" / "connectors" / "lingzhu" / "metis_state.json"
5200
+
5201
+ def _read_lingzhu_state(self) -> dict[str, Any]:
5202
+ payload = read_json(self._lingzhu_state_path(), {"delivered_counts": {}})
5203
+ if not isinstance(payload, dict):
5204
+ payload = {}
5205
+ delivered_counts = payload.get("delivered_counts")
5206
+ if not isinstance(delivered_counts, dict):
5207
+ delivered_counts = {}
5208
+ return {"delivered_counts": delivered_counts}
5209
+
5210
+ def _write_lingzhu_state(self, payload: dict[str, Any]) -> None:
5211
+ path = self._lingzhu_state_path()
5212
+ ensure_dir(path.parent)
5213
+ write_json(path, payload)
5214
+
5215
+ def _lingzhu_delivered_count(self, conversation_id: str) -> int:
5216
+ delivered_counts = self._read_lingzhu_state().get("delivered_counts") or {}
5217
+ raw_value = delivered_counts.get(conversation_identity_key(conversation_id))
5218
+ try:
5219
+ return max(0, int(raw_value))
5220
+ except (TypeError, ValueError):
5221
+ return 0
5222
+
5223
+ def _set_lingzhu_delivered_count(self, conversation_id: str, delivered_count: int) -> None:
5224
+ state = self._read_lingzhu_state()
5225
+ counts = dict(state.get("delivered_counts") or {})
5226
+ counts[conversation_identity_key(conversation_id)] = max(0, int(delivered_count))
5227
+ state["delivered_counts"] = counts
5228
+ self._write_lingzhu_state(state)
5229
+
5230
+ def _lingzhu_outbox_records(self, conversation_id: str) -> list[dict[str, Any]]:
5231
+ outbox_path = self.home / "logs" / "connectors" / "lingzhu" / "outbox.jsonl"
5232
+ target_key = conversation_identity_key(conversation_id)
5233
+ items: list[dict[str, Any]] = []
5234
+ for record in read_jsonl(outbox_path):
5235
+ if not isinstance(record, dict):
5236
+ continue
5237
+ current_conversation_id = str(record.get("conversation_id") or "").strip()
5238
+ if not current_conversation_id:
5239
+ continue
5240
+ if conversation_identity_key(current_conversation_id) != target_key:
5241
+ continue
5242
+ text = str(record.get("text") or "").strip()
5243
+ if not text:
5244
+ continue
5245
+ items.append(dict(record))
5246
+ return items
5247
+
5248
+ def _lingzhu_pending_outbox_records(
5249
+ self,
5250
+ conversation_id: str,
5251
+ *,
5252
+ delivered_count: int | None = None,
5253
+ ) -> tuple[list[dict[str, Any]], int]:
5254
+ records = self._lingzhu_outbox_records(conversation_id)
5255
+ baseline = self._lingzhu_delivered_count(conversation_id) if delivered_count is None else delivered_count
5256
+ applied_baseline = max(0, min(int(baseline), len(records)))
5257
+ return records[applied_baseline:], len(records)
5258
+
5259
+ @staticmethod
5260
+ def _lingzhu_wait_timeout_seconds(config: dict[str, Any]) -> float:
5261
+ try:
5262
+ timeout_ms = int(config.get("request_timeout_ms") or 60000)
5263
+ except (TypeError, ValueError):
5264
+ timeout_ms = 60000
5265
+ timeout_ms = max(15000, min(timeout_ms, 120000))
5266
+ return timeout_ms / 1000.0
5267
+
5268
+ def _lingzhu_wait_for_outbox_records(
5269
+ self,
5270
+ conversation_id: str,
5271
+ *,
5272
+ delivered_count: int,
5273
+ timeout_seconds: float,
5274
+ ) -> tuple[list[dict[str, Any]], int]:
5275
+ deadline = time.monotonic() + max(0.1, timeout_seconds)
5276
+ while time.monotonic() < deadline:
5277
+ pending_records, total_count = self._lingzhu_pending_outbox_records(
5278
+ conversation_id,
5279
+ delivered_count=delivered_count,
5280
+ )
5281
+ if pending_records:
5282
+ return pending_records, total_count
5283
+ time.sleep(0.25)
5284
+ return self._lingzhu_pending_outbox_records(conversation_id, delivered_count=delivered_count)
5285
+
5286
+ def _lingzhu_emit_outbox_records(
5287
+ self,
5288
+ handler: BaseHTTPRequestHandler,
5289
+ *,
5290
+ message_id: str,
5291
+ agent_id: str,
5292
+ records: list[dict[str, Any]],
5293
+ config: dict[str, Any] | None = None,
5294
+ ) -> int:
5295
+ emitted = 0
5296
+ resolved = dict(config or {})
5297
+ default_navigation_mode = str(resolved.get("default_navigation_mode") or "0").strip() or "0"
5298
+ experimental_enabled = bool(resolved.get("enable_experimental_native_actions", False))
5299
+ for record in records:
5300
+ raw_text = str(record.get("text") or "").strip()
5301
+ detected_tool_call = None
5302
+ text = raw_text
5303
+ if raw_text:
5304
+ detected_tool_call, text = lingzhu_detect_tool_call_from_text(
5305
+ raw_text,
5306
+ default_navigation_mode=default_navigation_mode,
5307
+ experimental_enabled=experimental_enabled,
5308
+ )
5309
+ if text:
5310
+ self._write_sse_event(
5311
+ handler,
5312
+ event="message",
5313
+ data=lingzhu_sse_answer(
5314
+ message_id=message_id,
5315
+ agent_id=agent_id,
5316
+ answer_stream=text,
5317
+ is_finish=True,
5318
+ ),
5319
+ )
5320
+ emitted += 1
5321
+ emitted_tool_call = False
5322
+ for action in record.get("surface_actions") or []:
5323
+ tool_call = lingzhu_surface_action_tool_call(
5324
+ action,
5325
+ default_navigation_mode=default_navigation_mode,
5326
+ experimental_enabled=experimental_enabled,
5327
+ )
5328
+ if not tool_call:
5329
+ continue
5330
+ self._write_sse_event(
5331
+ handler,
5332
+ event="message",
5333
+ data=lingzhu_sse_tool_call(
5334
+ message_id=message_id,
5335
+ agent_id=agent_id,
5336
+ tool_call=tool_call,
5337
+ is_finish=True,
5338
+ ),
5339
+ )
5340
+ emitted += 1
5341
+ emitted_tool_call = True
5342
+ if not emitted_tool_call and detected_tool_call:
5343
+ self._write_sse_event(
5344
+ handler,
5345
+ event="message",
5346
+ data=lingzhu_sse_tool_call(
5347
+ message_id=message_id,
5348
+ agent_id=agent_id,
5349
+ tool_call=detected_tool_call,
5350
+ is_finish=True,
5351
+ ),
5352
+ )
5353
+ emitted += 1
5354
+ return emitted
5355
+
5356
+ def _lingzhu_short_status_text(self, quest_id: str | None) -> str:
5357
+ if not quest_id:
5358
+ return self._lingzhu_unbound_help_text()
5359
+ snapshot = self.quest_service.snapshot(quest_id)
5360
+ runtime_status = str(snapshot.get("runtime_status") or snapshot.get("status") or "").strip().lower()
5361
+ if runtime_status in {"running", "active"}:
5362
+ return "进行中"
5363
+ if runtime_status == "waiting_for_user":
5364
+ return "等你确认"
5365
+ if runtime_status in {"paused", "stopped"}:
5366
+ return "已暂停"
5367
+ if runtime_status == "completed":
5368
+ return "已完成"
5369
+ if runtime_status == "error":
5370
+ return "出错了"
5371
+ return "暂无新进展"
5372
+
5373
+ @staticmethod
5374
+ def _lingzhu_reply_payload(result: dict[str, Any]) -> tuple[str, str | None, str]:
5375
+ if not isinstance(result, dict):
5376
+ return "", None, ""
5377
+ reply = result.get("reply")
5378
+ if not isinstance(reply, dict):
5379
+ return "", None, ""
5380
+ payload = reply.get("payload")
5381
+ if not isinstance(payload, dict):
5382
+ return "", None, ""
5383
+ text = str(payload.get("text") or payload.get("message") or "").strip()
5384
+ quest_id = str(payload.get("quest_id") or "").strip() or None
5385
+ kind = str(payload.get("kind") or "").strip()
5386
+ return text, quest_id, kind
5387
+
5388
+ def stream_lingzhu_sse(
5389
+ self,
5390
+ handler: BaseHTTPRequestHandler,
5391
+ *,
5392
+ raw_body: bytes,
5393
+ headers: dict[str, str],
5394
+ ) -> None:
5395
+ config = self.connectors_config.get("lingzhu")
5396
+ resolved = dict(config) if isinstance(config, dict) else {}
5397
+ if resolved.get("enabled") is False:
5398
+ handler.send_response(503)
5399
+ handler.send_header("Content-Type", "application/json; charset=utf-8")
5400
+ handler.end_headers()
5401
+ handler.wfile.write(json.dumps({"error": "Lingzhu connector is disabled"}, ensure_ascii=False).encode("utf-8"))
5402
+ return
5403
+
5404
+ auth_ak = self.config_manager._secret(resolved, "auth_ak", "auth_ak_env")
5405
+ auth_header = headers.get("Authorization") or headers.get("authorization") or ""
5406
+ if not lingzhu_verify_auth_header(auth_header, auth_ak):
5407
+ handler.send_response(401)
5408
+ handler.send_header("Content-Type", "application/json; charset=utf-8")
5409
+ handler.end_headers()
5410
+ handler.wfile.write(json.dumps({"error": "Unauthorized"}, ensure_ascii=False).encode("utf-8"))
5411
+ return
5412
+
5413
+ try:
5414
+ body = self.handlers.parse_body(raw_body)
5415
+ except Exception:
5416
+ handler.send_response(400)
5417
+ handler.send_header("Content-Type", "application/json; charset=utf-8")
5418
+ handler.end_headers()
5419
+ handler.wfile.write(json.dumps({"error": "Invalid JSON body"}, ensure_ascii=False).encode("utf-8"))
5420
+ return
5421
+
5422
+ if not isinstance(body, dict):
5423
+ handler.send_response(400)
5424
+ handler.send_header("Content-Type", "application/json; charset=utf-8")
5425
+ handler.end_headers()
5426
+ handler.wfile.write(json.dumps({"error": "Request body must be a JSON object"}, ensure_ascii=False).encode("utf-8"))
5427
+ return
5428
+
5429
+ message_id = str(body.get("message_id") or body.get("request_id") or generate_id("lingzhu")).strip()
5430
+ agent_id = str(body.get("agent_id") or resolved.get("agent_id") or "main").strip() or "main"
5431
+ messages = body.get("message")
5432
+ if not isinstance(messages, list):
5433
+ messages = body.get("messages")
5434
+ if not isinstance(messages, list):
5435
+ text = str(body.get("text") or body.get("content") or "").strip()
5436
+ messages = [{"role": "user", "type": "text", "text": text}] if text else None
5437
+ if not isinstance(messages, list):
5438
+ handler.send_response(400)
5439
+ handler.send_header("Content-Type", "application/json; charset=utf-8")
5440
+ handler.end_headers()
5441
+ handler.wfile.write(
5442
+ json.dumps({"error": "Missing required fields: message or messages"}, ensure_ascii=False).encode("utf-8")
5443
+ )
5444
+ return
5445
+
5446
+ conversation_id = lingzhu_request_conversation_id(body)
5447
+ binding_conversation_id = self._logical_connector_binding_conversation("lingzhu", conversation_id)
5448
+ sender_id = lingzhu_request_sender_id(body)
5449
+ inbound_text = lingzhu_extract_user_text(messages) or self._polite_copy(
5450
+ zh="你好,请继续。",
5451
+ en="Hello, please continue.",
5452
+ )
5453
+ channel = self._channel_with_bindings("lingzhu")
5454
+ known_quest_id = self._resolve_lingzhu_bound_quest(conversation_id)
5455
+ delivered_count = self._lingzhu_delivered_count(conversation_id)
5456
+ task_text = lingzhu_extract_task_text(inbound_text)
5457
+ is_command = inbound_text.startswith(channel.command_prefix()) or self._parse_lingzhu_short_command(inbound_text) is not None
5458
+
5459
+ inbound_payload = {
5460
+ "conversation_id": conversation_id,
5461
+ "chat_type": "direct",
5462
+ "message_id": message_id,
5463
+ "sender_id": sender_id,
5464
+ "sender_name": sender_id,
5465
+ "user_id": sender_id,
5466
+ "direct_id": sender_id,
5467
+ "text": inbound_text,
5468
+ "message": inbound_text,
5469
+ "content": inbound_text,
5470
+ "raw_event": body,
5471
+ "metadata": body.get("metadata"),
5472
+ }
5473
+
5474
+ handler.send_response(200)
5475
+ handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
5476
+ handler.send_header("Cache-Control", "no-cache")
5477
+ handler.send_header("Connection", "close")
5478
+ handler.send_header("X-Accel-Buffering", "no")
5479
+ handler.end_headers()
5480
+
5481
+ try:
5482
+ handler.wfile.write(b": keepalive\n\n")
5483
+ handler.wfile.flush()
5484
+
5485
+ if is_command:
5486
+ result = self.handle_connector_inbound("lingzhu", inbound_payload)
5487
+ reply_text, _, _ = self._lingzhu_reply_payload(result)
5488
+ pending_records, total_count = self._lingzhu_pending_outbox_records(
5489
+ conversation_id,
5490
+ delivered_count=delivered_count,
5491
+ )
5492
+ emitted = self._lingzhu_emit_outbox_records(
5493
+ handler,
5494
+ message_id=message_id,
5495
+ agent_id=agent_id,
5496
+ records=pending_records,
5497
+ config=resolved,
5498
+ )
5499
+ if emitted:
5500
+ self._set_lingzhu_delivered_count(conversation_id, total_count)
5501
+ else:
5502
+ answer_text = reply_text
5503
+ if not answer_text:
5504
+ if not bool(result.get("accepted", False)):
5505
+ reason = str(result.get("reason") or (result.get("normalized") or {}).get("reason") or "").strip()
5506
+ answer_text = reason or "请求未接受"
5507
+ else:
5508
+ answer_text = "已收到"
5509
+ self._write_sse_event(
5510
+ handler,
5511
+ event="message",
5512
+ data=lingzhu_sse_answer(
5513
+ message_id=message_id,
5514
+ agent_id=agent_id,
5515
+ answer_stream=answer_text,
5516
+ is_finish=True,
5517
+ ),
5518
+ )
5519
+ handler.close_connection = True
5520
+ return
5521
+
5522
+ if task_text is not None:
5523
+ target_quest_id = known_quest_id
5524
+ if target_quest_id:
5525
+ self.sessions.bind(target_quest_id, conversation_id)
5526
+ self.quest_service.bind_source(target_quest_id, binding_conversation_id)
5527
+ pending_before, total_before = self._lingzhu_pending_outbox_records(
5528
+ conversation_id,
5529
+ delivered_count=delivered_count,
5530
+ )
5531
+ emitted_before = self._lingzhu_emit_outbox_records(
5532
+ handler,
5533
+ message_id=message_id,
5534
+ agent_id=agent_id,
5535
+ records=pending_before,
5536
+ config=resolved,
5537
+ )
5538
+ if emitted_before:
5539
+ delivered_count = total_before
5540
+ self._set_lingzhu_delivered_count(conversation_id, total_before)
5541
+
5542
+ if not target_quest_id:
5543
+ self._write_sse_event(
5544
+ handler,
5545
+ event="message",
5546
+ data=lingzhu_sse_answer(
5547
+ message_id=message_id,
5548
+ agent_id=agent_id,
5549
+ answer_stream=self._lingzhu_unbound_help_text(),
5550
+ is_finish=True,
5551
+ ),
5552
+ )
5553
+ handler.close_connection = True
5554
+ return
5555
+
5556
+ self.submit_user_message(
5557
+ target_quest_id,
5558
+ text=task_text,
5559
+ source=conversation_id,
5560
+ client_message_id=message_id,
5561
+ )
5562
+ pending_after, total_after = self._lingzhu_wait_for_outbox_records(
5563
+ conversation_id,
5564
+ delivered_count=delivered_count,
5565
+ timeout_seconds=self._lingzhu_wait_timeout_seconds(resolved),
5566
+ )
5567
+ emitted_after = self._lingzhu_emit_outbox_records(
5568
+ handler,
5569
+ message_id=message_id,
5570
+ agent_id=agent_id,
5571
+ records=pending_after,
5572
+ config=resolved,
5573
+ )
5574
+ if emitted_after:
5575
+ self._set_lingzhu_delivered_count(conversation_id, total_after)
5576
+ else:
5577
+ self._write_sse_event(
5578
+ handler,
5579
+ event="message",
5580
+ data=lingzhu_sse_answer(
5581
+ message_id=message_id,
5582
+ agent_id=agent_id,
5583
+ answer_stream="已开始" if emitted_before else self._lingzhu_short_status_text(target_quest_id),
5584
+ is_finish=True,
5585
+ ),
5586
+ )
5587
+ handler.close_connection = True
5588
+ return
5589
+
5590
+ if known_quest_id:
5591
+ self.sessions.bind(known_quest_id, conversation_id)
5592
+ self.quest_service.bind_source(known_quest_id, binding_conversation_id)
5593
+
5594
+ pending_records, total_count = self._lingzhu_pending_outbox_records(
5595
+ conversation_id,
5596
+ delivered_count=delivered_count,
5597
+ )
5598
+ emitted = self._lingzhu_emit_outbox_records(
5599
+ handler,
5600
+ message_id=message_id,
5601
+ agent_id=agent_id,
5602
+ records=pending_records,
5603
+ config=resolved,
5604
+ )
5605
+ if emitted:
5606
+ self._set_lingzhu_delivered_count(conversation_id, total_count)
5607
+ else:
5608
+ self._write_sse_event(
5609
+ handler,
5610
+ event="message",
5611
+ data=lingzhu_sse_answer(
5612
+ message_id=message_id,
5613
+ agent_id=agent_id,
5614
+ answer_stream=self._lingzhu_short_status_text(known_quest_id),
5615
+ is_finish=True,
5616
+ ),
5617
+ )
5618
+ handler.close_connection = True
5619
+ except (BrokenPipeError, ConnectionResetError, TimeoutError):
5620
+ return
5621
+
4266
5622
  @staticmethod
4267
5623
  def _write_sse_event(
4268
5624
  handler: BaseHTTPRequestHandler,
@@ -4679,6 +6035,7 @@ class DaemonApp:
4679
6035
  return
4680
6036
 
4681
6037
  def serve(self, host: str, port: int) -> None:
6038
+ self._install_process_observability()
4682
6039
  app = self
4683
6040
 
4684
6041
  class RequestHandler(BaseHTTPRequestHandler):
@@ -4730,6 +6087,14 @@ class DaemonApp:
4730
6087
  except Exception as exc:
4731
6088
  self._write_json(500, {"ok": False, "message": str(exc)})
4732
6089
  return
6090
+ if route_name == "lingzhu_sse":
6091
+ content_length = int(self.headers.get("Content-Length", "0"))
6092
+ raw_body = self.rfile.read(content_length) if content_length else b""
6093
+ try:
6094
+ app.stream_lingzhu_sse(self, raw_body=raw_body, headers=dict(self.headers.items()))
6095
+ except Exception as exc:
6096
+ self._write_json(500, {"ok": False, "message": str(exc)})
6097
+ return
4733
6098
 
4734
6099
  content_length = int(self.headers.get("Content-Length", "0"))
4735
6100
  raw_body = self.rfile.read(content_length) if content_length else b""
@@ -4765,11 +6130,14 @@ class DaemonApp:
4765
6130
  "terminal_restore",
4766
6131
  "terminal_history",
4767
6132
  "latex_builds",
6133
+ "arxiv_list",
6134
+ "annotations_file",
6135
+ "annotations_project",
4768
6136
  }:
4769
6137
  payload = result(**params, path=self.path)
4770
6138
  elif method == "GET":
4771
6139
  payload = result(**params) if params else result()
4772
- elif route_name in {"document_open", "document_asset_upload", "chat", "command", "quest_control", "config_save", "quest_create", "quest_baseline_binding", "run_create", "qq_inbound", "connector_inbound", "docs_open", "admin_shutdown", "bash_stop", "quest_settings", "quest_bindings", "quest_delete", "quest_layout_update", "terminal_session_ensure", "terminal_attach", "terminal_input", "stage_view", "latex_init", "latex_compile", "system_update_action"}:
6140
+ elif route_name in {"document_open", "document_asset_upload", "chat", "command", "quest_control", "config_save", "quest_create", "quest_baseline_binding", "run_create", "qq_inbound", "connector_inbound", "docs_open", "admin_shutdown", "bash_stop", "quest_settings", "quest_bindings", "quest_delete", "quest_layout_update", "terminal_session_ensure", "terminal_attach", "terminal_input", "stage_view", "latex_init", "latex_compile", "system_update_action", "weixin_login_qr_start", "weixin_login_qr_wait", "arxiv_import", "annotation_create"}:
4773
6141
  payload = result(**params, body=body)
4774
6142
  elif route_name == "config_validate":
4775
6143
  payload = result(body)
@@ -4818,11 +6186,20 @@ class DaemonApp:
4818
6186
  self._shutdown_requested.clear()
4819
6187
  self._start_terminal_attach_server(host, port)
4820
6188
  self._start_background_connectors()
6189
+ self._resume_reconciled_quests()
4821
6190
  print(f"DeepScientist daemon listening on http://{host}:{port}")
4822
6191
  try:
4823
6192
  server.serve_forever()
4824
6193
  except KeyboardInterrupt:
4825
- pass
6194
+ self.logger.log("warning", "daemon.keyboard_interrupt", pid=os.getpid())
6195
+ except BaseException as exc:
6196
+ self._log_unhandled_exception(
6197
+ event_type="daemon.serve_crashed",
6198
+ exc_type=type(exc),
6199
+ exc_value=exc,
6200
+ exc_traceback=exc.__traceback__,
6201
+ )
6202
+ raise
4826
6203
  finally:
4827
6204
  self._stop_background_connectors()
4828
6205
  self._stop_terminal_attach_server()