@researai/deepscientist 1.5.0 → 1.5.1

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 (163) hide show
  1. package/AGENTS.md +26 -0
  2. package/README.md +19 -179
  3. package/assets/connectors/lingzhu/openclaw-bridge/README.md +124 -0
  4. package/assets/connectors/lingzhu/openclaw-bridge/index.ts +162 -0
  5. package/assets/connectors/lingzhu/openclaw-bridge/openclaw.plugin.json +145 -0
  6. package/assets/connectors/lingzhu/openclaw-bridge/package.json +35 -0
  7. package/assets/connectors/lingzhu/openclaw-bridge/src/cli.ts +180 -0
  8. package/assets/connectors/lingzhu/openclaw-bridge/src/config.ts +196 -0
  9. package/assets/connectors/lingzhu/openclaw-bridge/src/debug-log.ts +111 -0
  10. package/assets/connectors/lingzhu/openclaw-bridge/src/events.ts +4 -0
  11. package/assets/connectors/lingzhu/openclaw-bridge/src/http-handler.ts +1133 -0
  12. package/assets/connectors/lingzhu/openclaw-bridge/src/image-cache.ts +75 -0
  13. package/assets/connectors/lingzhu/openclaw-bridge/src/lingzhu-tools.ts +246 -0
  14. package/assets/connectors/lingzhu/openclaw-bridge/src/transform.ts +541 -0
  15. package/assets/connectors/lingzhu/openclaw-bridge/src/types.ts +131 -0
  16. package/assets/connectors/lingzhu/openclaw-bridge/tsconfig.json +14 -0
  17. package/assets/connectors/lingzhu/openclaw.lingzhu.config.template.json +39 -0
  18. package/bin/ds.js +233 -53
  19. package/docs/en/00_QUICK_START.md +134 -0
  20. package/docs/en/01_SETTINGS_REFERENCE.md +1104 -0
  21. package/docs/en/02_START_RESEARCH_GUIDE.md +404 -0
  22. package/docs/en/03_QQ_CONNECTOR_GUIDE.md +325 -0
  23. package/docs/en/04_LINGZHU_CONNECTOR_GUIDE.md +216 -0
  24. package/docs/en/05_TUI_GUIDE.md +141 -0
  25. package/docs/en/06_RUNTIME_AND_CANVAS.md +679 -0
  26. package/docs/en/07_MEMORY_AND_MCP.md +253 -0
  27. package/docs/en/08_FIGURE_STYLE_GUIDE.md +97 -0
  28. package/docs/en/09_DOCTOR.md +108 -0
  29. package/docs/en/90_ARCHITECTURE.md +245 -0
  30. package/docs/en/91_DEVELOPMENT.md +195 -0
  31. package/docs/en/99_ACKNOWLEDGEMENTS.md +29 -0
  32. package/docs/zh/00_QUICK_START.md +134 -0
  33. package/docs/zh/01_SETTINGS_REFERENCE.md +1137 -0
  34. package/docs/zh/02_START_RESEARCH_GUIDE.md +414 -0
  35. package/docs/zh/03_QQ_CONNECTOR_GUIDE.md +324 -0
  36. package/docs/zh/04_LINGZHU_CONNECTOR_GUIDE.md +230 -0
  37. package/docs/zh/05_TUI_GUIDE.md +128 -0
  38. package/docs/zh/06_RUNTIME_AND_CANVAS.md +271 -0
  39. package/docs/zh/07_MEMORY_AND_MCP.md +235 -0
  40. package/docs/zh/08_FIGURE_STYLE_GUIDE.md +97 -0
  41. package/docs/zh/09_DOCTOR.md +112 -0
  42. package/docs/zh/99_ACKNOWLEDGEMENTS.md +29 -0
  43. package/install.sh +32 -8
  44. package/package.json +4 -2
  45. package/pyproject.toml +1 -1
  46. package/src/deepscientist/artifact/guidance.py +9 -2
  47. package/src/deepscientist/artifact/service.py +482 -22
  48. package/src/deepscientist/bash_exec/monitor.py +27 -5
  49. package/src/deepscientist/bash_exec/runtime.py +639 -0
  50. package/src/deepscientist/bash_exec/service.py +99 -16
  51. package/src/deepscientist/bridges/base.py +3 -0
  52. package/src/deepscientist/bridges/connectors.py +292 -13
  53. package/src/deepscientist/channels/qq.py +19 -2
  54. package/src/deepscientist/channels/relay.py +1 -0
  55. package/src/deepscientist/cli.py +32 -25
  56. package/src/deepscientist/config/models.py +28 -2
  57. package/src/deepscientist/config/service.py +201 -6
  58. package/src/deepscientist/connector_runtime.py +2 -0
  59. package/src/deepscientist/daemon/api/handlers.py +50 -5
  60. package/src/deepscientist/daemon/api/router.py +1 -0
  61. package/src/deepscientist/daemon/app.py +442 -15
  62. package/src/deepscientist/doctor.py +444 -0
  63. package/src/deepscientist/home.py +1 -0
  64. package/src/deepscientist/latex_runtime.py +17 -4
  65. package/src/deepscientist/lingzhu_support.py +182 -0
  66. package/src/deepscientist/mcp/server.py +49 -2
  67. package/src/deepscientist/prompts/builder.py +181 -58
  68. package/src/deepscientist/quest/layout.py +1 -0
  69. package/src/deepscientist/quest/service.py +63 -2
  70. package/src/deepscientist/quest/stage_views.py +19 -1
  71. package/src/deepscientist/runtime_tools/__init__.py +16 -0
  72. package/src/deepscientist/runtime_tools/builtins.py +19 -0
  73. package/src/deepscientist/runtime_tools/models.py +29 -0
  74. package/src/deepscientist/runtime_tools/registry.py +40 -0
  75. package/src/deepscientist/runtime_tools/service.py +59 -0
  76. package/src/deepscientist/runtime_tools/tinytex.py +25 -0
  77. package/src/deepscientist/tinytex.py +276 -0
  78. package/src/prompts/connectors/lingzhu.md +12 -0
  79. package/src/prompts/connectors/qq.md +121 -0
  80. package/src/prompts/system.md +177 -33
  81. package/src/skills/analysis-campaign/SKILL.md +22 -6
  82. package/src/skills/baseline/SKILL.md +5 -4
  83. package/src/skills/decision/SKILL.md +4 -3
  84. package/src/skills/experiment/SKILL.md +5 -4
  85. package/src/skills/finalize/SKILL.md +5 -4
  86. package/src/skills/idea/SKILL.md +5 -4
  87. package/src/skills/intake-audit/SKILL.md +277 -0
  88. package/src/skills/intake-audit/references/state-audit-template.md +41 -0
  89. package/src/skills/rebuttal/SKILL.md +407 -0
  90. package/src/skills/rebuttal/references/action-plan-template.md +63 -0
  91. package/src/skills/rebuttal/references/evidence-update-template.md +30 -0
  92. package/src/skills/rebuttal/references/response-letter-template.md +113 -0
  93. package/src/skills/rebuttal/references/review-matrix-template.md +55 -0
  94. package/src/skills/review/SKILL.md +293 -0
  95. package/src/skills/review/references/experiment-todo-template.md +29 -0
  96. package/src/skills/review/references/review-report-template.md +83 -0
  97. package/src/skills/review/references/revision-log-template.md +40 -0
  98. package/src/skills/scout/SKILL.md +5 -4
  99. package/src/skills/write/SKILL.md +7 -3
  100. package/src/tui/dist/components/WelcomePanel.js +17 -43
  101. package/src/tui/dist/components/messages/BashExecOperationMessage.js +3 -2
  102. package/src/tui/package.json +1 -1
  103. package/src/ui/dist/assets/{AiManusChatView-7v-dHngU.js → AiManusChatView-w5lF2Ttt.js} +109 -575
  104. package/src/ui/dist/assets/{AnalysisPlugin-B_Xmz-KE.js → AnalysisPlugin-DJOED79I.js} +1 -1
  105. package/src/ui/dist/assets/{AutoFigurePlugin-Cko-0tm1.js → AutoFigurePlugin-DaG61Y0M.js} +63 -8
  106. package/src/ui/dist/assets/{CliPlugin-BsU0ht7q.js → CliPlugin-CV4LqUB_.js} +43 -609
  107. package/src/ui/dist/assets/{CodeEditorPlugin-DcMMP0Rt.js → CodeEditorPlugin-DylfAea4.js} +8 -8
  108. package/src/ui/dist/assets/{CodeViewerPlugin-BqoQ5QyY.js → CodeViewerPlugin-F7saY0LM.js} +5 -5
  109. package/src/ui/dist/assets/{DocViewerPlugin-D7eHNhU6.js → DocViewerPlugin-COP0c7jf.js} +3 -3
  110. package/src/ui/dist/assets/{GitDiffViewerPlugin-DLJN42T5.js → GitDiffViewerPlugin-CAS05pT9.js} +1 -1
  111. package/src/ui/dist/assets/{ImageViewerPlugin-gJMV7MOu.js → ImageViewerPlugin-Bco1CN_w.js} +5 -6
  112. package/src/ui/dist/assets/{LabCopilotPanel-B857sfxP.js → LabCopilotPanel-CvMlCD99.js} +12 -15
  113. package/src/ui/dist/assets/LabPlugin-BYankkE4.js +2676 -0
  114. package/src/ui/dist/assets/LabPlugin-D9jVIo0A.css +2698 -0
  115. package/src/ui/dist/assets/{LatexPlugin-DWKEo-Wj.js → LatexPlugin-LDSMR-t-.js} +16 -16
  116. package/src/ui/dist/assets/{MarkdownViewerPlugin-DBzoEmhv.js → MarkdownViewerPlugin-B7o80jgm.js} +4 -4
  117. package/src/ui/dist/assets/{MarketplacePlugin-DoHc-8vo.js → MarketplacePlugin-CM6ZOcpC.js} +3 -3
  118. package/src/ui/dist/assets/{NotebookEditor-CKjKH-yS.js → NotebookEditor-Dc61cXmK.js} +3 -3
  119. package/src/ui/dist/assets/{PdfLoader-zFoL0VPo.js → PdfLoader-DWowuQwx.js} +1 -1
  120. package/src/ui/dist/assets/{PdfMarkdownPlugin-DXPaL9Nt.js → PdfMarkdownPlugin-BsJM1q_a.js} +3 -3
  121. package/src/ui/dist/assets/{PdfViewerPlugin-DhK8qCFp.js → PdfViewerPlugin-DB2eEEFQ.js} +10 -10
  122. package/src/ui/dist/assets/{SearchPlugin-CdSi6krf.js → SearchPlugin-CraThSvt.js} +1 -1
  123. package/src/ui/dist/assets/{Stepper-V-WiDQJl.js → Stepper-CgocRTPq.js} +1 -1
  124. package/src/ui/dist/assets/{TextViewerPlugin-hIs1Efiu.js → TextViewerPlugin-B1JGhKtd.js} +4 -4
  125. package/src/ui/dist/assets/{VNCViewer-DG8b0q2X.js → VNCViewer-CclFC7FM.js} +9 -10
  126. package/src/ui/dist/assets/{bibtex-HDac6fVW.js → bibtex-D3IKsMl7.js} +1 -1
  127. package/src/ui/dist/assets/{code-BnBeNxBc.js → code-BP37Xx0p.js} +1 -1
  128. package/src/ui/dist/assets/{file-content-IRQ3jHb8.js → file-content-BAJSu-9r.js} +1 -1
  129. package/src/ui/dist/assets/{file-diff-panel-DZoQ9I6r.js → file-diff-panel-DUGeCTuy.js} +1 -1
  130. package/src/ui/dist/assets/{file-socket-BMCdLc-P.js → file-socket-CXc1Ojf7.js} +1 -1
  131. package/src/ui/dist/assets/{file-utils-CltILB3w.js → file-utils-2J21jt7M.js} +1 -1
  132. package/src/ui/dist/assets/{image-Boe6ffhu.js → image-CMMmgvcn.js} +1 -1
  133. package/src/ui/dist/assets/{index-BlplpvE1.js → index-BaVumsQT.js} +2 -2
  134. package/src/ui/dist/assets/{index-DZqJ-qAM.js → index-CWgMgpow.js} +60 -2154
  135. package/src/ui/dist/assets/{index-DO43pFZP.js → index-DmwmJmbW.js} +6372 -8434
  136. package/src/ui/dist/assets/{index-Bq2bvfkl.css → index-KGt-z-dD.css} +225 -2920
  137. package/src/ui/dist/assets/{index-2Zf65FZt.js → index-s7aHnNQ4.js} +1 -1
  138. package/src/ui/dist/assets/{message-square-mUHn_Ssb.js → message-square-CQRfX0Am.js} +1 -1
  139. package/src/ui/dist/assets/{monaco-fe0arNEU.js → monaco-B4TbdsrF.js} +1 -1
  140. package/src/ui/dist/assets/{popover-D_7i19qU.js → popover-B8Rokodk.js} +1 -1
  141. package/src/ui/dist/assets/{project-sync-DyVGrU7H.js → project-sync-D_i96KH4.js} +2 -8
  142. package/src/ui/dist/assets/{sigma-BzazRyxQ.js → sigma-D12PnzCN.js} +1 -1
  143. package/src/ui/dist/assets/{tooltip-DN_yjHFH.js → tooltip-B6YrI4aJ.js} +1 -1
  144. package/src/ui/dist/assets/trash-Bc8jGp0V.js +32 -0
  145. package/src/ui/dist/assets/{useCliAccess-DV2L2Qxy.js → useCliAccess-mXVCYSZ-.js} +12 -42
  146. package/src/ui/dist/assets/{useFileDiffOverlay-DyTj-p_V.js → useFileDiffOverlay-Bg6b9H9K.js} +1 -1
  147. package/src/ui/dist/assets/{wrap-text-ozYHtUwq.js → wrap-text-Drh5GEnL.js} +1 -1
  148. package/src/ui/dist/assets/{zoom-out-BN9MUyCQ.js → zoom-out-CJj9DZLn.js} +1 -1
  149. package/src/ui/dist/index.html +2 -2
  150. package/assets/fonts/Inter-Variable.ttf +0 -0
  151. package/assets/fonts/NotoSerifSC-Regular-C94HN_ZN.ttf +0 -0
  152. package/assets/fonts/NunitoSans-Variable.ttf +0 -0
  153. package/assets/fonts/Satoshi-Medium-ByP-Zb-9.woff2 +0 -0
  154. package/assets/fonts/SourceSans3-Variable.ttf +0 -0
  155. package/assets/fonts/ds-fonts.css +0 -83
  156. package/src/ui/dist/assets/Inter-Variable-VF2RPR_K.ttf +0 -0
  157. package/src/ui/dist/assets/LabPlugin-bL7rpic8.js +0 -43
  158. package/src/ui/dist/assets/NotoSerifSC-Regular-C94HN_ZN-C94HN_ZN.ttf +0 -0
  159. package/src/ui/dist/assets/NunitoSans-Variable-B_ZymHAd.ttf +0 -0
  160. package/src/ui/dist/assets/Satoshi-Medium-ByP-Zb-9-GkA34YXu.woff2 +0 -0
  161. package/src/ui/dist/assets/SourceSans3-Variable-CD-WOsSK.ttf +0 -0
  162. package/src/ui/dist/assets/info-CcsK_htA.js +0 -18
  163. package/src/ui/dist/assets/user-plus-BusDx-hF.js +0 -79
