@researai/deepscientist 1.5.1 → 1.5.2

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 (97) hide show
  1. package/README.md +47 -1
  2. package/bin/ds.js +1823 -121
  3. package/docs/en/00_QUICK_START.md +38 -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 +38 -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 +9 -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 +552 -25
  21. package/src/deepscientist/config/service.py +1 -1
  22. package/src/deepscientist/daemon/api/handlers.py +18 -1
  23. package/src/deepscientist/daemon/api/router.py +2 -0
  24. package/src/deepscientist/daemon/app.py +90 -1
  25. package/src/deepscientist/doctor.py +69 -2
  26. package/src/deepscientist/gitops/diff.py +3 -0
  27. package/src/deepscientist/home.py +25 -2
  28. package/src/deepscientist/mcp/context.py +3 -1
  29. package/src/deepscientist/mcp/server.py +6 -0
  30. package/src/deepscientist/prompts/builder.py +41 -0
  31. package/src/deepscientist/quest/layout.py +1 -0
  32. package/src/deepscientist/quest/service.py +70 -12
  33. package/src/deepscientist/quest/stage_views.py +46 -0
  34. package/src/deepscientist/runners/codex.py +2 -0
  35. package/src/deepscientist/shared.py +44 -17
  36. package/src/prompts/connectors/lingzhu.md +3 -0
  37. package/src/prompts/system.md +38 -5
  38. package/src/skills/analysis-campaign/SKILL.md +24 -1
  39. package/src/skills/baseline/SKILL.md +7 -1
  40. package/src/skills/decision/SKILL.md +3 -2
  41. package/src/skills/experiment/SKILL.md +17 -1
  42. package/src/skills/finalize/SKILL.md +4 -1
  43. package/src/skills/idea/SKILL.md +1 -1
  44. package/src/skills/intake-audit/SKILL.md +1 -1
  45. package/src/skills/rebuttal/SKILL.md +3 -1
  46. package/src/skills/review/SKILL.md +3 -1
  47. package/src/skills/scout/SKILL.md +1 -1
  48. package/src/skills/write/SKILL.md +1 -1
  49. package/src/tui/package.json +1 -1
  50. package/src/ui/dist/assets/{AiManusChatView-w5lF2Ttt.js → AiManusChatView-CZpg376x.js} +64 -68
  51. package/src/ui/dist/assets/{AnalysisPlugin-DJOED79I.js → AnalysisPlugin-CtHA22g3.js} +1 -1
  52. package/src/ui/dist/assets/{AutoFigurePlugin-DaG61Y0M.js → AutoFigurePlugin-BSWmLMmF.js} +5 -5
  53. package/src/ui/dist/assets/{CliPlugin-CV4LqUB_.js → CliPlugin-CJ7jdm_s.js} +9 -9
  54. package/src/ui/dist/assets/{CodeEditorPlugin-DylfAea4.js → CodeEditorPlugin-DhInVGFf.js} +8 -8
  55. package/src/ui/dist/assets/{CodeViewerPlugin-F7saY0LM.js → CodeViewerPlugin-D1n8S9r5.js} +5 -5
  56. package/src/ui/dist/assets/{DocViewerPlugin-COP0c7jf.js → DocViewerPlugin-C4XM_kqk.js} +3 -3
  57. package/src/ui/dist/assets/{GitDiffViewerPlugin-CAS05pT9.js → GitDiffViewerPlugin-W6kS9r6v.js} +1 -1
  58. package/src/ui/dist/assets/{ImageViewerPlugin-Bco1CN_w.js → ImageViewerPlugin-DPeUx_Oz.js} +5 -5
  59. package/src/ui/dist/assets/{LabCopilotPanel-CvMlCD99.js → LabCopilotPanel-eAelUaub.js} +10 -10
  60. package/src/ui/dist/assets/{LabPlugin-BYankkE4.js → LabPlugin-BbOrBxKY.js} +1 -1
  61. package/src/ui/dist/assets/{LatexPlugin-LDSMR-t-.js → LatexPlugin-C-HhkVXY.js} +7 -7
  62. package/src/ui/dist/assets/{MarkdownViewerPlugin-B7o80jgm.js → MarkdownViewerPlugin-BDIzIBfh.js} +4 -4
  63. package/src/ui/dist/assets/{MarketplacePlugin-CM6ZOcpC.js → MarketplacePlugin-DAOJphwr.js} +3 -3
  64. package/src/ui/dist/assets/{NotebookEditor-Dc61cXmK.js → NotebookEditor-BsoMvDoU.js} +1 -1
  65. package/src/ui/dist/assets/{PdfLoader-DWowuQwx.js → PdfLoader-fiC7RtHf.js} +1 -1
  66. package/src/ui/dist/assets/{PdfMarkdownPlugin-BsJM1q_a.js → PdfMarkdownPlugin-C5OxZBFK.js} +3 -3
  67. package/src/ui/dist/assets/{PdfViewerPlugin-DB2eEEFQ.js → PdfViewerPlugin-CAbxQebk.js} +10 -10
  68. package/src/ui/dist/assets/{SearchPlugin-CraThSvt.js → SearchPlugin-SE33Lb9B.js} +1 -1
  69. package/src/ui/dist/assets/{Stepper-CgocRTPq.js → Stepper-0Av7GfV7.js} +1 -1
  70. package/src/ui/dist/assets/{TextViewerPlugin-B1JGhKtd.js → TextViewerPlugin-Daf2gJDI.js} +4 -4
  71. package/src/ui/dist/assets/{VNCViewer-CclFC7FM.js → VNCViewer-BKrMUIOX.js} +9 -9
  72. package/src/ui/dist/assets/{bibtex-D3IKsMl7.js → bibtex-JBdOEe45.js} +1 -1
  73. package/src/ui/dist/assets/{code-BP37Xx0p.js → code-B0TDFCZz.js} +1 -1
  74. package/src/ui/dist/assets/{file-content-BAJSu-9r.js → file-content-3YtrSacz.js} +1 -1
  75. package/src/ui/dist/assets/{file-diff-panel-DUGeCTuy.js → file-diff-panel-CJEg5OG1.js} +1 -1
  76. package/src/ui/dist/assets/{file-socket-CXc1Ojf7.js → file-socket-CYQYdmB1.js} +1 -1
  77. package/src/ui/dist/assets/{file-utils-2J21jt7M.js → file-utils-Cd1C9Ppl.js} +1 -1
  78. package/src/ui/dist/assets/{image-CMMmgvcn.js → image-B33ctrvC.js} +1 -1
  79. package/src/ui/dist/assets/{index-s7aHnNQ4.js → index-9CLPVeZh.js} +1 -1
  80. package/src/ui/dist/assets/{index-CWgMgpow.js → index-BNQWqmJ2.js} +11 -11
  81. package/src/ui/dist/assets/{index-DmwmJmbW.js → index-BVXsmS7V.js} +15808 -14025
  82. package/src/ui/dist/assets/{index-BaVumsQT.js → index-Buw_N1VQ.js} +2 -2
  83. package/src/ui/dist/assets/{index-KGt-z-dD.css → index-SwmFAld3.css} +2700 -2
  84. package/src/ui/dist/assets/{message-square-CQRfX0Am.js → message-square-D0cUJ9yU.js} +1 -1
  85. package/src/ui/dist/assets/{monaco-B4TbdsrF.js → monaco-UZLYkp2n.js} +1 -1
  86. package/src/ui/dist/assets/{popover-B8Rokodk.js → popover-CTeiY-dK.js} +1 -1
  87. package/src/ui/dist/assets/{project-sync-D_i96KH4.js → project-sync-Dbs01Xky.js} +1 -1
  88. package/src/ui/dist/assets/{sigma-D12PnzCN.js → sigma-CM08S-xT.js} +1 -1
  89. package/src/ui/dist/assets/{tooltip-B6YrI4aJ.js → tooltip-pDtzvU9p.js} +1 -1
  90. package/src/ui/dist/assets/{trash-Bc8jGp0V.js → trash-YvPCP-da.js} +1 -1
  91. package/src/ui/dist/assets/{useCliAccess-mXVCYSZ-.js → useCliAccess-Bavi74Ac.js} +1 -1
  92. package/src/ui/dist/assets/{useFileDiffOverlay-Bg6b9H9K.js → useFileDiffOverlay-CVXY6oeg.js} +1 -1
  93. package/src/ui/dist/assets/{wrap-text-Drh5GEnL.js → wrap-text-Cf4flRW7.js} +1 -1
  94. package/src/ui/dist/assets/{zoom-out-CJj9DZLn.js → zoom-out-Hb0Z1YpT.js} +1 -1
  95. package/src/ui/dist/index.html +2 -2
  96. package/uv.lock +1155 -0
  97. package/src/ui/dist/assets/LabPlugin-D9jVIo0A.css +0 -2698
