@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
@@ -8,6 +8,7 @@ import shutil
8
8
  import signal
9
9
  import subprocess
10
10
  import sys
11
+ import tempfile
11
12
  import threading
12
13
  import time
13
14
  from pathlib import Path
@@ -15,6 +16,7 @@ from typing import Any
15
16
 
16
17
  from ..mcp.context import McpContext
17
18
  from ..shared import append_jsonl, ensure_dir, generate_id, read_json, read_jsonl, utc_now
19
+ from .runtime import TerminalRuntimeManager
18
20
 
19
21
  BASH_STATUS_MARKER_PREFIX = "__DS_BASH_STATUS__"
20
22
  BASH_CARRIAGE_RETURN_PREFIX = "__DS_BASH_CR__"
@@ -29,11 +31,16 @@ INPUT_ESCAPE_SEQUENCE_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b[@-_]")
29
31
 
30
32
  def _atomic_write_json(path: Path, payload: Any) -> None:
31
33
  ensure_dir(path.parent)
32
- temp_path = path.with_suffix(f"{path.suffix}.tmp")
33
- temp_path.write_text(
34
- json.dumps(payload, indent=2, ensure_ascii=False, sort_keys=False) + "\n",
34
+ with tempfile.NamedTemporaryFile(
35
+ "w",
35
36
  encoding="utf-8",
36
- )
37
+ dir=path.parent,
38
+ prefix=f"{path.name}.",
39
+ suffix=".tmp",
40
+ delete=False,
41
+ ) as handle:
42
+ handle.write(json.dumps(payload, indent=2, ensure_ascii=False, sort_keys=False) + "\n")
43
+ temp_path = Path(handle.name)
37
44
  temp_path.replace(path)
38
45
 
39
46
 
@@ -87,6 +94,7 @@ class BashExecService:
87
94
  self.home = home
88
95
  self._summary_cache_lock = threading.Lock()
89
96
  self._summary_cache: dict[str, dict[str, Any]] = {}
97
+ self._terminal_runtime_manager = TerminalRuntimeManager(home)
90
98
 
91
99
  def _quest_root(self, quest_id: str) -> Path:
92
100
  return self.home / "quests" / quest_id
@@ -130,6 +138,9 @@ class BashExecService:
130
138
  def terminal_rc_path(self, quest_root: Path, bash_id: str) -> Path:
131
139
  return self.session_dir(quest_root, bash_id) / "terminal.rc"
132
140
 
141
+ def prompt_events_path(self, quest_root: Path, bash_id: str) -> Path:
142
+ return self.session_dir(quest_root, bash_id) / "prompt-events.log"
143
+
133
144
  def monitor_log_path(self, quest_root: Path, bash_id: str) -> Path:
134
145
  return self.session_dir(quest_root, bash_id) / "monitor.log"
135
146
 
@@ -358,9 +369,27 @@ class BashExecService:
358
369
  status = _coerce_session_status(meta.get("status"))
359
370
  if status in TERMINAL_STATUSES:
360
371
  return self._session_payload(quest_root, meta)
372
+ kind = _normalize_string(meta.get("kind")).lower()
373
+ if kind == "terminal":
374
+ runtime = self._terminal_runtime_manager.get_runtime(quest_root, bash_id)
375
+ if runtime is not None:
376
+ return self._session_payload(quest_root, read_json(meta_path, meta) or meta)
361
377
  monitor_pid = meta.get("monitor_pid")
362
378
  process_pid = meta.get("process_pid")
363
- if _is_process_alive(process_pid) or _is_process_alive(monitor_pid):
379
+ if kind == "terminal" and _is_process_alive(process_pid):
380
+ process_group_id = meta.get("process_group_id")
381
+ if isinstance(process_group_id, int) and process_group_id > 0:
382
+ try:
383
+ os.killpg(process_group_id, signal.SIGTERM)
384
+ except ProcessLookupError:
385
+ pass
386
+ elif isinstance(process_pid, int) and process_pid > 0:
387
+ try:
388
+ os.kill(process_pid, signal.SIGTERM)
389
+ except ProcessLookupError:
390
+ pass
391
+ time.sleep(0.05)
392
+ if kind != "terminal" and (_is_process_alive(process_pid) or _is_process_alive(monitor_pid)):
364
393
  return self._session_payload(quest_root, meta)
