@researai/deepscientist 1.5.1 → 1.5.3

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 (116) hide show
  1. package/README.md +69 -1
  2. package/bin/ds.js +2239 -153
  3. package/docs/en/00_QUICK_START.md +60 -20
  4. package/docs/en/01_SETTINGS_REFERENCE.md +20 -20
  5. package/docs/en/02_START_RESEARCH_GUIDE.md +11 -11
  6. package/docs/en/03_QQ_CONNECTOR_GUIDE.md +10 -10
  7. package/docs/en/05_TUI_GUIDE.md +1 -1
  8. package/docs/en/09_DOCTOR.md +48 -4
  9. package/docs/en/90_ARCHITECTURE.md +4 -2
  10. package/docs/zh/00_QUICK_START.md +60 -20
  11. package/docs/zh/01_SETTINGS_REFERENCE.md +21 -21
  12. package/docs/zh/02_START_RESEARCH_GUIDE.md +19 -19
  13. package/docs/zh/03_QQ_CONNECTOR_GUIDE.md +10 -10
  14. package/docs/zh/05_TUI_GUIDE.md +1 -1
  15. package/docs/zh/09_DOCTOR.md +46 -4
  16. package/install.sh +125 -8
  17. package/package.json +2 -1
  18. package/pyproject.toml +1 -1
  19. package/src/deepscientist/__init__.py +6 -1
  20. package/src/deepscientist/artifact/service.py +553 -26
  21. package/src/deepscientist/bash_exec/monitor.py +23 -4
  22. package/src/deepscientist/bash_exec/runtime.py +3 -0
  23. package/src/deepscientist/bash_exec/service.py +132 -4
  24. package/src/deepscientist/bridges/base.py +10 -19
  25. package/src/deepscientist/channels/discord_gateway.py +25 -2
  26. package/src/deepscientist/channels/feishu_long_connection.py +41 -3
  27. package/src/deepscientist/channels/qq.py +524 -64
  28. package/src/deepscientist/channels/qq_gateway.py +22 -3
  29. package/src/deepscientist/channels/relay.py +429 -90
  30. package/src/deepscientist/channels/slack_socket.py +29 -5
  31. package/src/deepscientist/channels/telegram_polling.py +25 -2
  32. package/src/deepscientist/channels/whatsapp_local_session.py +32 -4
  33. package/src/deepscientist/cli.py +27 -0
  34. package/src/deepscientist/config/models.py +6 -40
  35. package/src/deepscientist/config/service.py +165 -156
  36. package/src/deepscientist/connector_profiles.py +346 -0
  37. package/src/deepscientist/connector_runtime.py +88 -43
  38. package/src/deepscientist/daemon/api/handlers.py +65 -11
  39. package/src/deepscientist/daemon/api/router.py +4 -2
  40. package/src/deepscientist/daemon/app.py +772 -219
  41. package/src/deepscientist/doctor.py +69 -2
  42. package/src/deepscientist/gitops/diff.py +3 -0
  43. package/src/deepscientist/home.py +25 -2
  44. package/src/deepscientist/mcp/context.py +3 -1
  45. package/src/deepscientist/mcp/server.py +66 -7
  46. package/src/deepscientist/migration.py +114 -0
  47. package/src/deepscientist/prompts/builder.py +71 -3
  48. package/src/deepscientist/qq_profiles.py +186 -0
  49. package/src/deepscientist/quest/layout.py +1 -0
  50. package/src/deepscientist/quest/service.py +70 -12
  51. package/src/deepscientist/quest/stage_views.py +46 -0
  52. package/src/deepscientist/runners/codex.py +2 -0
  53. package/src/deepscientist/shared.py +44 -17
  54. package/src/prompts/connectors/lingzhu.md +3 -0
  55. package/src/prompts/connectors/qq.md +42 -2
  56. package/src/prompts/system.md +123 -10
  57. package/src/skills/analysis-campaign/SKILL.md +35 -6
  58. package/src/skills/baseline/SKILL.md +73 -32
  59. package/src/skills/decision/SKILL.md +4 -3
  60. package/src/skills/experiment/SKILL.md +28 -6
  61. package/src/skills/finalize/SKILL.md +5 -2
  62. package/src/skills/idea/SKILL.md +2 -2
  63. package/src/skills/intake-audit/SKILL.md +2 -2
  64. package/src/skills/rebuttal/SKILL.md +4 -2
  65. package/src/skills/review/SKILL.md +4 -2
  66. package/src/skills/scout/SKILL.md +2 -2
  67. package/src/skills/write/SKILL.md +2 -2
  68. package/src/tui/package.json +1 -1
  69. package/src/ui/dist/assets/{AiManusChatView-w5lF2Ttt.js → AiManusChatView-qzChi9uh.js} +67 -94
  70. package/src/ui/dist/assets/{AnalysisPlugin-DJOED79I.js → AnalysisPlugin-CcC_-UqN.js} +1 -1
  71. package/src/ui/dist/assets/{AutoFigurePlugin-DaG61Y0M.js → AutoFigurePlugin-DD8LkJLe.js} +5 -5
  72. package/src/ui/dist/assets/{CliPlugin-CV4LqUB_.js → CliPlugin-DJJFfVmW.js} +17 -110
  73. package/src/ui/dist/assets/{CodeEditorPlugin-DylfAea4.js → CodeEditorPlugin-CrjkHNLh.js} +8 -8
  74. package/src/ui/dist/assets/{CodeViewerPlugin-F7saY0LM.js → CodeViewerPlugin-obnD6G5R.js} +5 -5
  75. package/src/ui/dist/assets/{DocViewerPlugin-COP0c7jf.js → DocViewerPlugin-DB9SUQVd.js} +3 -3
  76. package/src/ui/dist/assets/{GitDiffViewerPlugin-CAS05pT9.js → GitDiffViewerPlugin-DZLlNlD2.js} +1 -1
  77. package/src/ui/dist/assets/{ImageViewerPlugin-Bco1CN_w.js → ImageViewerPlugin-BGwfDZ0Y.js} +5 -5
  78. package/src/ui/dist/assets/{LabCopilotPanel-CvMlCD99.js → LabCopilotPanel-dfLptQcR.js} +10 -10
  79. package/src/ui/dist/assets/{LabPlugin-BYankkE4.js → LabPlugin-CeGjAl3A.js} +1 -1
  80. package/src/ui/dist/assets/{LatexPlugin-LDSMR-t-.js → LatexPlugin-BBJ7kd1V.js} +7 -7
  81. package/src/ui/dist/assets/{MarkdownViewerPlugin-B7o80jgm.js → MarkdownViewerPlugin-DKZi7BcB.js} +4 -4
  82. package/src/ui/dist/assets/{MarketplacePlugin-CM6ZOcpC.js → MarketplacePlugin-C_k-9jD0.js} +3 -3
  83. package/src/ui/dist/assets/{NotebookEditor-Dc61cXmK.js → NotebookEditor-4R88_BMO.js} +1 -1
  84. package/src/ui/dist/assets/{PdfLoader-DWowuQwx.js → PdfLoader-DwEFQLrw.js} +1 -1
  85. package/src/ui/dist/assets/{PdfMarkdownPlugin-BsJM1q_a.js → PdfMarkdownPlugin-D-jdsqF8.js} +3 -3
  86. package/src/ui/dist/assets/{PdfViewerPlugin-DB2eEEFQ.js → PdfViewerPlugin-CmeBGDY0.js} +10 -10
  87. package/src/ui/dist/assets/{SearchPlugin-CraThSvt.js → SearchPlugin-Dlz2WKJ4.js} +1 -1
  88. package/src/ui/dist/assets/{Stepper-CgocRTPq.js → Stepper-ClOgzWM3.js} +1 -1
  89. package/src/ui/dist/assets/{TextViewerPlugin-B1JGhKtd.js → TextViewerPlugin-DDQWxibk.js} +4 -4
  90. package/src/ui/dist/assets/{VNCViewer-CclFC7FM.js → VNCViewer-CJXT0Nm8.js} +9 -9
  91. package/src/ui/dist/assets/{bibtex-D3IKsMl7.js → bibtex-DLr4Rtk4.js} +1 -1
  92. package/src/ui/dist/assets/{code-BP37Xx0p.js → code-DgKK408Y.js} +1 -1
  93. package/src/ui/dist/assets/{file-content-BAJSu-9r.js → file-content-6HBqQnvQ.js} +1 -1
  94. package/src/ui/dist/assets/{file-diff-panel-DUGeCTuy.js → file-diff-panel-Dhu0TbBM.js} +1 -1
  95. package/src/ui/dist/assets/{file-socket-CXc1Ojf7.js → file-socket-CP3iwVZG.js} +1 -1
  96. package/src/ui/dist/assets/{file-utils-2J21jt7M.js → file-utils-BsS-Aw68.js} +1 -1
  97. package/src/ui/dist/assets/{image-CMMmgvcn.js → image-ByeK-Zcv.js} +1 -1
  98. package/src/ui/dist/assets/{index-DmwmJmbW.js → index-BLjo5--a.js} +33610 -31016
  99. package/src/ui/dist/assets/{index-CWgMgpow.js → index-BdsE0uRz.js} +11 -11
  100. package/src/ui/dist/assets/{index-s7aHnNQ4.js → index-C-eX-N6A.js} +1 -1
  101. package/src/ui/dist/assets/{index-KGt-z-dD.css → index-CuQhlrR-.css} +2747 -2
  102. package/src/ui/dist/assets/{index-BaVumsQT.js → index-DyremSIv.js} +2 -2
  103. package/src/ui/dist/assets/{message-square-CQRfX0Am.js → message-square-DnagiLnc.js} +1 -1
  104. package/src/ui/dist/assets/{monaco-B4TbdsrF.js → monaco-4kBFeprs.js} +1 -1
  105. package/src/ui/dist/assets/{popover-B8Rokodk.js → popover-hRCXZzs2.js} +1 -1
  106. package/src/ui/dist/assets/{project-sync-D_i96KH4.js → project-sync-O_85YuP6.js} +1 -1
  107. package/src/ui/dist/assets/{sigma-D12PnzCN.js → sigma-DvKopSnL.js} +1 -1
  108. package/src/ui/dist/assets/{tooltip-B6YrI4aJ.js → tooltip-BmlPc6kc.js} +1 -1
  109. package/src/ui/dist/assets/{trash-Bc8jGp0V.js → trash-n-UvdZFR.js} +1 -1
  110. package/src/ui/dist/assets/{useCliAccess-mXVCYSZ-.js → useCliAccess-WDd3_wIh.js} +1 -1
  111. package/src/ui/dist/assets/{useFileDiffOverlay-Bg6b9H9K.js → useFileDiffOverlay-rXLIL2NF.js} +1 -1
  112. package/src/ui/dist/assets/{wrap-text-Drh5GEnL.js → wrap-text-qIYQ4a_W.js} +1 -1
  113. package/src/ui/dist/assets/{zoom-out-CJj9DZLn.js → zoom-out-fZXCEFsy.js} +1 -1
  114. package/src/ui/dist/index.html +2 -2
  115. package/uv.lock +1155 -0
  116. package/src/ui/dist/assets/LabPlugin-D9jVIo0A.css +0 -2698