@@ -1,6 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import base64
3
4
  import json
5
+ import mimetypes
4
6
  import os
5
7
  import shutil
6
8
  import threading
@@ -8,11 +10,14 @@ import time
8
10
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
9
11
  from pathlib import Path
10
12
  from typing import Any
11
- from urllib.parse import urlencode, urlparse
13
+ from urllib.parse import parse_qs, urlencode, urlparse
14
+ from urllib.request import Request, urlopen
12
15
 
13
16
  from ..artifact import ArtifactService
14
17
  from ..bash_exec import BashExecService
18
+ from ..bash_exec.runtime import TerminalClient
15
19
  from ..bridges import get_connector_bridge, register_builtin_connector_bridges
20
+ from ..bridges.connectors import QQConnectorBridge
16
21
  from ..channels import QQRelayChannel, get_channel_factory, list_channel_names, register_builtin_channels
17
22
  from ..channels.discord_gateway import DiscordGatewayService
18
23
  from ..channels.feishu_long_connection import FeishuLongConnectionService
@@ -31,14 +36,24 @@ from ..prompts.builder import STANDARD_SKILLS
31
36
  from ..quest import QuestService
32
37
  from ..runners import CodexRunner, RunRequest, get_runner_factory, register_builtin_runners
33
38
  from ..runtime_logs import JsonlLogger
