@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.
- package/README.md +47 -1
- package/bin/ds.js +1823 -121
- package/docs/en/00_QUICK_START.md +38 -20
- package/docs/en/01_SETTINGS_REFERENCE.md +20 -20
- package/docs/en/02_START_RESEARCH_GUIDE.md +11 -11
- package/docs/en/03_QQ_CONNECTOR_GUIDE.md +10 -10
- package/docs/en/05_TUI_GUIDE.md +1 -1
- package/docs/en/09_DOCTOR.md +48 -4
- package/docs/en/90_ARCHITECTURE.md +4 -2
- package/docs/zh/00_QUICK_START.md +38 -20
- package/docs/zh/01_SETTINGS_REFERENCE.md +21 -21
- package/docs/zh/02_START_RESEARCH_GUIDE.md +19 -19
- package/docs/zh/03_QQ_CONNECTOR_GUIDE.md +10 -10
- package/docs/zh/05_TUI_GUIDE.md +1 -1
- package/docs/zh/09_DOCTOR.md +46 -4
- package/install.sh +9 -8
- package/package.json +2 -1
- package/pyproject.toml +1 -1
- package/src/deepscientist/__init__.py +6 -1
- package/src/deepscientist/artifact/service.py +552 -25
- package/src/deepscientist/config/service.py +1 -1
- package/src/deepscientist/daemon/api/handlers.py +18 -1
- package/src/deepscientist/daemon/api/router.py +2 -0
- package/src/deepscientist/daemon/app.py +90 -1
- package/src/deepscientist/doctor.py +69 -2
- package/src/deepscientist/gitops/diff.py +3 -0
- package/src/deepscientist/home.py +25 -2
- package/src/deepscientist/mcp/context.py +3 -1
- package/src/deepscientist/mcp/server.py +6 -0
- package/src/deepscientist/prompts/builder.py +41 -0
- package/src/deepscientist/quest/layout.py +1 -0
- package/src/deepscientist/quest/service.py +70 -12
- package/src/deepscientist/quest/stage_views.py +46 -0
- package/src/deepscientist/runners/codex.py +2 -0
- package/src/deepscientist/shared.py +44 -17
- package/src/prompts/connectors/lingzhu.md +3 -0
- package/src/prompts/system.md +38 -5
- package/src/skills/analysis-campaign/SKILL.md +24 -1
- package/src/skills/baseline/SKILL.md +7 -1
- package/src/skills/decision/SKILL.md +3 -2
- package/src/skills/experiment/SKILL.md +17 -1
- package/src/skills/finalize/SKILL.md +4 -1
- package/src/skills/idea/SKILL.md +1 -1
- package/src/skills/intake-audit/SKILL.md +1 -1
- package/src/skills/rebuttal/SKILL.md +3 -1
- package/src/skills/review/SKILL.md +3 -1
- package/src/skills/scout/SKILL.md +1 -1
- package/src/skills/write/SKILL.md +1 -1
- package/src/tui/package.json +1 -1
- package/src/ui/dist/assets/{AiManusChatView-w5lF2Ttt.js → AiManusChatView-CZpg376x.js} +64 -68
- package/src/ui/dist/assets/{AnalysisPlugin-DJOED79I.js → AnalysisPlugin-CtHA22g3.js} +1 -1
- package/src/ui/dist/assets/{AutoFigurePlugin-DaG61Y0M.js → AutoFigurePlugin-BSWmLMmF.js} +5 -5
- package/src/ui/dist/assets/{CliPlugin-CV4LqUB_.js → CliPlugin-CJ7jdm_s.js} +9 -9
- package/src/ui/dist/assets/{CodeEditorPlugin-DylfAea4.js → CodeEditorPlugin-DhInVGFf.js} +8 -8
- package/src/ui/dist/assets/{CodeViewerPlugin-F7saY0LM.js → CodeViewerPlugin-D1n8S9r5.js} +5 -5
- package/src/ui/dist/assets/{DocViewerPlugin-COP0c7jf.js → DocViewerPlugin-C4XM_kqk.js} +3 -3
- package/src/ui/dist/assets/{GitDiffViewerPlugin-CAS05pT9.js → GitDiffViewerPlugin-W6kS9r6v.js} +1 -1
- package/src/ui/dist/assets/{ImageViewerPlugin-Bco1CN_w.js → ImageViewerPlugin-DPeUx_Oz.js} +5 -5
- package/src/ui/dist/assets/{LabCopilotPanel-CvMlCD99.js → LabCopilotPanel-eAelUaub.js} +10 -10
- package/src/ui/dist/assets/{LabPlugin-BYankkE4.js → LabPlugin-BbOrBxKY.js} +1 -1
- package/src/ui/dist/assets/{LatexPlugin-LDSMR-t-.js → LatexPlugin-C-HhkVXY.js} +7 -7
- package/src/ui/dist/assets/{MarkdownViewerPlugin-B7o80jgm.js → MarkdownViewerPlugin-BDIzIBfh.js} +4 -4
- package/src/ui/dist/assets/{MarketplacePlugin-CM6ZOcpC.js → MarketplacePlugin-DAOJphwr.js} +3 -3
- package/src/ui/dist/assets/{NotebookEditor-Dc61cXmK.js → NotebookEditor-BsoMvDoU.js} +1 -1
- package/src/ui/dist/assets/{PdfLoader-DWowuQwx.js → PdfLoader-fiC7RtHf.js} +1 -1
- package/src/ui/dist/assets/{PdfMarkdownPlugin-BsJM1q_a.js → PdfMarkdownPlugin-C5OxZBFK.js} +3 -3
- package/src/ui/dist/assets/{PdfViewerPlugin-DB2eEEFQ.js → PdfViewerPlugin-CAbxQebk.js} +10 -10
- package/src/ui/dist/assets/{SearchPlugin-CraThSvt.js → SearchPlugin-SE33Lb9B.js} +1 -1
- package/src/ui/dist/assets/{Stepper-CgocRTPq.js → Stepper-0Av7GfV7.js} +1 -1
- package/src/ui/dist/assets/{TextViewerPlugin-B1JGhKtd.js → TextViewerPlugin-Daf2gJDI.js} +4 -4
- package/src/ui/dist/assets/{VNCViewer-CclFC7FM.js → VNCViewer-BKrMUIOX.js} +9 -9
- package/src/ui/dist/assets/{bibtex-D3IKsMl7.js → bibtex-JBdOEe45.js} +1 -1
- package/src/ui/dist/assets/{code-BP37Xx0p.js → code-B0TDFCZz.js} +1 -1
- package/src/ui/dist/assets/{file-content-BAJSu-9r.js → file-content-3YtrSacz.js} +1 -1
- package/src/ui/dist/assets/{file-diff-panel-DUGeCTuy.js → file-diff-panel-CJEg5OG1.js} +1 -1
- package/src/ui/dist/assets/{file-socket-CXc1Ojf7.js → file-socket-CYQYdmB1.js} +1 -1
- package/src/ui/dist/assets/{file-utils-2J21jt7M.js → file-utils-Cd1C9Ppl.js} +1 -1
- package/src/ui/dist/assets/{image-CMMmgvcn.js → image-B33ctrvC.js} +1 -1
- package/src/ui/dist/assets/{index-s7aHnNQ4.js → index-9CLPVeZh.js} +1 -1
- package/src/ui/dist/assets/{index-CWgMgpow.js → index-BNQWqmJ2.js} +11 -11
- package/src/ui/dist/assets/{index-DmwmJmbW.js → index-BVXsmS7V.js} +15808 -14025
- package/src/ui/dist/assets/{index-BaVumsQT.js → index-Buw_N1VQ.js} +2 -2
- package/src/ui/dist/assets/{index-KGt-z-dD.css → index-SwmFAld3.css} +2700 -2
- package/src/ui/dist/assets/{message-square-CQRfX0Am.js → message-square-D0cUJ9yU.js} +1 -1
- package/src/ui/dist/assets/{monaco-B4TbdsrF.js → monaco-UZLYkp2n.js} +1 -1
- package/src/ui/dist/assets/{popover-B8Rokodk.js → popover-CTeiY-dK.js} +1 -1
- package/src/ui/dist/assets/{project-sync-D_i96KH4.js → project-sync-Dbs01Xky.js} +1 -1
- package/src/ui/dist/assets/{sigma-D12PnzCN.js → sigma-CM08S-xT.js} +1 -1
- package/src/ui/dist/assets/{tooltip-B6YrI4aJ.js → tooltip-pDtzvU9p.js} +1 -1
- package/src/ui/dist/assets/{trash-Bc8jGp0V.js → trash-YvPCP-da.js} +1 -1
- package/src/ui/dist/assets/{useCliAccess-mXVCYSZ-.js → useCliAccess-Bavi74Ac.js} +1 -1
- package/src/ui/dist/assets/{useFileDiffOverlay-Bg6b9H9K.js → useFileDiffOverlay-CVXY6oeg.js} +1 -1
- package/src/ui/dist/assets/{wrap-text-Drh5GEnL.js → wrap-text-Cf4flRW7.js} +1 -1
- package/src/ui/dist/assets/{zoom-out-CJj9DZLn.js → zoom-out-Hb0Z1YpT.js} +1 -1
- package/src/ui/dist/index.html +2 -2
- package/uv.lock +1155 -0
- 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
|
-
"
|
|
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(
|
|
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
|
|
264
|
+
summary="Codex CLI is not available to DeepScientist.",
|
|
199
265
|
errors=[f"Runner binary `{binary}` could not be resolved."],
|
|
200
266
|
guidance=[
|
|
201
|
-
"
|
|
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
|
-
|
|
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("
|
|
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 []
|
|
@@ -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":
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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=
|
|
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)
|