@@ -0,0 +1,186 @@
1
+ from __future__ import annotations
2
+
3
+ from copy import deepcopy
4
+ from typing import Any
5
+
6
+ from .shared import slugify
7
+
8
+
9
+ QQ_PROFILE_ID_PREFIX = "qq-profile"
10
+ QQ_DEFAULT_SECRET_ENV = "QQ_APP_SECRET"
11
+
12
+
13
+ def default_qq_profile() -> dict[str, Any]:
14
+ return {
15
+ "profile_id": None,
16
+ "enabled": True,
17
+ "app_id": None,
18
+ "app_secret": None,
19
+ "app_secret_env": QQ_DEFAULT_SECRET_ENV,
20
+ "bot_name": "DeepScientist",
21
+ "main_chat_id": None,
22
+ }
23
+
24
+
25
+ def _as_text(value: Any) -> str | None:
26
+ text = str(value or "").strip()
27
+ return text or None
28
+
29
+
30
+ def _profile_id_seed(*, profile_id: Any, app_id: Any, bot_name: Any, index: int) -> str:
31
+ explicit = _as_text(profile_id)
32
+ if explicit:
33
+ return explicit
34
+ app_text = _as_text(app_id)
35
+ if app_text:
36
+ return f"qq-{app_text}"
37
+ bot_text = slugify(str(bot_name or "").strip(), default="")
38
+ if bot_text:
39
+ return f"{QQ_PROFILE_ID_PREFIX}-{bot_text}"
40
+ return f"{QQ_PROFILE_ID_PREFIX}-{index:03d}"
41
+
42
+
43
+ def _unique_profile_id(seed: str, *, used: set[str]) -> str:
44
+ base = slugify(seed, default=QQ_PROFILE_ID_PREFIX)
45
+ candidate = base
46
+ suffix = 2
47
+ while candidate in used:
48
+ candidate = f"{base}-{suffix}"
49
+ suffix += 1
50
+ used.add(candidate)
51
+ return candidate
52
+
53
+
54
+ def list_qq_profiles(config: dict[str, Any] | None) -> list[dict[str, Any]]:
55
+ normalized = normalize_qq_connector_config(config)
56
+ profiles = normalized.get("profiles")
57
+ return [dict(item) for item in profiles] if isinstance(profiles, list) else []
58
+
59
+
60
+ def find_qq_profile(
61
+ config: dict[str, Any] | None,
62
+ *,
63
+ profile_id: str | None = None,
64
+ app_id: str | None = None,
65
+ ) -> dict[str, Any] | None:
66
+ normalized_profile_id = _as_text(profile_id)
67
+ normalized_app_id = _as_text(app_id)
68
+ for profile in list_qq_profiles(config):
69
+ if normalized_profile_id and str(profile.get("profile_id") or "").strip() == normalized_profile_id:
70
+ return profile
71
+ if normalized_app_id and str(profile.get("app_id") or "").strip() == normalized_app_id:
72
+ return profile
73
+ return None
74
+
75
+
76
+ def merge_qq_profile_config(shared_config: dict[str, Any] | None, profile: dict[str, Any]) -> dict[str, Any]:
77
+ normalized = normalize_qq_connector_config(shared_config)
78
+ merged = deepcopy(normalized)
79
+ merged.pop("profiles", None)
80
+ merged.update(
81
+ {
82
+ "profile_id": str(profile.get("profile_id") or "").strip() or None,
83
+ "app_id": _as_text(profile.get("app_id")),
84
+ "app_secret": _as_text(profile.get("app_secret")),
85
+ "app_secret_env": _as_text(profile.get("app_secret_env")) or QQ_DEFAULT_SECRET_ENV,
86
+ "bot_name": _as_text(profile.get("bot_name")) or str(normalized.get("bot_name") or "DeepScientist"),
87
+ "main_chat_id": _as_text(profile.get("main_chat_id")),
88
+ "enabled": bool(normalized.get("enabled", False)) and bool(profile.get("enabled", True)),
89
+ "transport": "gateway_direct",
90
+ }
91
+ )
92
+ return merged
93
+
94
+
95
+ def qq_profile_label(profile: dict[str, Any] | None) -> str:
96
+ if not isinstance(profile, dict):
97
+ return "QQ"
98
+ bot_name = _as_text(profile.get("bot_name"))
99
+ app_id = _as_text(profile.get("app_id"))
100
+ if bot_name and app_id:
101
+ return f"{bot_name} · {app_id}"
102
+ if bot_name:
103
+ return bot_name
104
+ if app_id:
105
+ return f"QQ · {app_id}"
106
+ return "QQ"
107
+
108
+
109
+ def normalize_qq_connector_config(config: dict[str, Any] | None) -> dict[str, Any]:
110
+ payload = deepcopy(config or {})
111
+ shared_defaults = {
112
+ "enabled": False,
113
+ "transport": "gateway_direct",
114
+ "app_id": None,
115
+ "app_secret": None,
116
+ "app_secret_env": QQ_DEFAULT_SECRET_ENV,
117
+ "bot_name": "DeepScientist",
118
+ "command_prefix": "/",
119
+ "main_chat_id": None,
120
+ "require_at_in_groups": True,
121
+ "auto_bind_dm_to_active_quest": True,
122
+ "gateway_restart_on_config_change": True,
123
+ "auto_send_main_experiment_png": True,
124
+ "auto_send_analysis_summary_png": True,
125
+ "auto_send_slice_png": True,
126
+ "auto_send_paper_pdf": True,
127
+ "enable_markdown_send": False,
128
+ "enable_file_upload_experimental": False,
129
+ "profiles": [],
130
+ }
131
+ shared = {**shared_defaults, **payload}
132
+ shared["transport"] = "gateway_direct"
133
+ shared["command_prefix"] = _as_text(shared.get("command_prefix")) or "/"
134
+ shared["bot_name"] = _as_text(shared.get("bot_name")) or "DeepScientist"
135
+ shared["app_secret_env"] = _as_text(shared.get("app_secret_env")) or QQ_DEFAULT_SECRET_ENV
136
+
137
+ raw_profiles = payload.get("profiles")
138
+ items = list(raw_profiles) if isinstance(raw_profiles, list) else []
139
+ legacy_profile_seed = {
140
+ "app_id": payload.get("app_id"),
141
+ "app_secret": payload.get("app_secret"),
142
+ "app_secret_env": payload.get("app_secret_env"),
143
+ "bot_name": payload.get("bot_name"),
144
+ "main_chat_id": payload.get("main_chat_id"),
145
+ }
146
+ if not items:
147
+ if any(_as_text(legacy_profile_seed.get(key)) for key in ("app_id", "app_secret", "main_chat_id", "bot_name")):
148
+ items = [legacy_profile_seed]
149
+
150
+ profiles: list[dict[str, Any]] = []
151
+ used_ids: set[str] = set()
152
+ for index, raw in enumerate(items, start=1):
153
+ if not isinstance(raw, dict):
154
+ continue
155
+ current = {**default_qq_profile(), **raw}
156
+ current["enabled"] = bool(current.get("enabled", True))
157
+ current["app_id"] = _as_text(current.get("app_id"))
158
+ current["app_secret"] = _as_text(current.get("app_secret"))
159
+ current["app_secret_env"] = _as_text(current.get("app_secret_env")) or shared["app_secret_env"]
160
+ current["bot_name"] = _as_text(current.get("bot_name")) or shared["bot_name"]
161
+ current["main_chat_id"] = _as_text(current.get("main_chat_id"))
162
+ current["profile_id"] = _unique_profile_id(
163
+ _profile_id_seed(
164
+ profile_id=current.get("profile_id"),
165
+ app_id=current.get("app_id"),
166
+ bot_name=current.get("bot_name"),
167
+ index=index,
168
+ ),
169
+ used=used_ids,
170
+ )
171
+ profiles.append(current)
172
+
173
+ shared["profiles"] = profiles
174
+ if len(profiles) == 1:
175
+ mirror = profiles[0]
176
+ shared["app_id"] = mirror.get("app_id")
177
+ shared["app_secret"] = mirror.get("app_secret")
178
+ shared["app_secret_env"] = mirror.get("app_secret_env")
179
+ shared["bot_name"] = mirror.get("bot_name")
180
+ shared["main_chat_id"] = mirror.get("main_chat_id")
181
+ else:
182
+ shared["app_id"] = None
183
+ shared["app_secret"] = None
184
+ shared["main_chat_id"] = None
185
+
186
+ return shared
@@ -29,6 +29,7 @@ QUEST_DIRECTORIES = (
29
29
  "memory/knowledge",
30
30
  "memory/papers",
31
31
  "paper",
32
+ "release/open_source",
32
33
  ".codex/skills",
33
34
  ".claude/agents",
34
35
  ".ds/bash_exec",
@@ -1548,11 +1548,16 @@ class QuestService:
1548
1548
  if normalized in seen_files:
1549
1549
  return
1550
1550
  seen_files.add(normalized)
1551
+ resolved_document_id = document_id or self._path_to_document_id(
1552
+ normalized,
1553
+ quest_root=quest_root,
1554
+ workspace_root=workspace_root,
1555
+ )
1551
1556
  changed_files.append(
1552
1557
  {
1553
1558
  "path": normalized,
1554
1559
  "source": source,
1555
- "document_id": document_id,
1560
+ "document_id": resolved_document_id,
1556
1561
  "writable": writable,
1557
1562
  }
1558
1563
  )
@@ -1621,14 +1626,30 @@ class QuestService:
1621
1626
  "changed_files": changed_files[-30:],
1622
1627
  }