34
- from ..shared import append_jsonl, generate_id, read_json, read_jsonl, read_text, resolve_within, run_command, utc_now, which
39
+ 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
35
40
  from ..skills import SkillInstaller
36
41
  from ..team import SingleTeamService
37
42
  from .api import ApiHandlers, match_route
38
43
  from .sessions import SessionStore
44
+ from websockets.datastructures import Headers
45
+ from websockets.exceptions import ConnectionClosed
46
+ from websockets.http11 import Request as WebSocketRequest
47
+ from websockets.http11 import Response
48
+ from websockets.sync.server import Server as WebSocketServer
49
+ from websockets.sync.server import ServerConnection, serve as websocket_serve
50
+
51
+ TERMINAL_STREAM_IDLE_SLEEP_SECONDS = 0.02
39
52
 
40
53
 
41
54
  class DaemonApp:
55
+ _MAX_INBOUND_ATTACHMENT_BYTES = 25 * 1024 * 1024
56
+
42
57
  def __init__(self, home: Path) -> None:
43
58
  self.home = home.resolve()
44
59
  self.daemon_id = str(os.environ.get("DS_DAEMON_ID") or "").strip() or generate_id("daemon")
@@ -87,6 +102,10 @@ class DaemonApp:
87
102
  self._turn_lock = threading.Lock()