@@ -1052,7 +1052,7 @@ Use **Test** when the file exposes runtime dependencies.
1052
1052
  "warnings": [],
1053
1053
  "errors": [
1054
1054
  "Codex binary is not installed or could not be resolved.",
1055
- "Install `@openai/codex` and ensure the `codex` CLI is available to DeepScientist.",
1055
+ "DeepScientist could not resolve the bundled or configured `codex` CLI.",
1056
1056
  ],
1057
1057
  "details": details,
1058
1058
  "guidance": [
@@ -10,6 +10,7 @@ from urllib.parse import parse_qs, unquote
10
10
 
11
11
  from ...acp import OptionalACPBridge, build_session_descriptor, build_session_update, get_acp_bridge_status
12
12
  from ...bash_exec.service import DEFAULT_TERMINAL_SESSION_ID
13
+ from ... import __version__ as DEEPSCIENTIST_VERSION
13
14
  from ...gitops import commit_detail, compare_refs, diff_file_between_refs, diff_file_for_commit, export_git_graph, list_branch_canvas, log_ref_history
14
15
  from ...memory import MemoryService
15
16
  from ...quest import QuestService
@@ -71,6 +72,7 @@ class ApiHandlers:
71
72
  def _inject_ui_runtime(self, payload: str) -> str:
72
73
  runtime_payload = {
73
74
  "surface": "quest",
75
+ "version": DEEPSCIENTIST_VERSION,
74
76
  "supports": {
75
77
  "productApis": False,
76
78
  "socketIo": False,
@@ -160,6 +162,13 @@ npm --prefix src/ui run build</pre>
160
162
  "sessions": self.app.sessions.snapshot(),
161
163
  }
162
164
 
165
+ def system_update(self) -> dict:
166
+ return self.app.system_update_status()
167
+
168
+ def system_update_action(self, body: dict) -> dict:
169
+ action = str(body.get("action") or "").strip().lower()
170
+ return self.app.request_system_update(action=action)
171
+
163
172
  def cli_health(self) -> dict:
164
173
  online_channels = [
165
174
  channel.status()
@@ -394,12 +403,20 @@ npm --prefix src/ui run build</pre>
394
403
  def quest_events(self, quest_id: str, path: str) -> dict:
395
404
  query = self.parse_query(path)
396
405
  after = int((query.get("after") or ["0"])[0] or "0")
406
+ before_raw = ((query.get("before") or [""])[0] or "").strip()
407
+ before = int(before_raw) if before_raw.isdigit() else None
397
408
  limit = int((query.get("limit") or ["200"])[0] or "200")
398
409
  tail_raw = ((query.get("tail") or ["0"])[0] or "0").strip().lower()
399
410
  tail = tail_raw in {"1", "true", "yes", "on"}
400
411
  format_name = ((query.get("format") or ["both"])[0] or "both").lower()
401
412
  session_id = ((query.get("session_id") or [f"quest:{quest_id}"])[0] or f"quest:{quest_id}")
402
- payload = self._fresh_quest_service().events(quest_id, after=after, limit=limit, tail=tail)
413
+ payload = self._fresh_quest_service().events(
414
+ quest_id,
415
+ after=after,
416
+ before=before,
417
+ limit=limit,
418
+ tail=tail,
419
+ )
403
420
  if format_name in {"acp", "both"}:
404
421
  payload["acp_updates"] = [
405
422
  build_session_update(
@@ -8,6 +8,8 @@ ROUTES: list[tuple[str, re.Pattern[str], str]] = [
8
8
  ("GET", re.compile(r"^/ui/(?P<ui_path>.+)$"), "ui_asset"),
9
9
  ("GET", re.compile(r"^/(?P<spa_path>(?!api(?:/|$)|ui(?:/|$)|assets(?:/|$)).+)$"), "spa_root"),
10
10
  ("GET", re.compile(r"^/api/health$"), "health"),
11
+ ("GET", re.compile(r"^/api/system/update$"), "system_update"),
12
+ ("POST", re.compile(r"^/api/system/update$"), "system_update_action"),
11
13
  ("GET", re.compile(r"^/api/v1/health/cli$"), "cli_health"),
12
14
  ("POST", re.compile(r"^/api/admin/shutdown$"), "admin_shutdown"),
13
15
  ("GET", re.compile(r"^/api/acp/status$"), "acp_status"),
@@ -5,6 +5,7 @@ import json
5
5
  import mimetypes
6
6
  import os
7
7
  import shutil
8
+ import subprocess
8
9
  import threading
9
10
  import time
10
11
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -106,6 +107,8 @@ class DaemonApp:
106
107
  self._terminal_attach_thread: threading.Thread | None = None
107
108
  self._terminal_attach_host: str | None = None
108
109
  self._terminal_attach_port: int | None = None
110
+ self._serve_host: str | None = None
111
+ self._serve_port: int | None = None
109
112
  self._shutdown_requested = threading.Event()
110
113
  self._qq_gateway: QQGatewayService | None = None
111
114
  self._telegram_polling: TelegramPollingService | None = None
@@ -122,6 +125,88 @@ class DaemonApp:
122
125
  items.append(self.config_manager.lingzhu_snapshot(lingzhu_config))
123
126
  return items
124
127
 
128
+ def _launcher_update_base_command(self) -> list[str]:
129
+ node_binary = str(os.environ.get("DEEPSCIENTIST_NODE_BINARY") or "").strip() or which("node") or which("nodejs")
130
+ launcher_path = str(os.environ.get("DEEPSCIENTIST_LAUNCHER_PATH") or "").strip()
131
+ if not launcher_path:
132
+ launcher_path = str(self.repo_root / "bin" / "ds.js")
133
+ if not node_binary:
134
+ raise RuntimeError("Node.js is not available on PATH, so DeepScientist cannot check npm updates.")
135
+ if not Path(launcher_path).exists():
136
+ raise RuntimeError(f"DeepScientist launcher path does not exist: {launcher_path}")
137
+ return [node_binary, launcher_path, "update", "--home", str(self.home)]
138
+
139
+ def system_update_status(self) -> dict[str, object]:
140
+ command = [*self._launcher_update_base_command(), "--check", "--json"]
141
+ try:
142
+ result = subprocess.run(
143
+ command,
144
+ cwd=str(self.repo_root),
145
+ capture_output=True,
146
+ text=True,
147
+ timeout=8,
148
+ check=False,
149
+ env=os.environ.copy(),
150
+ )
151
+ except subprocess.TimeoutExpired as exc:
152
+ raise RuntimeError("DeepScientist update check timed out.") from exc
153
+ if result.returncode != 0:
154
+ raise RuntimeError((result.stderr or result.stdout or "Update check failed.").strip())
155
+ try:
156
+ payload = json.loads(result.stdout or "{}")
157
+ except json.JSONDecodeError as exc:
158
+ raise RuntimeError("DeepScientist update check returned invalid JSON.") from exc
159
+ if not isinstance(payload, dict):
160
+ raise RuntimeError("DeepScientist update check returned an invalid payload.")
161
+ return payload
162
+
163
+ def request_system_update(self, *, action: str) -> dict[str, object]:
164
+ normalized = str(action or "").strip().lower()
165
+ if normalized not in {"install_latest", "remind_later", "skip_version"}:
166
+ raise ValueError(f"Unsupported update action `{action}`.")
167
+ command = self._launcher_update_base_command()
168
+ if normalized == "install_latest":
169
+ host = self._serve_host or "0.0.0.0"
170
+ port = self._serve_port or 20999
171
+ command.extend(
172
+ [
173
+ "--yes",
174
+ "--background",
175
+ "--restart-daemon",
176
+ "--host",
177
+ str(host),
178
+ "--port",
179
+ str(port),
180
+ "--json",
181
+ ]
182
+ )
183
+ elif normalized == "remind_later":
184
+ command.extend(["--remind-later", "--json"])
185
+ else:
186
+ command.extend(["--skip-version", "--json"])
187
+
188
+ try:
189
+ result = subprocess.run(
190
+ command,
191
+ cwd=str(self.repo_root),
192
+ capture_output=True,
193
+ text=True,
194
+ timeout=8,
195
+ check=False,
196
+ env=os.environ.copy(),
197
+ )
198
+ except subprocess.TimeoutExpired as exc:
199
+ raise RuntimeError("DeepScientist update request timed out.") from exc
200
+ if result.returncode != 0:
201
+ raise RuntimeError((result.stderr or result.stdout or "Update request failed.").strip())
202
+ try:
203
+ payload = json.loads(result.stdout or "{}")
204
+ except json.JSONDecodeError as exc:
205
+ raise RuntimeError("DeepScientist update request returned invalid JSON.") from exc
206
+ if not isinstance(payload, dict):
207
+ raise RuntimeError("DeepScientist update request returned an invalid payload.")
208
+ return payload
209
+
125
210
  def _process_terminal_attach_request(
126
211
  self,
127
212
  connection: ServerConnection,
@@ -3667,7 +3752,7 @@ class DaemonApp:
3667
3752
  headers=dict(self.headers.items()),
3668
3753
  body=body,
3669
3754
  )
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"}:
3755
+ 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", "system_update_action"}:
3671
3756
  payload = result(**params, body=body)
3672
3757
  elif route_name == "config_validate":
3673
3758
  payload = result(body)
@@ -3711,6 +3796,8 @@ class DaemonApp:
3711
3796
  server = ThreadingHTTPServer((host, port), RequestHandler)
3712
3797
  server.daemon_threads = True
3713
3798
  self._server = server
3799
+ self._serve_host = host
3800
+ self._serve_port = port
3714
3801
  self._shutdown_requested.clear()
3715
3802
  self._start_terminal_attach_server(host, port)
3716
3803
  self._start_background_connectors()
@@ -3724,4 +3811,6 @@ class DaemonApp:
3724
3811
  self._stop_terminal_attach_server()
3725
3812
  self.bash_exec_service.shutdown()
3726
3813
  self._server = None
3814
+ self._serve_host = None
3815
+ self._serve_port = None
3727
3816
  server.server_close()
@@ -1,8 +1,11 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import os
3
4
  import socket
5
+ import subprocess
4
6
  import sys
5
7
  import tempfile
8
+ from shutil import which
6
9
  from pathlib import Path
7
10
  from typing import Any
8
11
  from urllib.error import URLError
@@ -108,6 +111,69 @@ def _check_home_writable(home: Path) -> dict[str, Any]:
108
111
  )
109
112
 
110
113
 
114
+ def _resolve_uv_binary(home: Path) -> str | None:
115
+ for env_name in ("DEEPSCIENTIST_UV", "UV_BIN"):
116
+ override = str(os.environ.get(env_name) or "").strip()
117
+ if not override:
118
+ continue
119
+ override_path = Path(override).expanduser()
120
+ if override_path.exists():
121
+ return str(override_path)
122
+ resolved_override = which(override)
123
+ if resolved_override:
124
+ return resolved_override
125
+
126
+ local_candidates = [
127
+ home / "runtime" / "tools" / "uv" / "bin" / "uv",
128
+ home / "runtime" / "tools" / "uv" / "bin" / "uv.exe",
129
+ ]
130
+ for candidate in local_candidates:
131
+ if candidate.exists():
132
+ return str(candidate)
133
+ return which("uv")
134
+
135
+
136
+ def _check_uv(home: Path) -> dict[str, Any]:
137
+ resolved = _resolve_uv_binary(home)
138
+ if not resolved:
139
+ guidance = [
140
+ "Run `ds` once so DeepScientist can bootstrap a local uv runtime manager automatically.",
141
+ ]
142
+ if sys.platform == "win32":
143
+ guidance.append('PowerShell: `powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"`')
144
+ else:
145
+ guidance.append("macOS/Linux: `curl -LsSf https://astral.sh/uv/install.sh | sh`")
146
+ return _make_check(
147
+ check_id="uv",
148
+ label="uv runtime manager",
149
+ ok=False,
150
+ summary="uv is not available to DeepScientist.",
151
+ errors=["DeepScientist cannot provision or repair its local Python runtime without `uv`."],
152
+ guidance=guidance,
153
+ )
154
+
155
+ version = ""
156
+ try:
157
+ result = subprocess.run(
158
+ [resolved, "--version"],
159
+ check=False,
160
+ capture_output=True,
161
+ text=True,
162
+ )
163
+ if result.returncode == 0:
164
+ version = (result.stdout or result.stderr or "").strip()
165
+ except OSError:
166
+ version = ""
167
+
168
+ return _make_check(
169
+ check_id="uv",
170
+ label="uv runtime manager",
171
+ ok=True,
172
+ summary="uv is available for locked Python runtime management.",
173
+ details={"resolved_binary": resolved, "version": version or None},
174
+ )
175
+
176
+
111
177
  def _check_git(config_manager: ConfigManager) -> dict[str, Any]:
112
178
  readiness = config_manager.git_readiness()
113
179
  return _make_check(
@@ -195,10 +261,10 @@ def _check_codex(config_manager: ConfigManager) -> dict[str, Any]:
195
261
  check_id="codex",
196
262
  label="Codex CLI",
197
263
  ok=False,
198
- summary="Codex CLI is not available on PATH.",
264
+ summary="Codex CLI is not available to DeepScientist.",
199
265
  errors=[f"Runner binary `{binary}` could not be resolved."],
200
266
  guidance=[
201
- "Install Codex first: `npm install -g @openai/codex`.",
267
+ "Run `npm install -g @researai/deepscientist` again so the bundled Codex dependency is installed.",
202
268
  "Then run `codex` once and complete login.",
203
269
  ],
204
270
  details={"binary": binary},
@@ -370,6 +436,7 @@ def run_doctor(home: Path, *, repo_root: Path) -> dict[str, Any]:
370
436
  checks = [
371
437
  _check_python_runtime(),
372
438
  _check_home_writable(home),
439
+ _check_uv(home),
373
440
  _check_git(config_manager),
374
441
  _check_config_validation(config_manager),
375
442
  _check_runner_support(config_manager),
@@ -333,6 +333,9 @@ def _collect_branch_state(repo: Path) -> dict[str, dict[str, Any]]:
333
333
  "baseline_ref": record.get("baseline_ref") or {},
334
334
  "baseline_comparisons": record.get("baseline_comparisons") or {},
335
335
  "progress_eval": record.get("progress_eval") or {},
336
+ "evaluation_summary": record.get("evaluation_summary")
337
+ or ((record.get("details") or {}) if isinstance(record.get("details"), dict) else {}).get("evaluation_summary")
338
+ or {},
336
339
  "files_changed": record.get("files_changed") or [],
337
340
  "evidence_paths": record.get("evidence_paths") or [],
338
341
  "updated_at": record.get("updated_at"),
@@ -1,12 +1,34 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import os
3
4
  from pathlib import Path
4
5
 
5
6
  from .shared import ensure_dir
6
7
 
7
8
 
9
+ def _looks_like_repo_root(path: Path) -> bool:
10
+ return (
11
+ (path / "pyproject.toml").exists()
12
+ and (path / "src" / "deepscientist").exists()
13
+ and (path / "src" / "skills").exists()
14
+ )
15
+
16
+
8
17
  def repo_root() -> Path:
9
- return Path(__file__).resolve().parents[2]
18
+ configured = str(os.environ.get("DEEPSCIENTIST_REPO_ROOT") or "").strip()
19
+ if configured:
20
+ candidate = Path(configured).expanduser().resolve()
21
+ if _looks_like_repo_root(candidate):
22
+ return candidate
23
+
24
+ cwd = Path.cwd().resolve()
25
+ if _looks_like_repo_root(cwd):
26
+ return cwd
27
+
28
+ candidate = Path(__file__).resolve().parents[2]
29
+ if _looks_like_repo_root(candidate):
30
+ return candidate
31
+ return candidate
10
32
 
11
33
 
12
34
  def default_home() -> Path:
@@ -15,9 +37,10 @@ def default_home() -> Path:
15
37
 
16
38
  def ensure_home_layout(home: Path) -> dict[str, Path]:
17
39
  runtime = ensure_dir(home / "runtime")
18
- ensure_dir(runtime / "venv")
19
40
  ensure_dir(runtime / "bundle")
20
41
  ensure_dir(runtime / "tools")
42
+ ensure_dir(runtime / "python")
43
+ ensure_dir(runtime / "uv-cache")
21
44
 
22
45
  config = ensure_dir(home / "config")
23
46
  ensure_dir(config / "baselines")
@@ -4,6 +4,8 @@ import os
4
4
  from dataclasses import dataclass
5
5
  from pathlib import Path
6
6
 
7
+ from ..home import default_home
8
+
7
9
 
8
10
  @dataclass(frozen=True)
9
11
  class McpContext:
@@ -24,7 +26,7 @@ class McpContext:
24
26
  value = os.environ.get(name, "").strip()
25
27
  return Path(value).expanduser() if value else None
26
28
 
27
- home = _path("DS_HOME") or (Path.home() / "DeepScientist")
29
+ home = _path("DEEPSCIENTIST_HOME") or _path("DS_HOME") or default_home()
28
30
  return cls(
29
31
  home=home,
30
32
  quest_id=os.environ.get("DS_QUEST_ID") or None,
@@ -307,6 +307,7 @@ def build_artifact_server(context: McpContext) -> FastMCP:
307
307
  status: str = "completed",
308
308
  baseline_id: str | None = None,
309
309
  baseline_variant_id: str | None = None,
310
+ evaluation_summary: dict[str, Any] | None = None,
310
311
  comment: str | dict[str, Any] | None = None,
311
312
  ) -> dict[str, Any]:
312
313
  return service.record_main_experiment(
@@ -330,6 +331,7 @@ def build_artifact_server(context: McpContext) -> FastMCP:
330
331
  status=status,
331
332
  baseline_id=baseline_id,
332
333
  baseline_variant_id=baseline_variant_id,
334
+ evaluation_summary=evaluation_summary,
333
335
  )
334
336
 
335
337
  @server.tool(
@@ -462,6 +464,8 @@ def build_artifact_server(context: McpContext) -> FastMCP:
462
464
  next_recommendation: str | None = None,
463
465
  dataset_scope: str = "full",
464
466
  subset_approval_ref: str | None = None,
467
+ comparison_baselines: list[dict[str, Any]] | None = None,
468
+ evaluation_summary: dict[str, Any] | None = None,
465
469
  comment: str | dict[str, Any] | None = None,
466
470
  ) -> dict[str, Any]:
467
471
  return service.record_analysis_slice(
@@ -481,6 +485,8 @@ def build_artifact_server(context: McpContext) -> FastMCP:
481
485
  next_recommendation=next_recommendation,
482
486
  dataset_scope=dataset_scope,
483
487
  subset_approval_ref=subset_approval_ref,
488
+ comparison_baselines=comparison_baselines,
489
+ evaluation_summary=evaluation_summary,
484
490
  )
485
491
 
486
492
  @server.tool(name="publish_baseline", description="Publish a quest baseline to the global baseline registry.")
@@ -735,7 +735,10 @@ class PromptBuilder:
735
735
  "- acknowledgment_protocol: after artifact.interact returns any human message, immediately call artifact.interact(...) again to confirm receipt; if answerable, answer directly, otherwise state the short plan, nearest checkpoint, and that the current background subtask is paused",
736
736
  "- progress_protocol: emit artifact.interact(kind='progress', reply_mode='threaded', ...) only at real human-meaningful checkpoints, after the first meaningful signal from long-running work, and then only occasional keepalives during truly long work, usually about every 20 to 30 minutes",
737
737
  "- long_run_reporting_protocol: for long-running bash_exec monitoring loops, report after each completed sleep/await cycle with real evidence plus the next planned check time and estimated next reply time",
738
+ "- timeout_protocol: before using bash_exec(mode='await', ...), estimate whether the command can finish within the selected wait window; if runtime is uncertain or likely longer, use bash_exec(mode='detach', ...) and monitor, or set timeout_seconds intentionally",
738
739
  "- blocking_protocol: use reply_mode='blocking' only for true unresolved user decisions; ordinary progress updates should stay threaded and non-blocking",
740
+ "- credential_blocking_protocol: if continuation requires user-supplied external credentials or secrets such as an API key, GitHub key/token, or Hugging Face key/token, emit one structured blocking decision request that asks the user to provide the credential or choose an alternative route; do not invent placeholders or silently skip the blocked step",
741
+ "- credential_wait_protocol: if that credential request remains unanswered, keep the quest waiting rather than self-resolving; if you are resumed without new credentials and no other work is possible, a long low-frequency park such as `bash_exec(command='sleep 3600', mode='await', timeout_seconds=3700)` is acceptable to avoid busy-looping",
739
742
  f"- standby_prefix_rule: when you intentionally leave one blocking standby interaction after task completion, prefix it with {'[等待决策]' if chinese_turn else '[Waiting for decision]'} and wait for a new user reply before continuing",
740
743
  "- stop_notice_protocol: if work must pause or stop, send a user-visible notice that explains why, confirms preserved context, and states that any new message or `/resume` will continue from the same quest",
741
744
  "- respect_protocol: write user-facing updates as natural, respectful, easy-to-follow chat; do not sound like a formal status report or internal tool log",
@@ -913,6 +916,26 @@ class PromptBuilder:
913
916
  "- active_baseline_metric_contract_rule: before planning or running `experiment` or `analysis-campaign`, read this JSON file and treat it as the canonical baseline comparison contract unless a newer confirmed baseline explicitly replaces it.",
914
917
  ]
915
918
  )
919
+ analysis_baseline_inventory = read_json(quest_root / "artifacts" / "baselines" / "analysis_inventory.json", {})
920
+ analysis_baseline_inventory = analysis_baseline_inventory if isinstance(analysis_baseline_inventory, dict) else {}
921
+ analysis_inventory_entries = (
922
+ analysis_baseline_inventory.get("entries") if isinstance(analysis_baseline_inventory.get("entries"), list) else []
923
+ )
924
+ registered_count = sum(
925
+ 1
926
+ for item in analysis_inventory_entries
927
+ if isinstance(item, dict) and str(item.get("status") or "").strip().lower() == "registered"
928
+ )
929
+ if analysis_inventory_entries:
930
+ lines.extend(
931
+ [
932
+ f"- supplementary_baseline_inventory_status: artifacts/baselines/analysis_inventory.json [exists]",
933
+ f"- supplementary_baseline_count: {len(analysis_inventory_entries)}",
934
+ f"- supplementary_baseline_registered_count: {registered_count}",
935
+ ]
936
+ )
937
+ else:
938
+ lines.append("- supplementary_baseline_inventory_status: artifacts/baselines/analysis_inventory.json [missing]")
916
939
  lines.extend(["", "Active interactions:"])
917
940
  active_interactions = snapshot.get("active_interactions") or []
918
941
  if active_interactions:
@@ -1001,10 +1024,14 @@ class PromptBuilder:
1001
1024
  )
1002
1025
  bundle_manifest = read_json(paper_root / "paper_bundle_manifest.json", {})
1003
1026
  bundle_manifest = bundle_manifest if isinstance(bundle_manifest, dict) else {}
1027
+ paper_baseline_inventory = read_json(paper_root / "baseline_inventory.json", {})
1028
+ paper_baseline_inventory = paper_baseline_inventory if isinstance(paper_baseline_inventory, dict) else {}
1004
1029
  claim_evidence_map = read_json(paper_root / "claim_evidence_map.json", {})
1005
1030
  claim_evidence_map = claim_evidence_map if isinstance(claim_evidence_map, dict) else {}
1006
1031
  compile_report = read_json(paper_root / "build" / "compile_report.json", {})
1007
1032
  compile_report = compile_report if isinstance(compile_report, dict) else {}
1033
+ open_source_manifest = read_json(quest_root / "release" / "open_source" / "manifest.json", {})
1034
+ open_source_manifest = open_source_manifest if isinstance(open_source_manifest, dict) else {}
1008
1035
 
1009
1036
  selected_outline_ref = str(
1010
1037
  selected_outline.get("outline_id") or bundle_manifest.get("selected_outline_ref") or ""
@@ -1045,6 +1072,7 @@ class PromptBuilder:
1045
1072
  f"- draft_status: {_path_status(bundle_manifest.get('draft_path'), fallback='paper/draft.md')}",
1046
1073
  f"- references_status: {_path_status(bundle_manifest.get('references_path'), fallback='paper/references.bib')}",
1047
1074
  f"- claim_evidence_map_status: {_path_status(bundle_manifest.get('claim_evidence_map_path'), fallback='paper/claim_evidence_map.json')}",
1075
+ f"- baseline_inventory_status: {_path_status(bundle_manifest.get('baseline_inventory_path'), fallback='paper/baseline_inventory.json')}",
1048
1076
  f"- review_status: {'paper/review/review.md [exists]' if (paper_root / 'review' / 'review.md').exists() else 'paper/review/review.md [missing]'}",
1049
1077
  f"- proofing_report_status: {'paper/proofing/proofing_report.md [exists]' if (paper_root / 'proofing' / 'proofing_report.md').exists() else 'paper/proofing/proofing_report.md [missing]'}",
1050
1078
  f"- page_images_manifest_status: {'paper/proofing/page_images_manifest.json [exists]' if (paper_root / 'proofing' / 'page_images_manifest.json').exists() else 'paper/proofing/page_images_manifest.json [missing]'}",
@@ -1061,6 +1089,8 @@ class PromptBuilder:
1061
1089
  f"- bundle_pdf_status: {_path_status(pdf_rel_path, fallback='paper/paper.pdf')}",
1062
1090
  f"- bundle_compile_report_status: {_path_status(compile_rel_path, fallback='paper/build/compile_report.json')}",
1063
1091
  f"- bundle_latex_root: {latex_root_path or 'none'}",
1092
+ f"- open_source_manifest_status: {_path_status(bundle_manifest.get('open_source_manifest_path'), fallback='release/open_source/manifest.json')}",
1093
+ f"- open_source_cleanup_plan_status: {_path_status(bundle_manifest.get('open_source_cleanup_plan_path'), fallback='release/open_source/cleanup_plan.md')}",
1064
1094
  ]
1065
1095
  )
1066
1096
  else:
@@ -1089,6 +1119,17 @@ class PromptBuilder:
1089
1119
 
1090
1120
  if compile_report:
1091
1121
  lines.append(f"- compile_report_ok: {compile_report.get('ok') if 'ok' in compile_report else 'unknown'}")
1122
+ supplementary_baselines = (
1123
+ paper_baseline_inventory.get("supplementary_baselines")
1124
+ if isinstance(paper_baseline_inventory.get("supplementary_baselines"), list)
1125
+ else []
1126
+ )
1127
+ if paper_baseline_inventory:
1128
+ lines.append(f"- paper_supplementary_baseline_count: {len(supplementary_baselines)}")
1129
+ if open_source_manifest:
1130
+ lines.append(
1131
+ f"- open_source_release_branch: {str(open_source_manifest.get('release_branch') or '').strip() or 'none'}"
1132
+ )
1092
1133
 
1093
1134
  lines.extend(["", "Recent supporting runs:"])
1094
1135
  recent_runs = snapshot.get("recent_runs") or []
@@ -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)