1623
1628
 
1624
- def events(self, quest_id: str, *, after: int = 0, limit: int = 200, tail: bool = False) -> dict:
1629
+ def events(
1630
+ self,
1631
+ quest_id: str,
1632
+ *,
1633
+ after: int = 0,
1634
+ before: int | None = None,
1635
+ limit: int = 200,
1636
+ tail: bool = False,
1637
+ ) -> dict:
1625
1638
  records = self._read_cached_jsonl(self._quest_root(quest_id) / ".ds" / "events.jsonl")
1626
1639
  normalized_limit = max(limit, 0)
1627
- if tail and normalized_limit > 0:
1640
+ direction = "after"
1641
+ if before is not None:
1642
+ direction = "before"
1643
+ end = max(int(before) - 1, 0)
1644
+ start = max(end - normalized_limit, 0)
1645
+ sliced = records[start:end]
1646
+ elif tail and normalized_limit > 0:
1647
+ direction = "tail"
1628
1648
  start = max(len(records) - normalized_limit, 0)
1649
+ sliced = records[start : start + normalized_limit]
1629
1650
  else:
1630
1651
  start = max(after, 0)
1631
- sliced = records[start : start + normalized_limit]
1652
+ sliced = records[start : start + normalized_limit]
1632
1653
  enriched = []