88
103
  self._turn_state: dict[str, dict[str, object]] = {}
89
104
  self._server: ThreadingHTTPServer | None = None
105
+ self._terminal_attach_server: WebSocketServer | None = None
106
+ self._terminal_attach_thread: threading.Thread | None = None
107
+ self._terminal_attach_host: str | None = None
108
+ self._terminal_attach_port: int | None = None
90
109
  self._shutdown_requested = threading.Event()
91
110
  self._qq_gateway: QQGatewayService | None = None
92
111
  self._telegram_polling: TelegramPollingService | None = None
@@ -96,6 +115,196 @@ class DaemonApp:
96
115
  self._whatsapp_local_session: WhatsAppLocalSessionService | None = None
97
116
  self.handlers = ApiHandlers(self)
98
117
 
118
+ def list_connector_statuses(self) -> list[dict[str, object]]:
119
+ items = [channel.status() for channel in self.channels.values()]
120
+ lingzhu_config = self.connectors_config.get("lingzhu")
121
+ if isinstance(lingzhu_config, dict):
122
+ items.append(self.config_manager.lingzhu_snapshot(lingzhu_config))
123
+ return items
124
+
125
+ def _process_terminal_attach_request(
126
+ self,
127
+ connection: ServerConnection,
128
+ request: WebSocketRequest,
129
+ ) -> Response | None:
130
+ query = parse_qs(urlparse(request.path).query)
131
+ token = str((query.get("token") or [""])[0] or "").strip()
132
+ if not token:
133
+ return Response(
134
+ 400,
135
+ "Bad Request",
136
+ Headers({"Content-Type": "text/plain; charset=utf-8"}),
137
+ b"Missing terminal attach token.",
138
+ )
139
+ attach_token, runtime = self.bash_exec_service.resolve_terminal_attach_token(token)
140
+ if attach_token is None:
141
+ return Response(
142
+ 404,
143
+ "Not Found",
144
+ Headers({"Content-Type": "text/plain; charset=utf-8"}),
145
+ b"Terminal attach token is invalid or expired.",
146
+ )
147
+ if runtime is None:
148
+ return Response(
149
+ 409,
150
+ "Conflict",
151
+ Headers({"Content-Type": "text/plain; charset=utf-8"}),
152
+ b"Terminal runtime is no longer active.",
153
+ )
154
+ setattr(connection, "_ds_terminal_attach_token", token)
155
+ return None
156
+
157
+ def _handle_terminal_attach_connection(self, connection: ServerConnection) -> None:
158
+ token_value = str(getattr(connection, "_ds_terminal_attach_token", "") or "").strip()
159
+ attach_token, runtime = self.bash_exec_service.consume_terminal_attach_token(token_value)
160
+ if attach_token is None or runtime is None:
161
+ try:
162
+ connection.close(code=1011, reason="terminal_attach_unavailable")
163
+ except Exception:
164
+ pass
165
+ return
166
+
167
+ send_lock = threading.Lock()
168
+ client = TerminalClient(
169
+ client_id=generate_id("tclient"),
170
+ send_text=connection.send,
171
+ send_binary=connection.send,
172
+ close=connection.close,
173
+ send_lock=send_lock,
174
+ )
175
+ runtime.attach_client(client)
176
+ try:
177
+ session = self.bash_exec_service.get_session(attach_token.quest_root, attach_token.bash_id)
178
+ with send_lock:
179
+ connection.send(
180
+ json.dumps(
181
+ {
182
+ "type": "ready",
183
+ "bash_id": attach_token.bash_id,
184
+ "status": session.get("status"),
185
+ "cwd": session.get("cwd"),
186
+ "workdir": session.get("workdir"),
187
+ },
188
+ ensure_ascii=False,
189
+ )
190
+ )
191
+ for chunk in runtime.snapshot_replay():
192
+ if chunk:
193
+ connection.send(chunk)
194
+ while True:
195
+ try:
196
+ message = connection.recv()
197
+ except ConnectionClosed:
198
+ break
199
+ if message is None:
200
+ break
201
+ if isinstance(message, bytes):
202
+ runtime.write_binary_input(message)
203
+ continue
204
+ try:
205
+ payload = json.loads(message)
206
+ except json.JSONDecodeError:
207
+ continue
208
+ if not isinstance(payload, dict):
209
+ continue
210
+ message_type = str(payload.get("type") or "").strip().lower()
211
+ if message_type == "input":
212
+ self.bash_exec_service.append_terminal_input(
213
+ attach_token.quest_root,
214
+ attach_token.bash_id,
215
+ data=str(payload.get("data") or ""),
216
+ source="web-pty",
217
+ )
218
+ continue
219
+ if message_type == "binary_input":
220
+ raw = str(payload.get("data") or "")
221
+ if raw:
222
+ runtime.write_binary_input(base64.b64decode(raw))
223
+ continue
224
+ if message_type == "resize":
225
+ cols = int(payload.get("cols") or 0)
226
+ rows = int(payload.get("rows") or 0)
227
+ self.bash_exec_service.resize_terminal_session(
228
+ attach_token.quest_root,
229
+ attach_token.bash_id,
230
+ cols=cols,
231
+ rows=rows,
232
+ )
233
+ continue
234
+ if message_type == "detach":
235
+ break
236
+ if message_type == "ping":
237
+ with send_lock:
238
+ connection.send(json.dumps({"type": "pong"}, ensure_ascii=False))
239
+ except Exception as exc:
240
+ try:
241
+ with send_lock:
242
+ connection.send(
243
+ json.dumps(
244
+ {"type": "error", "message": str(exc)},
245
+ ensure_ascii=False,
246
+ )
247
+ )
248
+ except Exception:
249
+ pass
250
+ finally:
251
+ runtime.detach_client(client.client_id)
252
+ try:
253
+ connection.close()
254
+ except Exception:
255
+ pass
256
+
257
+ def _start_terminal_attach_server(self, host: str, port: int) -> None:
258
+ if self._terminal_attach_server is not None:
259
+ return
260
+ candidates: list[int] = []
261
+ if port > 0 and port < 65535:
262
+ candidates.append(port + 1)
263
+ candidates.append(0)
264
+ last_error: Exception | None = None
265
+ for candidate in candidates:
266
+ try:
267
+ server = websocket_serve(
268
+ self._handle_terminal_attach_connection,
269
+ host=host,
270
+ port=candidate,
271
+ process_request=self._process_terminal_attach_request,
272
+ compression=None,
273
+ max_size=None,
274
+ max_queue=None,
275
+ )
276
+ self._terminal_attach_server = server
277
+ self._terminal_attach_host = host
278
+ self._terminal_attach_port = int(server.socket.getsockname()[1])
279
+ thread = threading.Thread(
280
+ target=server.serve_forever,
281
+ daemon=True,
282
+ name="deepscientist-terminal-attach",
283
+ )
284
+ thread.start()
285
+ self._terminal_attach_thread = thread
286
+ return
287
+ except OSError as exc:
288
+ last_error = exc
289
+ continue
290
+ if last_error is not None:
291
+ raise last_error
292
+
293
+ def _stop_terminal_attach_server(self) -> None:
294
+ server = self._terminal_attach_server
295
+ thread = self._terminal_attach_thread
296
+ self._terminal_attach_server = None
297
+ self._terminal_attach_thread = None
298
+ self._terminal_attach_host = None
299
+ self._terminal_attach_port = None
300
+ if server is not None:
301
+ try:
302
+ server.shutdown()
303
+ except Exception:
304
+ pass
305
+ if thread is not None and thread.is_alive():
306
+ thread.join(timeout=1.0)
307
+
99
308
  def get_runner(self, name: str):