365
394
  stop_reason = _normalize_string(meta.get("stop_reason"))
366
395
  meta["status"] = "terminated" if stop_reason else "failed"
@@ -509,12 +538,16 @@ class BashExecService:
509
538
  meta["stopped_by_user_id"] = request_payload["user_id"]
510
539
  meta["updated_at"] = utc_now()
511
540
  self._write_meta(quest_root, bash_id, meta)
512
- process_group_id = meta.get("process_group_id")
513
- if isinstance(process_group_id, int) and process_group_id > 0:
514
- try:
515
- os.killpg(process_group_id, signal.SIGTERM)
516
- except ProcessLookupError:
517
- pass
541
+ runtime = self._terminal_runtime_manager.get_runtime(quest_root, bash_id)
542
+ if runtime is not None:
543
+ runtime.stop(reason=request_payload["reason"], force=False)
544
+ else:
545
+ process_group_id = meta.get("process_group_id")
546
+ if isinstance(process_group_id, int) and process_group_id > 0:
547
+ try:
548
+ os.killpg(process_group_id, signal.SIGTERM)
549
+ except ProcessLookupError:
550
+ pass
518
551
  return self._session_payload(quest_root, meta)
519
552
 
520
553
  def _build_initial_meta(
@@ -724,7 +757,12 @@ class BashExecService:
724
757
  resolved_quest_id = _normalize_string(quest_id) or resolved_quest_root.name
725
758
  try:
726
759
  session = self.reconcile_session(resolved_quest_root, bash_id)
727
- if _normalize_string(session.get("kind")).lower() == "terminal" and _normalize_string(session.get("status")).lower() not in TERMINAL_STATUSES:
760
+ runtime = self._terminal_runtime_manager.get_runtime(resolved_quest_root, bash_id)
761
+ if (
762
+ _normalize_string(session.get("kind")).lower() == "terminal"
763
+ and _normalize_string(session.get("status")).lower() not in TERMINAL_STATUSES
764
+ and runtime is not None
765
+ ):
728
766
  return session
729
767
  except FileNotFoundError:
730
768
  session = None
@@ -747,6 +785,7 @@ class BashExecService:
747
785
  self.log_path(resolved_quest_root, bash_id).touch()
748
786
  self.input_path(resolved_quest_root, bash_id).touch()
749
787
  self.history_path(resolved_quest_root, bash_id).touch()
788
+ self.prompt_events_path(resolved_quest_root, bash_id).touch()
750
789
  _atomic_write_json(
751
790
  self.input_cursor_path(resolved_quest_root, bash_id),
752
791
  {"offset": len(read_jsonl(self.input_path(resolved_quest_root, bash_id))), "updated_at": utc_now()},
@@ -759,9 +798,9 @@ class BashExecService:
759
798
  terminal_rc_path.write_text(
760
799
  "\n".join(
761
800
  [
762
- "PS1=''",
763
- "PS2=''",
764
- 'PROMPT_COMMAND=\'printf "__DS_TERMINAL_PROMPT__ cwd=%q ts=%s\\\\n" "$PWD" "$(date -u +%FT%TZ)"\'',
801
+ "PS1='\\w$ '",
802
+ "PS2='> '",
803
+ 'PROMPT_COMMAND=\'printf "__DS_TERMINAL_PROMPT__ cwd=%q ts=%s\\n" "$PWD" "$(date -u +%FT%TZ)" >> "${DS_TERMINAL_PROMPT_PATH}"\'',
765
804
  "bind 'set enable-bracketed-paste off' >/dev/null 2>&1 || true",
766
805
  "",
767
806
  ]
@@ -775,6 +814,7 @@ class BashExecService:
775
814
  env_payload = {
776
815
  "TERM": "xterm-256color",
777
816
  "COLORTERM": "truecolor",
817
+ "DS_TERMINAL_PROMPT_PATH": str(self.prompt_events_path(resolved_quest_root, bash_id)),
778
818
  }
779
819
  command = f"exec bash --noprofile --rcfile {shlex.quote(str(terminal_rc_path))} -i"
780
820
  resolved_label = _normalize_string(label) or previous_label
@@ -803,10 +843,16 @@ class BashExecService:
803
843
  "started_at": meta["started_at"],
804
844
  },
805
845
  )
806
- meta["monitor_pid"] = self._start_monitor_process(
846
+ meta = self._terminal_runtime_manager.ensure_runtime(
807
847
  quest_root=resolved_quest_root,
808
848
  bash_id=bash_id,
849
+ meta_path=self.meta_path(resolved_quest_root, bash_id),
850
+ log_path=self.log_path(resolved_quest_root, bash_id),
851
+ terminal_log_path=self.terminal_log_path(resolved_quest_root, bash_id),
852
+ prompt_events_path=self.prompt_events_path(resolved_quest_root, bash_id),
809
853
  env_payload=env_payload,
854
+ command=command,
855
+ cwd=target_cwd,
810
856
  )
811
857
  meta["updated_at"] = utc_now()
812
858
  self._write_meta(resolved_quest_root, bash_id, meta)
@@ -870,6 +916,10 @@ class BashExecService:
870
916
  status = _normalize_string(session.get("status")).lower()
871
917
  if status in TERMINAL_STATUSES:
872
918
  raise ValueError("terminal_session_inactive")
919
+ runtime = self._terminal_runtime_manager.get_runtime(quest_root, bash_id)
920
+ if runtime is None:
921
+ raise ValueError("terminal_runtime_inactive")
922
+ runtime.write_input(normalized_data)
873
923
 
874
924
  entry = {
875
925
  "input_id": generate_id("tin"),
@@ -909,6 +959,39 @@ class BashExecService:
909
959
  "completed_commands": completed,
910
960
  }
911
961
 
962
+ def resize_terminal_session(self, quest_root: Path, bash_id: str, *, cols: int, rows: int) -> bool:
963
+ runtime = self._terminal_runtime_manager.get_runtime(quest_root, bash_id)
964
+ if runtime is None:
965
+ return False
966
+ runtime.resize(cols, rows)
967
+ return True
968
+
969
+ def issue_terminal_attach_token(self, quest_root: Path, bash_id: str, *, ttl_seconds: int = 60) -> dict[str, Any]:
970
+ runtime = self._terminal_runtime_manager.get_runtime(quest_root, bash_id)
971
+ if runtime is None:
972
+ raise ValueError("terminal_runtime_inactive")
973
+ token = self._terminal_runtime_manager.issue_attach_token(
974
+ quest_root,
975
+ bash_id,
976
+ ttl_seconds=ttl_seconds,
977
+ )
978
+ return {
979
+ "token": token.token,
980
+ "expires_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(token.expires_at)),
981
+ }
982
+
983
+ def get_terminal_runtime(self, quest_root: Path, bash_id: str):
984
+ return self._terminal_runtime_manager.get_runtime(quest_root, bash_id)
985
+
986
+ def consume_terminal_attach_token(self, token: str):
987
+ return self._terminal_runtime_manager.consume_attach_token(token)
988
+
989
+ def resolve_terminal_attach_token(self, token: str):
990
+ return self._terminal_runtime_manager.resolve_attach_token(token)
991
+
992
+ def shutdown(self) -> None:
993
+ self._terminal_runtime_manager.shutdown()
994
+
912
995
  def terminal_restore_payload(
913
996
  self,
914
997
  quest_root: Path,
@@ -44,6 +44,7 @@ class BaseConnectorBridge:
44
44
  "message": self.render_text(payload.get("text"), payload.get("attachments")),
45
45
  "reply_to_message_id": payload.get("reply_to_message_id"),
46
46
  "attachments": payload.get("attachments") or [],
47
+ "connector_hints": payload.get("connector_hints") or {},
47
48
  "quest_id": payload.get("quest_id"),
48
49
  "quest_root": payload.get("quest_root"),
49
50
  "importance": payload.get("importance"),
@@ -96,6 +97,8 @@ class BaseConnectorBridge:
96
97
  if isinstance(item, str):
97
98
  attachment_lines.append(f"- {item}")
98
99
  elif isinstance(item, dict):
100
+ if str(item.get("path_error") or "").strip():
101
+ continue
99
102
  candidate = item.get("label") or item.get("path") or item.get("url")
100
103
  if candidate:
101
104
  attachment_lines.append(f"- {candidate}")
@@ -1,11 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import base64
3
4
  import json
4
5
  import time
5
6
  from hashlib import sha256
6
7
  from hmac import new as hmac_new
7
8
  from pathlib import Path
8
9
  from typing import Any
10
+ from urllib.error import HTTPError
9
11
  from urllib.request import Request, urlopen
10
12
 
11
13
  from ..shared import append_jsonl, ensure_dir, utc_now
@@ -396,6 +398,8 @@ class DiscordConnectorBridge(BaseConnectorBridge):
396
398
  class QQConnectorBridge(BaseConnectorBridge):
397
399
  name = "qq"
398
400
  _token_cache: dict[str, dict[str, float | str]] = {}
401
+ _IMAGE_FILE_TYPE = 1
402
+ _FILE_FILE_TYPE = 4
399
403
 
400
404
  def parse_webhook(self, *, method: str, headers: dict[str, str], query: dict[str, list[str]], raw_body: bytes, body: dict[str, Any] | None, config: dict[str, Any]) -> BridgeWebhookResult:
401
405
  return BridgeWebhookResult(
@@ -412,18 +416,21 @@ class QQConnectorBridge(BaseConnectorBridge):
412
416
 
413
417
  def format_outbound(self, payload: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]:
414
418
  target = self.extract_target(payload.get("conversation_id"))
415
- message_body: dict[str, Any] = {
416
- "content": self.render_text(payload.get("text"), payload.get("attachments")),
417
- "msg_type": 0,
418
- }
419
419
  reply_to = str(payload.get("reply_to_message_id") or "").strip()
420
- if reply_to:
421
- message_body["msg_id"] = reply_to
422
- message_body["msg_seq"] = max(int(time.time() * 1000) % 65536, 1)
420
+ requested_render_mode = self._requested_render_mode(payload)
421
+ render_mode, render_mode_warning = self._effective_render_mode(requested_render_mode, config)
422
+ native_attachments, residual_attachments, attachment_issues = self._partition_native_attachments(payload.get("attachments"), config)
423
+ text = self.render_text(payload.get("text"), residual_attachments)
423
424
  return {
424
425
  "chat_type": target["chat_type"],
425
426
  "chat_id": target["chat_id"],
426
- "body": message_body,
427
+ "reply_to_message_id": reply_to or None,
428
+ "requested_render_mode": requested_render_mode,
429
+ "render_mode": render_mode,
430
+ "render_mode_warning": render_mode_warning,
431
+ "text": text,
432
+ "native_attachments": native_attachments,
433
+ "attachment_issues": attachment_issues,
427
434
  }
428
435
 
429
436
  def deliver_direct(self, payload: dict[str, Any], config: dict[str, Any]) -> dict[str, Any] | None:
@@ -437,17 +444,289 @@ class QQConnectorBridge(BaseConnectorBridge):
437
444
  if chat_type not in {"direct", "group"} or not chat_id:
438
445
  return None
439
446
  access_token = self._access_token(app_id, app_secret)
440
- endpoint = (
447
+ reply_to = str(formatted.get("reply_to_message_id") or "").strip() or None
448
+ text = str(formatted.get("text") or "").strip()
449
+ render_mode = str(formatted.get("render_mode") or "plain").strip().lower()
450
+ native_attachments = [dict(item) for item in (formatted.get("native_attachments") or []) if isinstance(item, dict)]
451
+ attachment_issues = [dict(item) for item in (formatted.get("attachment_issues") or []) if isinstance(item, dict)]
452
+ parts: list[dict[str, Any]] = []
453
+ warnings: list[str] = []
454
+ if str(formatted.get("render_mode_warning") or "").strip():
455
+ warnings.append(str(formatted.get("render_mode_warning")).strip())
456
+ for issue in attachment_issues:
457
+ error = str(issue.get("error") or "").strip()
458
+ if error:
459
+ warnings.append(error)
460
+ if text or not native_attachments:
461
+ text_result = self._send_text_message(
462
+ access_token=access_token,
463
+ chat_type=chat_type,
464
+ chat_id=chat_id,
465
+ text=text,
466
+ reply_to_message_id=reply_to,
467
+ render_mode=render_mode,
468
+ )
469
+ parts.append({"part": "text", "render_mode": render_mode, **text_result})
470
+ for index, attachment in enumerate(native_attachments, start=1):
471
+ media_kind = str(attachment.get("qq_media_kind") or "file").strip()
472
+ media_result = self._send_media_attachment(
473
+ access_token=access_token,
474
+ chat_type=chat_type,
475
+ chat_id=chat_id,
476
+ attachment=attachment,
477
+ reply_to_message_id=reply_to,
478
+ )
479
+ parts.append(
480
+ {
481
+ "part": f"attachment_{index}",
482
+ "media_kind": media_kind,
483
+ "path": attachment.get("path"),
484
+ "url": attachment.get("url"),
485
+ **media_result,
486
+ }
487
+ )
488
+ succeeded = [item for item in parts if bool(item.get("ok", False))]
489
+ failed = [item for item in parts if not bool(item.get("ok", False))]
490
+ error_messages = [str(item.get("error") or "").strip() for item in failed if str(item.get("error") or "").strip()]
491
+ error_messages.extend(warnings)
492
+ last_success = succeeded[-1] if succeeded else {}
493
+ return {
494
+ "ok": bool(succeeded),
495
+ "queued": False,
496
+ "partial": bool(succeeded and (failed or warnings)),
497
+ "transport": "qq-http",
498
+ "status_code": last_success.get("status_code") or (failed[-1].get("status_code") if failed else None),
499
+ "message_id": last_success.get("message_id"),
500
+ "response": str(last_success.get("response") or "").strip()[:500] or None,
501
+ "parts": parts,
502
+ "warnings": warnings,
503
+ "error": "; ".join(error_messages) if error_messages else None,
504
+ }
505
+
506
+ @staticmethod
507
+ def _requested_render_mode(payload: dict[str, Any]) -> str:
508
+ connector_hints = payload.get("connector_hints") if isinstance(payload.get("connector_hints"), dict) else {}
509
+ qq_hints = connector_hints.get("qq") if isinstance(connector_hints.get("qq"), dict) else {}
510
+ requested = str(qq_hints.get("render_mode") or "auto").strip().lower()
511
+ return requested if requested in {"auto", "plain", "markdown"} else "auto"
512
+
513
+ @staticmethod
514
+ def _effective_render_mode(requested: str, config: dict[str, Any]) -> tuple[str, str | None]:
515
+ markdown_enabled = bool(config.get("enable_markdown_send", False))
516
+ if requested == "markdown" and not markdown_enabled:
517
+ return "plain", "QQ markdown send was requested, but `enable_markdown_send` is disabled."
518
+ if requested == "markdown":
519
+ return "markdown", None
520
+ return "plain", None
521
+
522
+ @classmethod
523
+ def _partition_native_attachments(
524
+ cls,
525
+ attachments: Any,
526
+ config: dict[str, Any],
527
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
528
+ native_enabled = bool(config.get("enable_file_upload_experimental", False))
529
+ native_items: list[dict[str, Any]] = []
530
+ residual_items: list[dict[str, Any]] = []
531
+ issues: list[dict[str, Any]] = []
532
+ for index, raw_item in enumerate(attachments if isinstance(attachments, list) else [], start=1):
533
+ if not isinstance(raw_item, dict):
534
+ continue
535
+ item = dict(raw_item)
536
+ if str(item.get("path_error") or "").strip():
537
+ issues.append(
538
+ {
539
+ "attachment_index": index,
540
+ "error": f"attachment {index}: path resolution failed for {item.get('path')}",
541
+ }
542
+ )
543
+ continue
544
+ connector_delivery = item.get("connector_delivery") if isinstance(item.get("connector_delivery"), dict) else {}
545
+ qq_delivery = connector_delivery.get("qq") if isinstance(connector_delivery.get("qq"), dict) else {}
546
+ media_kind = str(qq_delivery.get("media_kind") or "").strip().lower()
547
+ if media_kind not in {"image", "file"}:
548
+ residual_items.append(item)
549
+ continue
550
+ if not native_enabled:
551
+ issues.append(
552
+ {
553
+ "attachment_index": index,
554
+ "error": (
555
+ f"attachment {index}: QQ native media send is disabled by "
556
+ "`enable_file_upload_experimental`."
557
+ ),
558
+ }
559
+ )
560
+ continue
561
+ source_path = str(item.get("path") or "").strip()
562
+ source_url = str(item.get("url") or "").strip()
563
+ if not source_path and not source_url:
564
+ issues.append(
565
+ {
566
+ "attachment_index": index,
567
+ "error": f"attachment {index}: QQ {media_kind} send requires `path` or `url`.",
568
+ }
569
+ )
570
+ continue
571
+ item["qq_media_kind"] = media_kind
572
+ native_items.append(item)
573
+ return native_items, residual_items, issues
574
+
575
+ @staticmethod
576
+ def _message_endpoint(chat_type: str, chat_id: str) -> str:
577
+ return (
441
578
  f"https://api.sgroup.qq.com/v2/users/{chat_id}/messages"
442
579
  if chat_type == "direct"
443
580
  else f"https://api.sgroup.qq.com/v2/groups/{chat_id}/messages"
444
581
  )
445
- request = Request(endpoint, data=json.dumps(formatted["body"], ensure_ascii=False).encode("utf-8"), method="POST")
582
+
583
+ @staticmethod
584
+ def _files_endpoint(chat_type: str, chat_id: str) -> str:
585
+ return (
586
+ f"https://api.sgroup.qq.com/v2/users/{chat_id}/files"
587
+ if chat_type == "direct"
588
+ else f"https://api.sgroup.qq.com/v2/groups/{chat_id}/files"
589
+ )
590
+
591
+ @staticmethod
592
+ def _message_seq(reply_to_message_id: str | None) -> int | None:
593
+ if not reply_to_message_id:
594
+ return None
595
+ return max(int(time.time() * 1000) % 65536, 1)
596
+
597
+ def _send_text_message(
598
+ self,
599
+ *,
600
+ access_token: str,
601
+ chat_type: str,
602
+ chat_id: str,
603
+ text: str,
604
+ reply_to_message_id: str | None,
605
+ render_mode: str,
606
+ ) -> dict[str, Any]:
607
+ if not text.strip():
608
+ return {"ok": False, "error": "QQ text message content is empty."}
609
+ body: dict[str, Any]
610
+ if render_mode == "markdown":
611
+ body = {"markdown": {"content": text}, "msg_type": 2}
612
+ else:
613
+ body = {"content": text, "msg_type": 0}
614
+ msg_seq = self._message_seq(reply_to_message_id)
615
+ if msg_seq is not None:
616
+ body["msg_seq"] = msg_seq
617
+ if reply_to_message_id:
618
+ body["msg_id"] = reply_to_message_id
619
+ return self._post_json(
620
+ endpoint=self._message_endpoint(chat_type, chat_id),
621
+ access_token=access_token,
622
+ body=body,
623
+ )
624
+
625
+ def _send_media_attachment(
626
+ self,
627
+ *,
628
+ access_token: str,
629
+ chat_type: str,
630
+ chat_id: str,
631
+ attachment: dict[str, Any],
632
+ reply_to_message_id: str | None,
633
+ ) -> dict[str, Any]:
634
+ media_kind = str(attachment.get("qq_media_kind") or "file").strip().lower()
635
+ upload_payload: dict[str, Any] = {
636
+ "file_type": self._IMAGE_FILE_TYPE if media_kind == "image" else self._FILE_FILE_TYPE,
637
+ "srv_send_msg": False,
638
+ }
639
+ url_value = str(attachment.get("url") or "").strip()
640
+ path_value = str(attachment.get("path") or "").strip()
641
+ try:
642
+ if url_value:
643
+ upload_payload["url"] = url_value
644
+ elif path_value:
645
+ payload, file_name = self._file_data_payload(Path(path_value), media_kind=media_kind)
646
+ upload_payload["file_data"] = payload
647
+ if media_kind == "file" and file_name:
648
+ upload_payload["file_name"] = file_name
649
+ else:
650
+ return {"ok": False, "error": "QQ native media send requires `path` or `url`."}
651
+ except Exception as exc:
652
+ return {"ok": False, "error": str(exc)}
653
+ upload_result = self._post_json(
654
+ endpoint=self._files_endpoint(chat_type, chat_id),
655
+ access_token=access_token,
656
+ body=upload_payload,
657
+ )
658
+ if not upload_result.get("ok", False):
659
+ upload_result["error"] = str(upload_result.get("error") or "QQ media upload failed.").strip()
660
+ return upload_result
661
+ file_info = str(upload_result.get("payload", {}).get("file_info") or "").strip()
662
+ if not file_info:
663
+ return {
664
+ "ok": False,
665
+ "error": "QQ media upload succeeded but returned no `file_info`.",
666
+ "status_code": upload_result.get("status_code"),
667
+ "response": upload_result.get("response"),
668
+ }
669
+ message_body: dict[str, Any] = {
670
+ "msg_type": 7,
671
+ "media": {"file_info": file_info},
672
+ }
673
+ msg_seq = self._message_seq(reply_to_message_id)
674
+ if msg_seq is not None:
675
+ message_body["msg_seq"] = msg_seq
676
+ if reply_to_message_id:
677
+ message_body["msg_id"] = reply_to_message_id
678
+ send_result = self._post_json(
679
+ endpoint=self._message_endpoint(chat_type, chat_id),
680
+ access_token=access_token,
681
+ body=message_body,
682
+ )
683
+ if not send_result.get("ok", False):
684
+ send_result["error"] = str(send_result.get("error") or "QQ media message send failed.").strip()
685
+ return send_result
686
+
687
+ @staticmethod
688
+ def _file_data_payload(path: Path, *, media_kind: str) -> tuple[str, str]:
689
+ if not path.exists():
690
+ raise FileNotFoundError(f"QQ native {media_kind} attachment path does not exist: {path}")
691
+ payload = base64.b64encode(path.read_bytes()).decode("utf-8")
692
+ return payload, path.name
693
+
694
+ def _post_json(self, *, endpoint: str, access_token: str, body: dict[str, Any]) -> dict[str, Any]:
695
+ request = Request(endpoint, data=json.dumps(body, ensure_ascii=False).encode("utf-8"), method="POST")
446
696
  request.add_header("Content-Type", "application/json; charset=utf-8")
447
697
  request.add_header("Authorization", f"QQBot {access_token}")
448
- with urlopen(request, timeout=8) as response: # noqa: S310
449
- response_text = response.read().decode("utf-8", errors="replace")
450
- return {"ok": 200 <= response.status < 300, "status_code": response.status, "response": response_text[:500], "transport": "qq-http"}
698
+ try:
699
+ with urlopen(request, timeout=8) as response: # noqa: S310
700
+ response_text = response.read().decode("utf-8", errors="replace")
701
+ payload = json.loads(response_text) if response_text else {}
702
+ return {
703
+ "ok": 200 <= response.status < 300,
704
+ "status_code": response.status,
705
+ "response": response_text[:500],
706
+ "payload": payload if isinstance(payload, dict) else {},
707
+ "message_id": str((payload or {}).get("id") or "").strip() or None,
708
+ }
709
+ except HTTPError as exc:
710
+ response_text = exc.read().decode("utf-8", errors="replace")
711
+ try:
712
+ payload = json.loads(response_text) if response_text else {}
713
+ except json.JSONDecodeError:
714
+ payload = {}
715
+ return {
716
+ "ok": False,
717
+ "status_code": exc.code,
718
+ "response": response_text[:500],
719
+ "payload": payload if isinstance(payload, dict) else {},
720
+ "error": str((payload or {}).get("message") or response_text or exc.reason).strip() or "QQ HTTP error",
721
+ }
722
+ except Exception as exc: # pragma: no cover - defensive network transport guard
723
+ return {
724
+ "ok": False,
725
+ "status_code": None,
726
+ "response": None,
727
+ "payload": {},
728
+ "error": str(exc),
729
+ }
451
730
 
452
731
  @classmethod
453
732
  def _access_token(cls, app_id: str, app_secret: str) -> str:
@@ -352,12 +352,15 @@ class QQRelayChannel(BaseChannel):
352
352
  fragments.append(f"Reason: {reason}")
353
353
  text = "\n".join(fragments)
354
354
  attachments = self._normalize_attachments(payload.get("attachments"))
355
+ conversation_id = str(payload.get("conversation_id") or "").strip()
355
356
  return {
356
- "conversation_id": payload.get("conversation_id"),
357
- "reply_to_message_id": payload.get("reply_to_message_id"),
357
+ "conversation_id": conversation_id,
358
+ "reply_to_message_id": payload.get("reply_to_message_id") or self._reply_to_message_id_for(conversation_id),
358
359
  "kind": kind,
359
360
  "text": text,
360
361
  "attachments": attachments,
362
+ "surface_actions": [dict(item) for item in (payload.get("surface_actions") or []) if isinstance(item, dict)],
363
+ "connector_hints": dict(payload.get("connector_hints")) if isinstance(payload.get("connector_hints"), dict) else {},
361
364
  "quest_id": payload.get("quest_id"),
362
365
  "quest_root": payload.get("quest_root"),
363
366
  "importance": payload.get("importance"),
@@ -548,6 +551,20 @@ class QQRelayChannel(BaseChannel):
548
551
  events.sort(key=lambda item: (str(item.get("created_at") or ""), str(item.get("conversation_id") or "")), reverse=True)
549
552
  return events[: self.recent_event_limit]
550
553
 
554
+ def _reply_to_message_id_for(self, conversation_id: str) -> str | None:
555
+ normalized = str(conversation_id or "").strip()
556
+ if not normalized:
557
+ return None
558
+ identity = conversation_identity_key(normalized)
559
+ state = self._read_state()
560
+ for item in self._recent_conversations(state):
561
+ if conversation_identity_key(str(item.get("conversation_id") or "").strip()) != identity:
562
+ continue
563
+ message_id = str(item.get("message_id") or "").strip()
564
+ if message_id:
565
+ return message_id
566
+ return None
567
+
551
568
  def _build_recent_event(self, event_type: str, record: dict[str, Any]) -> dict[str, Any] | None:
552
569
  if not isinstance(record, dict):
553
570
  return None
@@ -337,6 +337,7 @@ class GenericRelayChannel(BaseChannel):
337
337
  "kind": kind,
338
338
  "text": text,
339
339
  "attachments": attachments,
340
+ "surface_actions": [dict(item) for item in (payload.get("surface_actions") or []) if isinstance(item, dict)],
340
341
  "quest_id": payload.get("quest_id"),
341
342
  "quest_root": payload.get("quest_root"),
342
343
  "importance": payload.get("importance"),