1633
1654
  for index, item in enumerate(sliced, start=start + 1):
1634
1655
  enriched.append(
@@ -1638,11 +1659,23 @@ class QuestService:
1638
1659
  **item,
1639
1660
  }
1640
1661
  )
1641
- next_cursor = len(records) if tail else start + len(sliced)
1662
+ if before is not None:
1663
+ next_cursor = start + len(sliced)
1664
+ else:
1665
+ next_cursor = len(records) if tail else start + len(sliced)
1666
+ oldest_cursor = enriched[0]["cursor"] if enriched else None
1667
+ newest_cursor = enriched[-1]["cursor"] if enriched else None
1668
+ if before is not None:
1669
+ has_more = start > 0
1670
+ else:
1671
+ has_more = start > 0 if tail else next_cursor < len(records)
1642
1672
  return {
1643
1673
  "quest_id": quest_id,
1644
1674
  "cursor": next_cursor,
1645
- "has_more": start > 0 if tail else next_cursor < len(records),
1675
+ "has_more": has_more,
1676
+ "oldest_cursor": oldest_cursor,
1677
+ "newest_cursor": newest_cursor,
1678
+ "direction": direction,
1646
1679
  "events": enriched,
1647
1680
  }
1648
1681
 
@@ -1790,18 +1823,13 @@ class QuestService:
1790
1823
 