100
309
  normalized = str(name or "").strip().lower()
101
310
  try:
@@ -200,6 +409,7 @@ class DaemonApp:
200
409
  source: str = "local",
201
410
  announce_connector_binding: bool = True,
202
411
  exclude_conversation_id: str | None = None,
412
+ preferred_connector_conversation_id: str | None = None,
203
413
  requested_baseline_ref: dict[str, object] | None = None,
204
414
  startup_contract: dict[str, object] | None = None,
205
415
  ) -> dict:
@@ -248,13 +458,48 @@ class DaemonApp:
248
458
  raise RuntimeError(
249
459
  f"Quest creation failed because the requested baseline `{baseline_id}` could not be attached and confirmed: {exc}"
250
460
  ) from exc
251
- self._auto_bind_connectors_to_latest_quest(
252
- snapshot["quest_id"],
253
- goal=goal,
254
- source=source,
255
- announce=announce_connector_binding,
256
- exclude_conversation_id=exclude_conversation_id,
257
- )
461
+ preferred_binding = normalize_conversation_id(preferred_connector_conversation_id)
462
+ preferred_parsed = parse_conversation_id(preferred_binding)
463
+ if (
464
+ preferred_binding
465
+ and preferred_parsed
466
+ and str(preferred_parsed.get("connector") or "").strip().lower() in self.channels
467
+ and str(preferred_parsed.get("connector") or "").strip().lower() != "local"
468
+ ):
469
+ connector_name = str(preferred_parsed.get("connector") or "").strip().lower()
470
+ result = self.update_quest_binding(snapshot["quest_id"], preferred_binding, force=True)
471
+ if isinstance(result, tuple):
472
+ self.logger.log(
473
+ "warning",
474
+ "quest.preferred_connector_binding_failed",
475
+ quest_id=snapshot.get("quest_id"),
476
+ conversation_id=preferred_binding,
477
+ status=result[0],
478
+ message=str(result[1].get("message") or "Unable to bind preferred connector target."),
479
+ )
480
+ elif announce_connector_binding:
481
+ channel = self._channel_with_bindings(connector_name)
482
+ channel.send(
483
+ {
484
+ "conversation_id": preferred_binding,
485
+ "quest_id": snapshot["quest_id"],
486
+ "kind": "ack",
487
+ "message": self._quest_created_connector_message(
488
+ connector_name,
489
+ quest_id=snapshot["quest_id"],
490
+ goal=goal,
491
+ previous_quest_id=str(result.get("previous_quest_id") or "").strip() or None,
492
+ ),
493
+ }
494
+ )
495
+ elif not preferred_binding:
496
+ self._auto_bind_connectors_to_latest_quest(
497
+ snapshot["quest_id"],
498
+ goal=goal,
499
+ source=source,
500
+ announce=announce_connector_binding,
501
+ exclude_conversation_id=exclude_conversation_id,
502
+ )
258
503
  return snapshot