1791
1824
  quest_root = self._quest_root(quest_id)
1792
1825
  workspace_root = self.active_workspace_root(quest_root)
1793
- changed_paths = {
1794
- item["path"]: item
1795
- for item in self.workflow(quest_id).get("changed_files", [])
1796
- if item.get("path")
1797
- }
1798
1826
  git_status = self._git_status_map(workspace_root)
1799
1827
 
1800
1828
  root_nodes = self._tree_children(
1801
1829
  workspace_root,
1802
1830
  workspace_root,
1803
1831
  git_status=git_status,
1804
- changed_paths=changed_paths,
1832
+ changed_paths={},
1805
1833
  profile=profile,
1806
1834
  )
1807
1835
  sections = self._group_explorer_sections(root_nodes)
@@ -2036,6 +2064,36 @@ class QuestService:
2036
2064
  return None, None
2037
2065
  return document_id, None
2038
2066
 
2067
+ @staticmethod
2068
+ def _path_to_document_id(
2069
+ path: str | Path | None,
2070
+ *,
2071
+ quest_root: Path,
2072
+ workspace_root: Path,
2073
+ ) -> str | None:
2074
+ if not path:
2075
+ return None
2076
+ try:
2077
+ candidate = Path(path).expanduser()
2078
+ if not candidate.is_absolute():
2079
+ candidate = (workspace_root / candidate).resolve()
2080
+ else:
2081
+ candidate = candidate.resolve()
2082
+ except OSError:
2083
+ return None
2084
+
2085
+ try:
2086
+ relative_to_workspace = candidate.relative_to(workspace_root.resolve()).as_posix()
2087
+ return f"path::{relative_to_workspace}"
2088
+ except ValueError:
2089
+ pass
2090
+
2091
+ try:
2092
+ relative_to_quest = candidate.relative_to(quest_root.resolve()).as_posix()
2093
+ return f"questpath::{relative_to_quest}"
2094
+ except ValueError:
2095
+ return None
2096
+
2039
2097
  @staticmethod
2040
2098
  def _markdown_asset_directory(relative_path: str) -> PurePosixPath:
2041
2099
  base_path = PurePosixPath(relative_path)
@@ -62,6 +62,38 @@ def _field(label: str, value: object, *, tone: str = "default") -> dict[str, Any
62
62
  }
63
63
 
64
64
 
65
+ def _evaluation_summary(value: object) -> dict[str, Any]:
66
+ if not isinstance(value, dict):
67
+ return {}
68
+ normalized: dict[str, Any] = {}
69
+ for key in (
70
+ "takeaway",
71
+ "claim_update",
72
+ "baseline_relation",
73
+ "comparability",
74
+ "failure_mode",
75
+ "next_action",
76
+ ):
77
+ raw = value.get(key)
78
+ text = str(raw).strip() if raw is not None else ""
79
+ if text:
80
+ normalized[key] = text
81
+ return normalized
82
+
83
+
84
+ def _evaluation_summary_fields(value: object, *, prefix: str = "Evaluation") -> list[dict[str, Any]]:
85
+ summary = _evaluation_summary(value)
86
+ labels = (
87
+ ("takeaway", f"{prefix} Takeaway"),
88
+ ("claim_update", f"{prefix} Claim Update"),
89
+ ("baseline_relation", f"{prefix} Baseline Relation"),
90
+ ("comparability", f"{prefix} Comparability"),
91
+ ("failure_mode", f"{prefix} Failure Mode"),
92
+ ("next_action", f"{prefix} Next Action"),
93
+ )
94
+ return [_field(label, summary[key]) for key, label in labels if summary.get(key)]
95
+
96
+
65
97
  def _artifact_sort_key(item: dict[str, Any]) -> tuple[str, str]:
66
98
  payload = item.get("payload") if isinstance(item.get("payload"), dict) else {}
67
99
  return (
@@ -814,6 +846,9 @@ class QuestStageViewBuilder:
814
846
  )
815
847
  latest_metrics_summary = latest_experiment_payload.get("metrics_summary") or latest_result_payload.get("metrics_summary") or {}
816
848
  latest_run_id = str(latest_experiment_payload.get("run_id") or "").strip() or None
849
+ latest_evaluation_summary = _evaluation_summary(
850
+ latest_experiment_payload.get("evaluation_summary") or latest_result_payload.get("evaluation_summary")
851
+ )
817
852
 
818
853
  analysis_manifests = self._analysis_manifests()
819
854
  analysis_manifest = next(
@@ -883,6 +918,7 @@ class QuestStageViewBuilder:
883
918
  _field("Latest Metrics", latest_metrics_summary or "Not recorded"),
884
919
  _field("Delta vs Baseline", latest_progress_eval.get("delta_vs_baseline") or "Not recorded"),
885
920
  _field("Breakthrough", latest_progress_eval.get("breakthrough_level") or "Not recorded"),
921
+ *_evaluation_summary_fields(latest_evaluation_summary),
886
922
  ],
887
923
  key_files=self._dedupe_files(
888
924
  [
@@ -940,6 +976,7 @@ class QuestStageViewBuilder:
940
976
  "verdict": latest_experiment_payload.get("verdict"),
941
977
  "metrics_summary": latest_metrics_summary,
942
978
  "progress_eval": latest_progress_eval,
979
+ "evaluation_summary": latest_evaluation_summary,
943
980
  "run_md_path": latest_experiment_paths.get("run_md"),
944
981
  "result_json_path": latest_experiment_paths.get("result_json"),
945
982
  }
@@ -979,6 +1016,7 @@ class QuestStageViewBuilder:
979
1016
  result_payload = read_json(Path(paths.get("result_json")), {}) if str(paths.get("result_json") or "").strip() else {}
980
1017
  progress_eval = payload.get("progress_eval") or result_payload.get("progress_eval") or {}
981
1018
  baseline_ref = payload.get("baseline_ref") or result_payload.get("baseline_ref") or {}
1019
+ evaluation_summary = _evaluation_summary(payload.get("evaluation_summary") or result_payload.get("evaluation_summary"))
982
1020
  run_id = str(payload.get("run_id") or "pending").strip() or "pending"
983
1021
  note = (
984
1022
  str(payload.get("summary") or result_payload.get("conclusion") or (progress_eval or {}).get("reason") or "").strip()
@@ -1028,6 +1066,7 @@ class QuestStageViewBuilder:
1028
1066
  _field("Metrics Summary", metrics_summary or "Not recorded"),
1029
1067
  _field("Delta vs Baseline", (progress_eval or {}).get("delta_vs_baseline") or "Not recorded"),
1030
1068
  _field("Breakthrough Level", (progress_eval or {}).get("breakthrough_level") or "Not recorded"),
1069
+ *_evaluation_summary_fields(evaluation_summary),
1031
1070
  ],
1032
1071
  key_files=key_files,
1033
1072
  history=self._artifact_history(experiment_items),
@@ -1040,6 +1079,7 @@ class QuestStageViewBuilder:
1040
1079
  "baseline_ref": baseline_ref,
1041
1080
  "metrics_summary": metrics_summary,
1042
1081
  "progress_eval": progress_eval,
1082
+ "evaluation_summary": evaluation_summary,
1043
1083
  "result_payload": result_payload,
1044
1084
  }
1045
1085
  },
@@ -1141,6 +1181,9 @@ class QuestStageViewBuilder:
1141
1181
  "reviewer_resolution": detail_payload.get("reviewer_resolution"),
1142
1182
  "manuscript_update_hint": detail_payload.get("manuscript_update_hint"),
1143
1183
  "next_recommendation": detail_payload.get("next_recommendation"),
1184
+ "evaluation_summary": _evaluation_summary(
1185
+ run_payload.get("evaluation_summary") or detail_payload.get("evaluation_summary")
1186
+ ),
1144
1187
  "deviations": detail_payload.get("deviations") or [],
1145
1188
  "evidence_paths": detail_payload.get("evidence_paths") or [],
1146
1189
  "plan_path": item.get("plan_path"),
@@ -1233,8 +1276,11 @@ class QuestStageViewBuilder:
1233
1276
  self._file_entry("paper/writing_plan.md", label="Writing Plan", description="Paper writing plan."),
1234
1277
  self._file_entry("paper/references.bib", label="References", description="Bibliography file."),
1235
1278
  self._file_entry("paper/claim_evidence_map.json", label="Claim-Evidence Map", description="Claim to evidence mapping."),
1279
+ self._file_entry("paper/baseline_inventory.json", label="Baseline Inventory", description="Canonical and supplementary baseline inventory for writing."),
1236
1280
  self._file_entry("paper/build/compile_report.json", label="Compile Report", description="Paper build/compile report."),
1237
1281
  self._file_entry("paper/paper_bundle_manifest.json", label="Bundle Manifest", description="Final paper bundle manifest."),
1282
+ self._file_entry("release/open_source/manifest.json", label="Open Source Manifest", description="Open-source cleanup and release preparation manifest."),
1283
+ self._file_entry("release/open_source/cleanup_plan.md", label="Open Source Cleanup Plan", description="Checklist for cleaning the paper branch into a public release."),
1238
1284
  self._file_entry(latex_root_rel, label="LaTeX Sources", description="LaTeX source folder.", expected_kind="directory"),