259
504
 
260
505
  def schedule_turn(self, quest_id: str, *, reason: str = "user_message") -> dict:
@@ -650,7 +895,8 @@ class DaemonApp:
650
895
  if state.get("stop_requested"):
651
896
  return
652
897
  snapshot = self.quest_service.snapshot(quest_id)
653
- if str(snapshot.get("status") or snapshot.get("runtime_status") or "").strip() in {"stopped", "paused", "completed", "error"} and not snapshot.get("active_run_id"):
898
+ runtime_status = str(snapshot.get("runtime_status") or snapshot.get("status") or "").strip()
899
+ if runtime_status in {"stopped", "paused", "completed", "error"} and not snapshot.get("active_run_id"):
654
900
  return
655
901
  latest_user_message = self._latest_user_message(quest_id)
656
902
  if turn_reason != "auto_continue" and latest_user_message is None:
@@ -1411,6 +1657,8 @@ class DaemonApp:
1411
1657
  interrupted_quests.append(quest_id)
1412
1658
  self._shutdown_requested.set()
1413
1659
  self._stop_background_connectors()
1660
+ self._stop_terminal_attach_server()
1661
+ self.bash_exec_service.shutdown()
1414
1662
  self.logger.log(
1415
1663
  "info",
1416
1664
  "daemon.shutdown_requested",
@@ -1545,6 +1793,7 @@ class DaemonApp:
1545
1793
  self.sessions.bind(quest_id, normalized)
1546
1794
  self.quest_service.bind_source(quest_id, "local:default")
1547
1795
  self.quest_service.bind_source(quest_id, normalized)
1796
+ removed = self._unbind_external_bindings(quest_id, preserve={normalized})
1548
1797
  snapshot = self.quest_service.snapshot(quest_id)
1549
1798
  previous_quest_id = str(existing_bound or "").strip() or None
1550
1799
  if previous_quest_id == quest_id:
@@ -1560,7 +1809,7 @@ class DaemonApp:
1560
1809
  "quest_id": quest_id,
1561
1810
  "conversation_id": normalized,
1562
1811
  "snapshot": snapshot,
1563
- "removed_conversations": [],
1812
+ "removed_conversations": removed,
1564
1813
  "conflicts_resolved": [item.get("quest_id") for item in deduped_conflicts if item.get("quest_id")],
1565
1814
  "previous_quest_id": previous_quest_id,
1566
1815
  }
@@ -2127,11 +2376,21 @@ class DaemonApp:
2127
2376
  )
2128
2377
 
2129
2378
  self.sessions.bind(quest_id, conversation_id)