1239
1285
  self._file_entry(main_tex_rel, label="Main TeX", description="Primary TeX source file."),
1240
1286
  ]
@@ -530,6 +530,7 @@ class CodexRunner:
530
530
 
531
531
  env = dict(**os.environ)
532
532
  env["CODEX_HOME"] = str(codex_home)
533
+ env["DEEPSCIENTIST_HOME"] = str(self.home)
533
534
  env["DS_HOME"] = str(self.home)
534
535
  env["DS_QUEST_ID"] = request.quest_id
535
536
  env["DS_QUEST_ROOT"] = str(request.quest_root)
@@ -846,6 +847,7 @@ class CodexRunner:
846
847
  tool_timeout_sec = None
847
848
 
848
849
  shared_env = {
850
+ "DEEPSCIENTIST_HOME": str(self.home),
849
851
  "DS_HOME": str(self.home),
850
852
  "DS_QUEST_ID": quest_id,
851
853
  "DS_QUEST_ROOT": str(quest_root),
@@ -71,7 +71,8 @@ def write_json(path: Path, payload: Any) -> None:
71
71
  )
72
72
 
73
73
 
74
- def read_json(path: Path, default: Any = None) -> Any:
74
+ def read_json(path: Path | str, default: Any = None) -> Any:
75
+ path = Path(path)
75
76
  if not path.exists():
76
77
  return default
77
78
  payload = path.read_text(encoding="utf-8").strip()
@@ -155,35 +156,61 @@ def which(binary: str) -> str | None:
155
156
  return shutil.which(binary)
156
157
 
157
158
 
158
- def resolve_runner_binary(binary: str, *, runner_name: str | None = None) -> str | None:
159
- normalized = str(binary or "").strip()
159
+ def _resolve_executable_reference(reference: str) -> str | None:
160
+ normalized = str(reference or "").strip()
160
161
  if not normalized:
161
162
  return None
162
163
 
163
164
  candidate = Path(normalized).expanduser()
164
165
  if candidate.is_absolute() or os.path.sep in normalized or (os.path.altsep and os.path.altsep in normalized):
165
166
  return str(candidate) if candidate.exists() else None
167
+ return shutil.which(normalized)
168
+
169
+
170
+ def _codex_repo_roots() -> list[Path]:
171
+ roots: list[Path] = []
172
+ configured = str(os.environ.get("DEEPSCIENTIST_REPO_ROOT") or "").strip()
173
+ if configured:
174
+ roots.append(Path(configured).expanduser().resolve())
175
+ roots.append(Path(__file__).resolve().parents[2])
176
+
177
+ deduped: list[Path] = []
178
+ seen: set[str] = set()
179
+ for root in roots:
180
+ key = str(root)
181
+ if key in seen:
182
+ continue
183
+ seen.add(key)
184
+ deduped.append(root)
185
+ return deduped
166
186
 
167
- discovered = shutil.which(normalized)
168
- if discovered:
169
- return discovered
187
+
188
+ def resolve_runner_binary(binary: str, *, runner_name: str | None = None) -> str | None:
189
+ normalized = str(binary or "").strip()
190
+ if not normalized:
191
+ return None
192
+
193
+ resolved_reference = _resolve_executable_reference(normalized)
194
+ candidate = Path(normalized).expanduser()
195
+ if candidate.is_absolute() or os.path.sep in normalized or (os.path.altsep and os.path.altsep in normalized):
196
+ return resolved_reference
170
197
 
171
198
  normalized_runner = str(runner_name or candidate.name or normalized).strip().lower()
172
199
  if normalized_runner != "codex":
173
- return None
200
+ return resolved_reference
174
201
 
175
202
  for env_name in ("DEEPSCIENTIST_CODEX_BINARY", "DS_CODEX_BINARY"):
176
203
  override = os.environ.get(env_name)
177
204
  if override:
178
- override_path = Path(override).expanduser()
179
- if override_path.exists():
180
- return str(override_path)
205
+ resolved_override = _resolve_executable_reference(override)
206
+ if resolved_override:
207
+ return resolved_override
181
208
 
182
- repo_root = Path(__file__).resolve().parents[2]
183
- node_bin_root = repo_root / "node_modules" / ".bin"
184
209
  names = ["codex.cmd", "codex.exe", "codex"] if sys.platform.startswith("win") else ["codex"]
185
- for name in names:
186
- package_local = node_bin_root / name
187
- if package_local.exists():
188
- return str(package_local)
189
- return None
210
+ for root in _codex_repo_roots():
211
+ node_bin_root = root / "node_modules" / ".bin"
212
+ for name in names:
213
+ package_local = node_bin_root / name
214
+ if package_local.exists():
215
+ return str(package_local)
216
+ return resolved_reference
@@ -10,3 +10,6 @@
10
10
  - lingzhu_progress_rule: for long-running work, your first substantive reply should contain either the direct answer or the first concrete checkpoint, not a duplicate transport acknowledgement
11
11
  - lingzhu_safety_rule: request only actions that are clearly justified by the current quest and understandable to the human user
12
12
  - lingzhu_text_rule: even when requesting `surface_actions`, always include a clear text explanation of what is happening and why
13
+ - lingzhu_reply_style_rule: for Lingzhu-facing user-visible text sent through `artifact.interact(...)`, keep the message clear, concise, respectful, and high-information-density
14
+ - lingzhu_reply_length_rule: for each Lingzhu-facing `artifact.interact(...)` message, normally answer in at most 2 to 3 sentences unless the user explicitly asks for more detail
15
+ - lingzhu_summary_first_rule: in Lingzhu-facing `artifact.interact(...)` messages, usually give only the synopsis and key facts needed for the user's next decision or understanding; avoid long preambles, repetition, and low-signal detail
@@ -4,6 +4,14 @@
4
4
  - connector_contract_scope: loaded only when QQ is the active or bound external connector for this quest
5
5
  - connector_contract_goal: use `artifact.interact(...)` as the main durable user-visible thread on QQ instead of exposing raw internal runner or tool chatter
6
6
  - qq_reply_style: keep QQ replies concise, milestone-first, respectful, and easy to scan on a phone
7
+ - qq_reply_length_rule: for ordinary QQ progress updates, normally use only 2 to 4 short sentences, or 3 short bullets at most
8
+ - qq_summary_first_rule: start with the conclusion the user cares about, then what it means, then the next action
9
+ - qq_progress_shape_rule: make the current task, the main difficulty or latest real progress, and the next concrete measure explicit whenever possible
10
+ - qq_eta_rule: for baseline reproduction, main experiments, analysis experiments, and other important long-running research phases, include a rough ETA for the next meaningful result or the next update; if uncertain, say that and still give the next check-in window
11
+ - qq_tool_call_keepalive_rule: for ordinary active work, if roughly 10 to 30 tool calls pass without a user-visible checkpoint, send one concise QQ progress update before continuing
12
+ - qq_internal_detail_rule: omit worker names, heartbeat timestamps, retry counters, pending/running/completed counts, file names, and monitor-window narration unless the user asked for them or the detail changes the recommended action
13
+ - qq_translation_rule: convert internal execution and file-management work into user value, such as saying the baseline record is now organized for easier later comparison instead of listing touched files
14
+ - qq_preflight_rule: before sending a QQ progress update, rewrite it if it still sounds like a monitoring log, execution diary, or file inventory
7
15
  - qq_operator_surface_rule: treat QQ as an operator surface for coordination and milestone delivery, not as a full artifact browser
8
16
  - qq_default_text_rule: plain text is the default and safest QQ mode
9
17
  - qq_absolute_path_rule: when you request native QQ image or file delivery via an attachment `path`, prefer an absolute path
@@ -39,12 +47,44 @@
39
47
 
40
48
  ## Examples
41
49
 
50
+ ### 0. Bad vs good QQ progress update
51
+
52
+ Bad:
53
+
54
+ ```text
55
+ 我刚结束新的 60 秒监控窗,当前还是 15 pending / 2 running / 3 completed。local-gptoss + tare + GSM8K_DSPy 的 heartbeat 已推进到 00:07:10 UTC,local-qwen + atare + BBH_tracking_shuffled_objects_five_objects 也推进到 00:06:38 UTC。我已经同步更新 status、summary、execution 和 inventory,接下来继续看下一段 120 秒恢复窗。
56
+ ```
57
+
58
+ Why bad:
59
+
60
+ - it forces the user to infer the conclusion from telemetry
61
+ - it exposes internal counters, timestamps, worker labels, and file actions that usually do not help the user
62
+ - it reads like a monitoring transcript, not like a collaborator update
63
+
64
+ Good:
65
+
66
+ ```text
67
+ 公开 baseline 还在继续推进,暂时不需要额外修补。当前主要情况是整体在往前走,但其中一条线仍然更慢、更不稳定。接下来我会继续盯下一轮结果,预计 20 到 30 分钟内会有下一次关键判断;如果更早出现完成、再次卡住,或者需要干预,我会提前同步给您。
68
+ ```
69
+
70
+ Why good:
71
+
72
+ - it starts with the conclusion the user actually needs
73
+ - it keeps the meaningful risk but removes unnecessary internal telemetry
74
+ - it tells the user exactly what will happen next
75
+
76
+ English-style reference shape:
77
+
78
+ ```text
79
+ I'm working on {current task}. The main issue right now is {difficulty or risk}, but {latest real progress or current judgment}. Next I'll {concrete next measure}. You should hear from me again in about {ETA}, or sooner if {important condition} happens.
80
+ ```
81
+
42
82
  ### 1. Plain-text QQ progress update
43
83
 
44
84
  ```python
45
85
  artifact.interact(
46
86
  kind="progress",
47
- message="主实验第一轮已经跑完,结果稳定。我正在继续做消融,下一次会同步关键变化。",
87
+ message="主实验第一轮已经跑完,结果目前比较稳定。接下来我会继续补消融,确认这个提升是不是稳得住。下一次我只同步关键变化给您。",
48
88
  reply_mode="threaded",
49
89
  )
50
90
  ```
@@ -56,7 +96,7 @@ Use the normal `artifact.interact(...)` call. When DeepScientist already knows t
56
96
  ```python
57
97
  artifact.interact(
58
98
  kind="progress",
59
- message="我已经看完您刚才提到的那篇论文,正在整理它和当前 baseline 的核心差异,稍后给您一个更完整的结论。",
99
+ message="我已经看完您刚才提到的那篇论文,也确认了它和当前 baseline 的核心差异。接下来我会把真正影响路线选择的部分整理出来,再给您一个更完整的结论。",
60
100
  reply_mode="threaded",
61
101
  )
62
102
  ```