2379
+ materialized_attachments = self._materialize_connector_attachments(
2380
+ quest_id=quest_id,
2381
+ connector_name=connector_name,
2382
+ conversation_id=conversation_id,
2383
+ message_id=str(message.get("message_id") or "").strip() or None,
2384
+ attachments=[dict(item) for item in (message.get("attachments") or []) if isinstance(item, dict)],
2385
+ )
2130
2386
  self.submit_user_message(
2131
2387
  quest_id,
2132
- text=text,
2388
+ text=self._connector_message_text_with_attachment_notice(
2389
+ original_text=text,
2390
+ attachments=materialized_attachments,
2391
+ ),
2133
2392
  source=conversation_id,
2134
- attachments=[dict(item) for item in (message.get("attachments") or []) if isinstance(item, dict)],
2393
+ attachments=materialized_attachments,
2135
2394
  )
2136
2395
  return channel.send(
2137
2396
  {
@@ -2148,6 +2407,171 @@ class DaemonApp:
2148
2407
  }
2149
2408
  )
2150
2409
 
2410
+ def _connector_message_text_with_attachment_notice(
2411
+ self,
2412
+ *,
2413
+ original_text: str,
2414
+ attachments: list[dict[str, object]],
2415
+ ) -> str:
2416
+ base = str(original_text or "").strip()
2417
+ if not attachments:
2418
+ return base
2419
+ lines: list[str] = []
2420
+ if base:
2421
+ lines.extend([base, ""])
2422
+ lines.append(
2423
+ self._polite_copy(
2424
+ zh="系统提示:用户刚刚发送了附件。请优先阅读这些 quest 本地文件,再继续处理这条请求:",
2425
+ en="System note: the user just sent attachments. Read these quest-local files first before continuing this request:",
2426
+ )
2427
+ )
2428
+ for index, item in enumerate(attachments, start=1):
2429
+ label = str(
2430
+ item.get("name")
2431
+ or item.get("quest_relative_path")
2432
+ or item.get("path")
2433
+ or item.get("url")
2434
+ or f"attachment-{index}"
2435
+ ).strip()
2436
+ content_type = str(item.get("content_type") or "").strip()
2437
+ location = str(item.get("path") or item.get("url") or "unavailable").strip()
2438
+ error = str(item.get("download_error") or "").strip()
2439
+ suffix = f" ({content_type})" if content_type else ""
2440
+ if error:
2441
+ lines.append(f"- {label}{suffix}: {location} | download_error={error}")
2442
+ else:
2443
+ lines.append(f"- {label}{suffix}: {location}")
2444
+ return "\n".join(lines).strip()
2445
+
2446
+ def _materialize_connector_attachments(
2447
+ self,
2448
+ *,
2449
+ quest_id: str,
2450
+ connector_name: str,
2451
+ conversation_id: str,
2452
+ message_id: str | None,
2453
+ attachments: list[dict[str, object]],
2454
+ ) -> list[dict[str, object]]:
2455
+ if not attachments:
2456
+ return []
2457
+ quest_root = self.home / "quests" / quest_id
2458
+ batch_slug = slugify(message_id or generate_id("userfile"), default=generate_id("userfile"))
2459
+ batch_root = ensure_dir(quest_root / "userfiles" / connector_name / batch_slug)
2460
+ materialized: list[dict[str, object]] = []
2461
+ for index, raw_item in enumerate(attachments, start=1):
2462
+ materialized.append(
2463
+ self._materialize_single_connector_attachment(
2464
+ connector_name=connector_name,
2465
+ quest_root=quest_root,
2466
+ batch_root=batch_root,
2467
+ index=index,
2468
+ attachment=dict(raw_item),
2469
+ )
2470
+ )
2471
+ write_json(
2472
+ batch_root / "manifest.json",
2473
+ {
2474
+ "connector": connector_name,
2475
+ "quest_id": quest_id,
2476
+ "conversation_id": conversation_id,
2477
+ "message_id": message_id,
2478
+ "materialized_at": utc_now(),
2479
+ "attachments": materialized,
2480
+ },
2481
+ )
2482
+ return materialized
2483
+
2484
+ def _materialize_single_connector_attachment(
2485
+ self,
2486
+ *,
2487
+ connector_name: str,
2488
+ quest_root: Path,
2489
+ batch_root: Path,
2490
+ index: int,
2491
+ attachment: dict[str, object],
2492
+ ) -> dict[str, object]:
2493
+ resolved = dict(attachment)
2494
+ name = str(resolved.get("name") or "").strip()
2495
+ content_type = str(resolved.get("content_type") or "").strip()
2496
+ url = str(resolved.get("url") or "").strip()
2497
+ target_path = batch_root / self._connector_attachment_filename(index=index, name=name, content_type=content_type)
2498
+ resolved["manifest_path"] = str(batch_root / "manifest.json")
2499
+ resolved["batch_path"] = str(batch_root)
2500
+ if not url:
2501
+ resolved["materialized"] = False
2502
+ resolved["download_error"] = "missing_download_url"
2503
+ return resolved
2504
+ try:
2505
+ size_bytes = self._download_connector_attachment(
2506
+ connector_name=connector_name,
2507
+ url=url,
2508
+ target_path=target_path,
2509
+ )
2510
+ resolved["path"] = str(target_path)
2511
+ resolved["quest_relative_path"] = str(target_path.relative_to(quest_root))
2512
+ resolved["size_bytes"] = int(size_bytes)
2513
+ resolved["materialized"] = True
2514
+ resolved["downloaded_at"] = utc_now()
2515
+ return resolved
2516
+ except Exception as exc:
2517
+ if target_path.exists():
2518
+ target_path.unlink(missing_ok=True)
2519
+ resolved["materialized"] = False
2520
+ resolved["download_error"] = str(exc)
2521
+ return resolved
2522
+
2523
+ def _download_connector_attachment(
2524
+ self,
2525
+ *,
2526
+ connector_name: str,
2527
+ url: str,
2528
+ target_path: Path,
2529
+ ) -> int:
2530
+ request = Request(url)
2531
+ for key, value in self._connector_attachment_headers(connector_name).items():
2532
+ request.add_header(key, value)
2533
+ ensure_dir(target_path.parent)
2534
+ total = 0
2535
+ with urlopen(request, timeout=20) as response: # noqa: S310
2536
+ with target_path.open("wb") as handle:
2537
+ while True:
2538
+ chunk = response.read(65536)
2539
+ if not chunk:
2540
+ break
2541
+ total += len(chunk)
2542
+ if total > self._MAX_INBOUND_ATTACHMENT_BYTES:
2543
+ raise ValueError(
2544
+ f"attachment exceeds max inbound size limit ({self._MAX_INBOUND_ATTACHMENT_BYTES} bytes)"
2545
+ )
2546
+ handle.write(chunk)
2547
+ return total
2548
+
2549
+ def _connector_attachment_headers(self, connector_name: str) -> dict[str, str]:
2550
+ if str(connector_name or "").strip().lower() != "qq":
2551
+ return {}
2552
+ config = self.connectors_config.get("qq", {})
2553
+ if not isinstance(config, dict):
2554
+ return {}
2555
+ app_id = str(config.get("app_id") or "").strip()
2556
+ app_secret = QQConnectorBridge.read_secret(config, "app_secret", "app_secret_env")
2557
+ if not app_id or not app_secret:
2558
+ return {}
2559
+ return {
2560
+ "Authorization": f"QQBot {QQConnectorBridge._access_token(app_id, app_secret)}",
2561
+ }
2562
+
2563
+ @staticmethod
2564
+ def _connector_attachment_filename(*, index: int, name: str, content_type: str) -> str:
2565
+ raw_name = str(name or "").strip()
2566
+ suffix = Path(raw_name).suffix if raw_name else ""
2567
+ if not suffix and content_type:
2568
+ suffix = mimetypes.guess_extension(content_type, strict=False) or ""
2569
+ if suffix and not suffix.startswith("."):
2570
+ suffix = f".{suffix}"
2571
+ stem_source = Path(raw_name).stem if raw_name else f"attachment-{index:03d}"
2572
+ stem = slugify(stem_source, default=f"attachment-{index:03d}")
2573
+ return f"{stem}{suffix or '.bin'}"
2574
+
2151
2575
  def _qq_channel(self) -> QQRelayChannel:
2152
2576
  return self.channels["qq"] # type: ignore[return-value]
2153
2577
 
@@ -3141,7 +3565,7 @@ class DaemonApp:
3141
3565
  handler.wfile.write(b": keep-alive\n\n")
3142
3566
  handler.wfile.flush()
3143
3567
  heartbeat_at = time.monotonic()
3144
- time.sleep(0.08)
3568
+ time.sleep(TERMINAL_STREAM_IDLE_SLEEP_SECONDS)
3145
3569
  except (BrokenPipeError, ConnectionResetError, TimeoutError):
3146
3570
  return
3147
3571
 
@@ -3243,7 +3667,7 @@ class DaemonApp:
3243
3667
  headers=dict(self.headers.items()),
3244
3668
  body=body,
3245
3669
  )
3246
- elif route_name in {"document_open", "document_asset_upload", "chat", "command", "quest_control", "config_save", "quest_create", "quest_baseline_binding", "run_create", "qq_inbound", "connector_inbound", "docs_open", "admin_shutdown", "bash_stop", "quest_settings", "quest_bindings", "quest_delete", "terminal_session_ensure", "terminal_input", "stage_view", "latex_init", "latex_compile"}:
3670
+ elif route_name in {"document_open", "document_asset_upload", "chat", "command", "quest_control", "config_save", "quest_create", "quest_baseline_binding", "run_create", "qq_inbound", "connector_inbound", "docs_open", "admin_shutdown", "bash_stop", "quest_settings", "quest_bindings", "quest_delete", "terminal_session_ensure", "terminal_attach", "terminal_input", "stage_view", "latex_init", "latex_compile"}:
3247
3671
  payload = result(**params, body=body)
3248
3672
  elif route_name == "config_validate":
3249
3673
  payload = result(body)
@@ -3288,6 +3712,7 @@ class DaemonApp:
3288
3712
  server.daemon_threads = True
3289
3713
  self._server = server
3290
3714
  self._shutdown_requested.clear()
3715
+ self._start_terminal_attach_server(host, port)
3291
3716
  self._start_background_connectors()
3292
3717
  print(f"DeepScientist daemon listening on http://{host}:{port}")
3293
3718
  try:
@@ -3296,5 +3721,7 @@ class DaemonApp:
3296
3721
  pass
3297
3722
  finally:
3298
3723
  self._stop_background_connectors()
3724
+ self._stop_terminal_attach_server()
3725
+ self.bash_exec_service.shutdown()
3299
3726
  self._server = None
3300
3727
  server.server_close()