@gavinc-cn/touchstone-dsh 0.1.1 → 0.3.0
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 +39 -11
- package/README.zh.md +25 -10
- package/backend/lib/access.js +44 -0
- package/backend/lib/agents.js +341 -0
- package/backend/lib/args.js +94 -0
- package/backend/lib/auth.js +195 -0
- package/backend/lib/board.js +6154 -0
- package/backend/lib/builtin-assets.js +1177 -0
- package/backend/lib/casescan.js +456 -0
- package/backend/lib/chat.js +2053 -0
- package/backend/lib/cookie.js +76 -0
- package/backend/lib/db.js +2260 -0
- package/backend/lib/dshdriver.js +1148 -0
- package/backend/lib/dshevents.js +851 -0
- package/backend/lib/export-cases.js +261 -0
- package/backend/lib/feishu.js +1738 -0
- package/backend/lib/feishu_cfg.js +228 -0
- package/backend/lib/feishu_conv.js +1484 -0
- package/backend/lib/feishu_device.js +428 -0
- package/backend/lib/feishu_inbound.js +1674 -0
- package/backend/lib/feishu_provision.js +855 -0
- package/backend/lib/feishu_rest.js +398 -0
- package/backend/lib/feishu_slash.js +568 -0
- package/backend/lib/feishu_ws.js +950 -0
- package/backend/lib/http.js +312 -0
- package/backend/lib/lib.js +973 -0
- package/backend/lib/loadcase.js +749 -0
- package/backend/lib/loadgen.js +1407 -0
- package/backend/lib/localbus.js +164 -0
- package/backend/lib/lock.js +405 -0
- package/backend/lib/proc.js +278 -0
- package/backend/lib/prompts.js +1021 -0
- package/backend/lib/pybase64.js +114 -0
- package/backend/lib/pyjson.js +214 -0
- package/backend/lib/query.js +125 -0
- package/backend/lib/rag.js +1310 -0
- package/backend/lib/runner.js +4520 -0
- package/backend/lib/servercaps.js +182 -0
- package/backend/lib/serverstate.js +237 -0
- package/backend/lib/sessparse.js +1176 -0
- package/backend/lib/sse.js +207 -0
- package/backend/lib/static.js +84 -0
- package/backend/lib/taskjson.js +302 -0
- package/backend/lib/waitq.js +2114 -0
- package/backend/lib/worktree.js +588 -0
- package/backend/lib/zstd-frames.js +218 -0
- package/backend/routes/admin.js +553 -0
- package/backend/routes/agents.js +149 -0
- package/backend/routes/assets.js +145 -0
- package/backend/routes/board.js +1220 -0
- package/backend/routes/feishu.js +269 -0
- package/backend/routes/feishu_app.js +215 -0
- package/backend/routes/feishu_hook.js +358 -0
- package/backend/routes/index.js +297 -0
- package/backend/routes/projects.js +1059 -0
- package/backend/routes/sessions.js +946 -0
- package/backend/routes/tasks.js +1530 -0
- package/backend/schema/fixtures/ancient.sql +189 -0
- package/backend/schema/fixtures/pre_alter.sql +177 -0
- package/backend/schema/migrations.json +154 -0
- package/backend/schema/schema.json +1451 -0
- package/backend/schema/schema.sql +216 -0
- package/backend/server.js +498 -0
- package/board.py +805 -72
- package/chat.py +590 -17
- package/db.py +356 -140
- package/dsh-plugin/cordis.patch.yml +7 -3
- package/dsh-plugin/install.sh +28 -11
- package/dsh-plugin/lib/agent-driver.js +710 -89
- package/dsh-plugin/lib/client.bundle.js +94 -9
- package/dsh-plugin/lib/index.js +529 -39
- package/dsh-plugin/lib/pack-closure.mjs +291 -0
- package/dsh-plugin/scripts/prepack-check.mjs +30 -2
- package/dsh-plugin/src/client.js +94 -9
- package/dshdriver.py +74 -5
- package/dshevents.py +65 -3
- package/feishu.py +1197 -34
- package/feishu_conv.py +920 -0
- package/lifecycle.py +116 -13
- package/package.json +6 -1
- package/requirements.txt +2 -1
- package/server.py +218 -26
- package/sessparse.py +150 -44
- package/touchstone.py +6 -3
- package/waitq.py +23 -0
- package/webui/dist-plugin/assets/index-ChP3sg6y.css +1 -0
- package/webui/dist-plugin/assets/index-UrMyeAXF.js +484 -0
- package/webui/dist-plugin/index.html +2 -2
- package/webui/dist-plugin/assets/index-B0WoP1Qy.css +0 -1
- package/webui/dist-plugin/assets/index-DtwKoi1T.js +0 -473
package/README.md
CHANGED
|
@@ -31,8 +31,10 @@ remembers what it already tried. A unified queue keeps exactly one writer per pr
|
|
|
31
31
|
editing the same checkout at once), and the whole thing is observable — live round logs, a rendered
|
|
32
32
|
session window, and an SSE-backed board.
|
|
33
33
|
|
|
34
|
-
Touchstone runs **locally**.
|
|
35
|
-
|
|
34
|
+
Touchstone runs **locally**. Standalone mode is a Python web site; plugin mode defaults to the bundled Node
|
|
35
|
+
backend (since 2026-10-15, with `backend: python` as the one-line fallback). Either way it is a SQLite
|
|
36
|
+
database plus a file-system workspace, and no data leaves your machine except for the model calls your
|
|
37
|
+
`dsh` host makes.
|
|
36
38
|
|
|
37
39
|
## Highlights
|
|
38
40
|
|
|
@@ -56,8 +58,9 @@ no data leaves your machine except for the model calls your `dsh` host makes.
|
|
|
56
58
|
drives the load itself, N runs per task, with per-run metrics, charts, and Markdown/JSON reports.
|
|
57
59
|
- **Optional integrations.** Feishu (Lark) push notifications with two-way answering from chat cards, and
|
|
58
60
|
RAG-based semantic search over the case library.
|
|
59
|
-
- **Self-contained stack.** Backend:
|
|
60
|
-
|
|
61
|
+
- **Self-contained stack.** Backend: the bundled Node backend (plugin-mode default, needs Node 22.19+) or
|
|
62
|
+
the Python standard library plus four small dependencies (standalone mode / `backend: python`).
|
|
63
|
+
Frontend: React + Vite. Storage: SQLite + plain files. Runs on Linux and Windows.
|
|
61
64
|
|
|
62
65
|
## Screenshots
|
|
63
66
|
|
|
@@ -133,8 +136,8 @@ flowchart LR
|
|
|
133
136
|
|
|
134
137
|
| Requirement | Version | Needed for |
|
|
135
138
|
|-------------|---------|------------|
|
|
136
|
-
|
|
|
137
|
-
|
|
|
139
|
+
| Node.js | 22.19+ or 24+ | The `dsh` host **and the plugin-mode backend** (default; the front-end build alone needs Node 18+) |
|
|
140
|
+
| Python | 3.12+ | Standalone mode (`./touchstone.sh start`) and plugin mode with `backend: python` (verified on 3.12) |
|
|
138
141
|
| DeepSeek Harness (`dsh`) | current release | Agent execution — required for plugin mode |
|
|
139
142
|
|
|
140
143
|
### 1. Clone and install
|
|
@@ -142,7 +145,7 @@ flowchart LR
|
|
|
142
145
|
```bash
|
|
143
146
|
git clone https://github.com/gavinc-cn/touchstone-dsh.git
|
|
144
147
|
cd touchstone-dsh
|
|
145
|
-
python3 -m pip install -r requirements.txt
|
|
148
|
+
python3 -m pip install -r requirements.txt # needed for standalone mode / plugin mode with backend: python
|
|
146
149
|
```
|
|
147
150
|
|
|
148
151
|
### 2. Start the site (standalone mode)
|
|
@@ -165,6 +168,14 @@ grep '一次性初始口令' .run/server.log # or: ./touchstone.sh status
|
|
|
165
168
|
You will be asked to change it on first login. Set `TS_ADMIN_PASSWORD=<your-password>` before the first
|
|
166
169
|
start to skip the one-time password (and the forced change).
|
|
167
170
|
|
|
171
|
+
> **Plugin mode (`dsh`) is password-free by default** (2026-10-08): the shell passes
|
|
172
|
+
> `TS_ADMIN_PASSWORDLESS=1`, so the seeded `admin` has an **empty password** and no forced change — the
|
|
173
|
+
> panel is auto-logged-in as `admin` anyway. Set a password in **Settings → Change password** whenever you
|
|
174
|
+
> want one (it then becomes the password used for direct/standalone logins). The plugin also writes the
|
|
175
|
+
> backend's stdout/stderr line by line to `<db dir>/plugin-backend.log` (next to
|
|
176
|
+
> `~/.touchstone/touchstone.db`) — that is where the plugin-mode startup banner and the
|
|
177
|
+
> `[board]`/`[waitq]`/`[runner]` diagnostics can be read.
|
|
178
|
+
|
|
168
179
|
> Standalone mode gives you the full site — projects, case library, bug reports, board, queue, monitoring —
|
|
169
180
|
> but **agent execution needs plugin mode** (below), because the agent runs inside the `dsh` host process.
|
|
170
181
|
|
|
@@ -184,14 +195,30 @@ node ~/.dsh/profiles/web/node_modules/@gavinc-cn/touchstone-dsh/dsh-plugin/scrip
|
|
|
184
195
|
# 4) restart the dsh web UI, open http://127.0.0.1:3080, use the Touchstone entry (Alt+T toggles the panel)
|
|
185
196
|
```
|
|
186
197
|
|
|
187
|
-
|
|
198
|
+
**Plugin mode needs no Python by default.** Since 2026-10-15 the plugin shell starts the bundled Node
|
|
199
|
+
backend (it probes the host Node for `node:sqlite` first; if the host is too old it shows a hint page with
|
|
200
|
+
the two ways out). Python is used only for the Python backend — standalone mode, or `backend: python` in the
|
|
201
|
+
plugin config, which is the one-line fallback during the observation window (deleting the key no longer
|
|
202
|
+
reverts anything: the default *is* `node` now). For that Python backend, the platform's own dependencies are
|
|
203
|
+
installed with pip, not npm:
|
|
188
204
|
|
|
189
205
|
```bash
|
|
190
206
|
pip install -r ~/.dsh/profiles/web/node_modules/@gavinc-cn/touchstone-dsh/requirements.txt
|
|
207
|
+
# Windows: python -m pip install ... (the official installer ships python.exe, not python3)
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
With the Python backend, the plugin resolves the interpreter itself when you do not configure one: it probes
|
|
211
|
+
`python` first, then `python3` (the same order as `touchstone.cmd`), and uses the first one that actually
|
|
212
|
+
runs. If that is not the interpreter where you installed the dependencies, the missing-dependency page prints
|
|
213
|
+
the exact command for the interpreter it picked; to pin another one, set `pythonPath` in the profile patch:
|
|
214
|
+
|
|
215
|
+
```yaml
|
|
216
|
+
- id: touchstone-dsh
|
|
217
|
+
config: { backend: python, pythonPath: /path/to/python }
|
|
191
218
|
```
|
|
192
219
|
|
|
193
|
-
No `repoDir` is needed: the package carries `server.py
|
|
194
|
-
its own directory.
|
|
220
|
+
No `repoDir` is needed: the package carries `server.py`, `backend/` (the Node backend) and
|
|
221
|
+
`webui/dist-plugin`, so the plugin falls back to its own directory.
|
|
195
222
|
|
|
196
223
|
**Option B — from a checkout (development):**
|
|
197
224
|
|
|
@@ -204,7 +231,7 @@ The installer prints the optional profile patch for pointing the plugin at your
|
|
|
204
231
|
specific interpreter); skip it to use the package's own directory:
|
|
205
232
|
|
|
206
233
|
```yaml
|
|
207
|
-
- id: touchstone
|
|
234
|
+
- id: touchstone-dsh
|
|
208
235
|
config: { repoDir: /path/to/touchstone-dsh, pythonPath: /path/to/python3 }
|
|
209
236
|
```
|
|
210
237
|
|
|
@@ -236,6 +263,7 @@ python touchstone.py start | stop | restart | status | build | test
|
|
|
236
263
|
| `TS_PYTHON` | `python3` | Interpreter used by the launcher (must have the runtime dependencies) |
|
|
237
264
|
| `TOUCHSTONE_DB` | `~/.touchstone/touchstone.db` | SQLite database. Must live on a local disk — file locking does not work on CIFS/network shares |
|
|
238
265
|
| `TS_ADMIN_PASSWORD` | *(random, printed once)* | Initial `admin` password; setting it skips the forced change |
|
|
266
|
+
| `TS_ADMIN_PASSWORDLESS` | `1` in plugin mode | Seed `admin` with an empty password and no forced change (the plugin shell sets this); ignored when `TS_ADMIN_PASSWORD` is set |
|
|
239
267
|
| `TS_WEB_DIR` | `webui/dist` | Static front-end directory the server serves |
|
|
240
268
|
| `TOUCHSTONE_RUN_DIR` | `.run/` in the repository | PID / log / port files |
|
|
241
269
|
| `TS_EXT_DIR` | `extensions` | Root of the built-in asset manifests |
|
package/README.zh.md
CHANGED
|
@@ -30,8 +30,9 @@ Touchstone 把「我们真该多测一点」变成一条持续转动的流水线
|
|
|
30
30
|
一个写者(不会有两个 agent 同时改同一份检出),并且全流程可观测——逐轮实时日志、可渲染的会话窗口、
|
|
31
31
|
以及基于 SSE 的看板。
|
|
32
32
|
|
|
33
|
-
Touchstone
|
|
34
|
-
|
|
33
|
+
Touchstone 是**本地运行**的:独立形态是 Python 站点;插件形态自 2026-10-15 起缺省走包内 Node 后端
|
|
34
|
+
(要回 Python 就在插件 config 里显式写 `backend: python`)。两种形态都是 SQLite + 文件系统工作区,
|
|
35
|
+
除了你的 `dsh` 宿主发起的模型调用之外,数据不出本机。
|
|
35
36
|
|
|
36
37
|
## 主要特性
|
|
37
38
|
|
|
@@ -50,8 +51,8 @@ Touchstone 是**本地运行**的:Python 站点 + SQLite + 文件系统工作
|
|
|
50
51
|
- **内置压测**。压测任务先让 agent 产出场景与脚本,之后由平台自己发压,一个任务可跑 N 次,每次都有
|
|
51
52
|
指标、图表与 Markdown/JSON 报告。
|
|
52
53
|
- **可选集成**。飞书(Lark)推送通知与卡片双向作答;案例库的可选语义检索(RAG)。
|
|
53
|
-
-
|
|
54
|
-
Linux 与 Windows 均可运行。
|
|
54
|
+
- **自包含技术栈**。后端:包内 Node 后端(插件形态缺省,需 Node 22.19+)或 Python 标准库 + 4 个小依赖
|
|
55
|
+
(独立形态 / `backend: python`);前端:React + Vite;存储:SQLite + 普通文件。Linux 与 Windows 均可运行。
|
|
55
56
|
|
|
56
57
|
## 界面截图
|
|
57
58
|
|
|
@@ -123,8 +124,8 @@ flowchart LR
|
|
|
123
124
|
|
|
124
125
|
| 依赖 | 版本 | 用途 |
|
|
125
126
|
|------|------|------|
|
|
126
|
-
|
|
|
127
|
-
|
|
|
127
|
+
| Node.js | 22.19+ 或 24+ | 运行 `dsh` 宿主**与插件形态的后端**(缺省;仅构建前端时 Node 18+ 即可) |
|
|
128
|
+
| Python | 3.12+ | 独立形态(`./touchstone.sh start`)与插件形态的 `backend: python`(实测 3.12) |
|
|
128
129
|
| DeepSeek Harness(`dsh`) | 当前版本 | agent 执行——插件形态必需 |
|
|
129
130
|
|
|
130
131
|
### 1. 克隆并安装
|
|
@@ -132,7 +133,7 @@ flowchart LR
|
|
|
132
133
|
```bash
|
|
133
134
|
git clone https://github.com/gavinc-cn/touchstone-dsh.git
|
|
134
135
|
cd touchstone-dsh
|
|
135
|
-
python3 -m pip install -r requirements.txt
|
|
136
|
+
python3 -m pip install -r requirements.txt # 独立形态 / 插件形态配了 backend: python 才需要
|
|
136
137
|
```
|
|
137
138
|
|
|
138
139
|
### 2. 启动站点(独立形态)
|
|
@@ -174,13 +175,27 @@ node ~/.dsh/profiles/web/node_modules/@gavinc-cn/touchstone-dsh/dsh-plugin/scrip
|
|
|
174
175
|
# 4) 重启 dsh web,打开 http://127.0.0.1:3080,用侧栏里的 Touchstone 入口(Alt+T 开关面板)
|
|
175
176
|
```
|
|
176
177
|
|
|
177
|
-
|
|
178
|
+
**插件形态缺省不需要 Python。** 自 2026-10-15 起薄壳拉起的是包内 Node 后端(先探宿主 Node 的
|
|
179
|
+
`node:sqlite`,过旧则给提示页并列出两条出路)。只有用 Python 后端时才需要 Python —— 即独立形态,
|
|
180
|
+
或在插件 config 里显式写 `backend: python`(观测期的一行回滚;**删掉该键不再有任何回退效果**,
|
|
181
|
+
缺省就是 `node`)。走 Python 后端时,平台自身的依赖走 pip,npm 不管:
|
|
178
182
|
|
|
179
183
|
```bash
|
|
180
184
|
pip install -r ~/.dsh/profiles/web/node_modules/@gavinc-cn/touchstone-dsh/requirements.txt
|
|
185
|
+
# Windows 用 python -m pip install ...(官方安装器只装 python.exe,没有 python3)
|
|
181
186
|
```
|
|
182
187
|
|
|
183
|
-
|
|
188
|
+
配了 Python 后端且未指定解释器时,插件自己探——先试 `python`、再试 `python3`(与 `touchstone.cmd`
|
|
189
|
+
同序),用第一个真能跑起来的那个。如果探到的不是你装依赖的那支,缺依赖提示页里给的就是**它探到的那支**
|
|
190
|
+
的解释器命令;想指定别支,在 profile patch 里写 `pythonPath`:
|
|
191
|
+
|
|
192
|
+
```yaml
|
|
193
|
+
- id: touchstone-dsh
|
|
194
|
+
config: { backend: python, pythonPath: /path/to/python }
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
**不需要配 `repoDir`**:包里带着 `server.py`、`backend/`(Node 后端)与 `webui/dist-plugin`,
|
|
198
|
+
插件缺省就用包自身目录。
|
|
184
199
|
|
|
185
200
|
**方式 B —— 从检出安装(开发用):**
|
|
186
201
|
|
|
@@ -192,7 +207,7 @@ cd touchstone-dsh
|
|
|
192
207
|
安装脚本会打印**可选**的 profile 覆盖(指向本机工作副本、或指定解释器);不写就用包自身目录:
|
|
193
208
|
|
|
194
209
|
```yaml
|
|
195
|
-
- id: touchstone
|
|
210
|
+
- id: touchstone-dsh
|
|
196
211
|
config: { repoDir: /path/to/touchstone-dsh, pythonPath: /path/to/python3 }
|
|
197
212
|
```
|
|
198
213
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 请求访问门禁(`server.py` 里挂在 Handler 上的那几道**纯判据**,M4.5)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么单独一个文件:`server.js` 是入口脚本——模块顶层就 `await main()`(进程契约要求
|
|
5
|
+
* 「被 import 即启动」不成立,而 Node 22.19 没有 `import.meta.main`,用它做守卫会在旧版
|
|
6
|
+
* 上静默不启动)。因此门禁判据留在 `server.js` 里就**不可被单测 import**,只能靠实例级
|
|
7
|
+
* 对拍覆盖。抽成纯函数(零 IO、零 import)后,`backend/test/write-base.test.mjs` 能逐条
|
|
8
|
+
* 钉死边界,而 `server.js` 只负责「在正确的时机调它」。
|
|
9
|
+
*
|
|
10
|
+
* 与 Python 的对齐点(`server.py`):
|
|
11
|
+
* - `MUST_CHANGE_PW_ALLOW`(487):白名单三端点;
|
|
12
|
+
* - `_must_change_pw_blocked`(5203):`must_change_pw` 为假 ⇒ 放行;信任头 ⇒ 放行;
|
|
13
|
+
* 在白名单里 ⇒ 放行;否则 403 `{"error":"请先修改初始口令"}`;
|
|
14
|
+
* - **`do_GET`(1023) 与 `do_POST`(1040) 都调它**,且都在各自的 `if path.startswith("/api/")`
|
|
15
|
+
* 之内;`do_PATCH`(1227) / `do_DELETE`(1266) 没有这道闸。
|
|
16
|
+
* ⚠ 2026-10-14 订正:本文件此前写的是「只有 `do_POST`(1040) 调它」,**与源码不符**——
|
|
17
|
+
* `server.js` 曾照这句错注释实现,于是持一次性口令的账号在 Node 侧能 GET 全部 `/api/*`
|
|
18
|
+
* 读端点(oracle 一律 403)。M7-R3 车道用隔离实例真跑 `server.py` 量出来的。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** 强制改密闸白名单(`server.py:MUST_CHANGE_PW_ALLOW`):持一次性口令的账号只能走这三个端点 */
|
|
22
|
+
export const MUST_CHANGE_PW_ALLOW = Object.freeze([
|
|
23
|
+
'/api/auth/me', '/api/auth/change_password', '/api/auth/logout',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* `server.py:_must_change_pw_blocked` 的纯判据。
|
|
28
|
+
*
|
|
29
|
+
* 返回 `true` = 调用方应回 **403 紧凑体** `{"error":"请先修改初始口令"}`。
|
|
30
|
+
*
|
|
31
|
+
* `user` 为空(未登录)不算「被闸挡住」——那种情况该走 401,且 Python 里
|
|
32
|
+
* `if not user or not user["must_change_pw"]: return False` 也是放行(401 由更早的检查发)。
|
|
33
|
+
*
|
|
34
|
+
* @param {object|null|undefined} user 当前用户行(`must_change_pw` 为 0/1 或布尔)
|
|
35
|
+
* @param {string} path 不含查询串的请求路径
|
|
36
|
+
* @param {{trusted?: boolean}} [options] `trusted` = 反代注入免登信任头(`_trusted_admin_request`)
|
|
37
|
+
* @returns {boolean}
|
|
38
|
+
*/
|
|
39
|
+
export function mustChangePwBlocked(user, path, { trusted = false } = {}) {
|
|
40
|
+
if (!user || !user.must_change_pw) return false;
|
|
41
|
+
if (trusted) return false;
|
|
42
|
+
if (MUST_CHANGE_PW_ALLOW.includes(path)) return false;
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agents.py` 的 Node 对应物(M3 交付归族读口;M6-2 补齐扫描口)。
|
|
3
|
+
*
|
|
4
|
+
* M3(读路径)里几乎所有端点都要按项目的 `agent_path` 归族(`_api_get_board` 下发
|
|
5
|
+
* family/capabilities、`_api_board_session_messages` 归一 sessparse 解析族、
|
|
6
|
+
* `board.web_busy_map` 判「是不是 dsh 族」…),而归族判据是纯字符串规则、零 IO,故先落。
|
|
7
|
+
* M6-2 把模块的另一大半(`scan_agents` / `scan_skills` / `_probe_version` /
|
|
8
|
+
* `_search_extra` / `_parse_skill_md` / `_scan_skill_dir` 与四张常量表)按 `agents.py` 补齐。
|
|
9
|
+
*
|
|
10
|
+
* 归族返回值与 Python 逐字一致(`"dsh_plugin"` / `"retired"`),判据见 `agents.py:134-162`:
|
|
11
|
+
* ① `dsh-plugin:` 前缀(必须先于 basename 判——它不是有效可执行路径);
|
|
12
|
+
* ② `kimi-web:` / `opencode-web:` 两个旧虚拟前缀;
|
|
13
|
+
* ③ basename 含 kimi/opencode/claude/hermes/deepseek 的旧 CLI 族;
|
|
14
|
+
* ④ basename == `dsh`(裸 dsh CLI,`--profile headless` 形态已退场);
|
|
15
|
+
* ⑤ **其余一律 `dsh_plugin`**(空 `agent_path` 与未知可执行名的默认族)。
|
|
16
|
+
*
|
|
17
|
+
* 第 ⑤ 条是最容易写反的一条:默认族不是「未知就报错」,而是「收敛到唯一产品入口」。
|
|
18
|
+
*/
|
|
19
|
+
import { spawnSync } from 'node:child_process';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import os from 'node:os';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
|
|
24
|
+
/** dsh 插件驱动前缀(`agents.py:DSH_PLUGIN_PREFIX`):后跟任意提示串,平台不使用 */
|
|
25
|
+
export const DSH_PLUGIN_PREFIX = 'dsh-plugin:';
|
|
26
|
+
|
|
27
|
+
/** 退场族标记(`agents.py:RETIRED_FAMILY`):起跑处据此返回 `RETIRED_MSG` */
|
|
28
|
+
export const RETIRED_FAMILY = 'retired';
|
|
29
|
+
|
|
30
|
+
/** 退场族提示文案(`agents.py:RETIRED_MSG`,逐字照抄——它直接展示给用户) */
|
|
31
|
+
export const RETIRED_MSG = '该智能体族已下线,请在项目设置里改绑 dsh 插件';
|
|
32
|
+
|
|
33
|
+
/** 「插件·进程内」条目展示名(`agents.py:PLUGIN_ENTRY_NAME`,M6 的 scan 会用) */
|
|
34
|
+
export const PLUGIN_ENTRY_NAME = 'dsh(插件·进程内)';
|
|
35
|
+
|
|
36
|
+
/** 退场族的旧虚拟条目前缀(`agents.py:LEGACY_WEB_PREFIXES`) */
|
|
37
|
+
export const LEGACY_WEB_PREFIXES = ['kimi-web:', 'opencode-web:'];
|
|
38
|
+
|
|
39
|
+
/** 旧 CLI 族可执行名关键词(`agents.py:152` 的判定同源) */
|
|
40
|
+
const RETIRED_NAME_HINTS = ['kimi', 'opencode', 'claude', 'hermes', 'deepseek'];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 归族:只可能返回 `"dsh_plugin"`(可跑)或 `RETIRED_FAMILY`(退场族)。
|
|
44
|
+
*
|
|
45
|
+
* Python 用 `ap.strip()` 后判前缀、再用 `os.path.basename(ap).lower()` 判名字;
|
|
46
|
+
* Node 侧 `path.basename` 在 POSIX 上等价(Windows 反斜杠路径在 Linux 版上本来就
|
|
47
|
+
* 不是有效路径,与 Python 同——两版都跑在同一个平台上,不比跨平台行为)。
|
|
48
|
+
*/
|
|
49
|
+
export function agentFamily(agentPath) {
|
|
50
|
+
const ap = String(agentPath ?? '').trim();
|
|
51
|
+
if (ap.startsWith(DSH_PLUGIN_PREFIX)) return 'dsh_plugin';
|
|
52
|
+
if (LEGACY_WEB_PREFIXES.some((prefix) => ap.startsWith(prefix))) return RETIRED_FAMILY;
|
|
53
|
+
const name = path.basename(ap).toLowerCase();
|
|
54
|
+
if (RETIRED_NAME_HINTS.some((hint) => name.includes(hint))) return RETIRED_FAMILY;
|
|
55
|
+
if (name === 'dsh') return RETIRED_FAMILY; // 裸 dsh CLI(headless 形态)已退场
|
|
56
|
+
return 'dsh_plugin'; // 空串/未知名的默认族(见文件头 ⑤)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
60
|
+
// M6-2:扫描口(`agents.py:45-247`)
|
|
61
|
+
//
|
|
62
|
+
// 与 Python 的形态差异(两条,都是「Node 没有 stdlib 对应物」):
|
|
63
|
+
// ① `shutil.which` → 本地 `whichSync`:POSIX 语义(名字含 `/` 直接判,否则遍历 `PATH`
|
|
64
|
+
// 各段取第一个「文件且可执行」者)。Windows 的 PATHEXT 分支不在本轮范围(本机 POSIX)。
|
|
65
|
+
// ② `glob.glob` → 本地 `globDirs`:只支持本文件用到的那一种模式(`<base>/*/<suffix>`,
|
|
66
|
+
// 单个 `*` 整段通配),按 `sorted()` 返回——与 `sorted(glob.glob(pat))` 同序。
|
|
67
|
+
// 另注:`_probe_version` 的 `text[0][:120]` 在 Python 按**码点**切,这里用 `[...str]` 同口径
|
|
68
|
+
// (`String.prototype.slice` 会切在 UTF-16 码元上)。
|
|
69
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
70
|
+
|
|
71
|
+
/** 支持的 agent 清单(`agents.py:AGENT_DEFS`;展示名仅作文档,实际用于定位 dsh 可执行) */
|
|
72
|
+
export const AGENT_DEFS = [['dsh(DeepSeek Harness)', ['dsh']]];
|
|
73
|
+
|
|
74
|
+
/** PATH 之外常见的安装目录(`agents.py:EXTRA_DIRS`,顺序即查找顺序) */
|
|
75
|
+
export const EXTRA_DIRS = [
|
|
76
|
+
'/usr/local/bin',
|
|
77
|
+
path.join(os.homedir(), '.local/bin'),
|
|
78
|
+
'/bin/versions/node',
|
|
79
|
+
path.join(os.homedir(), '.npm-global/bin'),
|
|
80
|
+
];
|
|
81
|
+
if (process.platform === 'win32' && process.env.APPDATA) {
|
|
82
|
+
EXTRA_DIRS.push(path.join(process.env.APPDATA, 'npm'));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 递归进 versions 下各 node 版本的 bin/(`agents.py:VERSIONS_BIN_GLOB`) */
|
|
86
|
+
export const VERSIONS_BIN_GLOB = '/bin/versions/node';
|
|
87
|
+
|
|
88
|
+
/** conda/mamba 各虚拟环境的 bin 目录(`agents.py:CONDA_ENV_BIN_GLOBS`,glob 模式) */
|
|
89
|
+
export const CONDA_ENV_BIN_GLOBS = [
|
|
90
|
+
'/opt/miniconda3/envs/*/bin',
|
|
91
|
+
'/opt/anaconda3/envs/*/bin',
|
|
92
|
+
path.join(os.homedir(), 'miniconda3/envs/*/bin'),
|
|
93
|
+
path.join(os.homedir(), 'anaconda3/envs/*/bin'),
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
/** skill 目录约定(`agents.py:SKILL_DIRS_BY_FAMILY`):族 → [用户级元组, 项目级元组] */
|
|
97
|
+
export const SKILL_DIRS_BY_FAMILY = {
|
|
98
|
+
dsh_plugin: [['~/.dsh/skills', '~/.agents/skills'], ['.dsh/skills', '.agents/skills']],
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** SKILL.md 相对扫描根的最大目录深度(`agents.py:SKILL_WALK_MAX_DEPTH`) */
|
|
102
|
+
export const SKILL_WALK_MAX_DEPTH = 2;
|
|
103
|
+
|
|
104
|
+
/** `_SKILL_FRONT_RE`:`\A---\s*\n(.*?)\n---`(DOTALL) */
|
|
105
|
+
const SKILL_FRONT_RE = /^---\s*\n([\s\S]*?)\n---/;
|
|
106
|
+
/** `_SKILL_NAME_RE` / `_SKILL_DESC_RE`:多行模式下 `^name:` / `^description:`(行首锚定) */
|
|
107
|
+
const SKILL_NAME_RE = /^name:[ \t]*(.+)$/m;
|
|
108
|
+
const SKILL_DESC_RE = /^description:[ \t]*(.+)$/m;
|
|
109
|
+
|
|
110
|
+
/** description 截断长度(`agents.py:_SKILL_DESC_LIMIT`) */
|
|
111
|
+
export const SKILL_DESC_LIMIT = 200;
|
|
112
|
+
|
|
113
|
+
/** 文本拆行(照 Python `str.splitlines()` 的边界集合) */
|
|
114
|
+
function splitLines(text) {
|
|
115
|
+
return String(text ?? '').split(/\r\n|\r|\n|\u2028|\u2029/);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 文件是否「存在且可执行」(Python `os.path.isfile(p) and os.access(p, os.X_OK)`) */
|
|
119
|
+
function isExecutableFile(p) {
|
|
120
|
+
try {
|
|
121
|
+
const st = fs.statSync(p);
|
|
122
|
+
if (!st.isFile()) return false;
|
|
123
|
+
fs.accessSync(p, fs.constants.X_OK);
|
|
124
|
+
return true;
|
|
125
|
+
} catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** `os.path.isdir`(不存在/权限不足一律 false) */
|
|
131
|
+
function isDir(p) {
|
|
132
|
+
try {
|
|
133
|
+
return fs.statSync(p).isDirectory();
|
|
134
|
+
} catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** `os.path.expanduser('~/x')`:只处理开头的 `~`(POSIX 下用 HOME) */
|
|
140
|
+
function expandUser(p) {
|
|
141
|
+
const s = String(p ?? '');
|
|
142
|
+
if (s === '~') return os.homedir();
|
|
143
|
+
if (s.startsWith('~/')) return path.join(os.homedir(), s.slice(2));
|
|
144
|
+
return s;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** `shutil.which` 的 POSIX 子集(见文件头差异①) */
|
|
148
|
+
export function whichSync(name) {
|
|
149
|
+
const n = String(name ?? '');
|
|
150
|
+
if (!n) return null;
|
|
151
|
+
if (n.includes('/')) return isExecutableFile(n) ? n : null;
|
|
152
|
+
for (const dir of String(process.env.PATH ?? '').split(path.delimiter)) {
|
|
153
|
+
if (!dir) continue;
|
|
154
|
+
const candidate = path.join(dir, n);
|
|
155
|
+
if (isExecutableFile(candidate)) return candidate;
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** `glob.glob` 的单 `*` 整段通配子集(见文件头差异②),返回 `sorted()` 序 */
|
|
161
|
+
function globDirs(pattern) {
|
|
162
|
+
const star = pattern.indexOf('*');
|
|
163
|
+
if (star < 0) return [pattern];
|
|
164
|
+
const base = pattern.slice(0, pattern.lastIndexOf('/', star));
|
|
165
|
+
const rest = pattern.slice(pattern.indexOf('/', star));
|
|
166
|
+
let names;
|
|
167
|
+
try {
|
|
168
|
+
names = fs.readdirSync(base);
|
|
169
|
+
} catch {
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
return names.slice().sort().map((n) => `${base}/${n}${rest}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* `agents._search_extra`:在扩展目录中查找 name 可执行文件,返回路径或 null。
|
|
177
|
+
*
|
|
178
|
+
* 命中判据是「文件 + 可执行位」(`agents.py:82/88`);`VERSIONS_BIN_GLOB` 那一项要**再下钻
|
|
179
|
+
* 一层**到 `<d>/<版本>/bin/<name>`;conda 那组是 glob 展开后逐个匹配。
|
|
180
|
+
*/
|
|
181
|
+
export function searchExtra(name) {
|
|
182
|
+
const candidates = [];
|
|
183
|
+
for (const d of EXTRA_DIRS) {
|
|
184
|
+
if (!isDir(d)) continue;
|
|
185
|
+
if (d === VERSIONS_BIN_GLOB) {
|
|
186
|
+
let versions;
|
|
187
|
+
try {
|
|
188
|
+
versions = fs.readdirSync(d);
|
|
189
|
+
} catch {
|
|
190
|
+
versions = [];
|
|
191
|
+
}
|
|
192
|
+
for (const ver of versions) candidates.push(path.join(d, ver, 'bin', name));
|
|
193
|
+
} else {
|
|
194
|
+
candidates.push(path.join(d, name));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
for (const p of candidates) {
|
|
198
|
+
if (isExecutableFile(p)) return p;
|
|
199
|
+
}
|
|
200
|
+
for (const pat of CONDA_ENV_BIN_GLOBS) {
|
|
201
|
+
for (const bindir of globDirs(pat)) {
|
|
202
|
+
const p = path.join(bindir, name);
|
|
203
|
+
if (isExecutableFile(p)) return p;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* `agents._probe_version`:跑 `--version`(2s 超时),失败返回空串。
|
|
211
|
+
*
|
|
212
|
+
* Python 的 `except (OSError, TimeoutExpired)` 对应 Node 的 `error`(ENOENT/EACCES)与
|
|
213
|
+
* 「被信号杀死」(spawnSync 超时 ⇒ `status === null`);**非 0 退出不算失败**
|
|
214
|
+
* (Python `subprocess.run` 同样不抛),照旧读它写出来的 stdout/stderr,
|
|
215
|
+
* 取首行(`splitlines()` 口径)并按码点截 120。
|
|
216
|
+
*/
|
|
217
|
+
export function probeVersion(exePath) {
|
|
218
|
+
const p = spawnSync(exePath, ['--version'], {
|
|
219
|
+
encoding: 'utf8',
|
|
220
|
+
timeout: 2000,
|
|
221
|
+
cwd: os.tmpdir(),
|
|
222
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
223
|
+
});
|
|
224
|
+
if (p.error || p.status === null) return '';
|
|
225
|
+
const text = splitLines(String(p.stdout || p.stderr || '').trim());
|
|
226
|
+
return text.length ? [...text[0]].slice(0, 120).join('') : '';
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* `agents.scan_agents`:扫描本机 dsh 可执行,返回**产品入口单条目**(恒 1 条)。
|
|
231
|
+
*
|
|
232
|
+
* 未找到时同样返回一条 `found=false`(前端显示「未找到」),保持「下拉里有且只有这一条」
|
|
233
|
+
* 的读侧不变量。**裸 dsh CLI 行不返回**(它属退场族,列出来是误选陷阱)。
|
|
234
|
+
*/
|
|
235
|
+
export function scanAgents() {
|
|
236
|
+
let found = '';
|
|
237
|
+
for (const [, bins] of AGENT_DEFS) {
|
|
238
|
+
for (const b of bins) {
|
|
239
|
+
found = whichSync(b) || searchExtra(b) || '';
|
|
240
|
+
if (found) break;
|
|
241
|
+
}
|
|
242
|
+
if (found) break;
|
|
243
|
+
}
|
|
244
|
+
return [{
|
|
245
|
+
name: PLUGIN_ENTRY_NAME,
|
|
246
|
+
path: found ? DSH_PLUGIN_PREFIX + found : '',
|
|
247
|
+
version: found ? probeVersion(found) : '',
|
|
248
|
+
found: Boolean(found),
|
|
249
|
+
}];
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* `agents._parse_skill_md`:解析 SKILL.md 的 frontmatter,返回 `[name, description]`。
|
|
254
|
+
*
|
|
255
|
+
* 只读前 **4096 字节**(照 `f.read(4096)`,按 UTF-8 解码后的字符数);读失败返回 null。
|
|
256
|
+
* name 缺省用所在目录名;description 缺省空串并截断到 200(Python `[:200]` 按码点)。
|
|
257
|
+
*/
|
|
258
|
+
export function parseSkillMd(filePath, fallbackName) {
|
|
259
|
+
let head;
|
|
260
|
+
try {
|
|
261
|
+
const fd = fs.openSync(filePath, 'r');
|
|
262
|
+
try {
|
|
263
|
+
const buf = Buffer.alloc(4096);
|
|
264
|
+
const read = fs.readSync(fd, buf, 0, 4096, 0);
|
|
265
|
+
head = new TextDecoder('utf-8').decode(buf.subarray(0, read));
|
|
266
|
+
} finally {
|
|
267
|
+
fs.closeSync(fd);
|
|
268
|
+
}
|
|
269
|
+
} catch {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
const m = SKILL_FRONT_RE.exec(head);
|
|
273
|
+
const body = m ? m[1] : head; // 无 frontmatter 时退化为头部全文(容错)
|
|
274
|
+
const nm = SKILL_NAME_RE.exec(body);
|
|
275
|
+
const dm = SKILL_DESC_RE.exec(body);
|
|
276
|
+
const name = nm ? nm[1].trim() : fallbackName;
|
|
277
|
+
const desc = dm ? [...dm[1].trim()].slice(0, SKILL_DESC_LIMIT).join('') : '';
|
|
278
|
+
return [name, desc];
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* `agents._scan_skill_dir`:扫一个 skill 根(深度受限),返回 `[{name,description,source}]`。
|
|
283
|
+
*
|
|
284
|
+
* 深度按「相对根的目录层数」算:只在 `rel < SKILL_WALK_MAX_DEPTH` 时继续下钻,
|
|
285
|
+
* 故 `SKILL.md` 最深允许出现在第 2 层(`<类>/<名>/SKILL.md`)。命中目录先收自身再下钻,
|
|
286
|
+
* 与 `os.walk` 的自顶向下序一致。
|
|
287
|
+
*/
|
|
288
|
+
export function scanSkillDir(root, source) {
|
|
289
|
+
const skills = [];
|
|
290
|
+
const base = String(root ?? '').replace(/[/\\]+$/, '');
|
|
291
|
+
if (!base || !isDir(base)) return skills;
|
|
292
|
+
const baseDepth = base.split(path.sep).length - 1;
|
|
293
|
+
const walk = (dir) => {
|
|
294
|
+
let entries;
|
|
295
|
+
try {
|
|
296
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
297
|
+
} catch {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (entries.some((e) => e.name === 'SKILL.md')) {
|
|
301
|
+
const parsed = parseSkillMd(path.join(dir, 'SKILL.md'),
|
|
302
|
+
path.basename(dir.replace(/[/\\]+$/, '')));
|
|
303
|
+
if (parsed) skills.push({ name: parsed[0], description: parsed[1], source });
|
|
304
|
+
}
|
|
305
|
+
const rel = dir.split(path.sep).length - 1 - baseDepth;
|
|
306
|
+
if (rel >= SKILL_WALK_MAX_DEPTH) return;
|
|
307
|
+
for (const ent of entries) {
|
|
308
|
+
if (ent.isDirectory()) walk(path.join(dir, ent.name));
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
walk(base);
|
|
312
|
+
return skills;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* `agents.scan_skills`:列出 agent 可用的 skill(用户级 + 项目级),按 name 排序。
|
|
317
|
+
*
|
|
318
|
+
* 同名时项目级**覆盖**用户级;用户级内部先命中者保留(`setdefault` 语义);`agent_path`
|
|
319
|
+
* 为空返回空列表;无目录约定的族返回空。
|
|
320
|
+
*/
|
|
321
|
+
export function scanSkills(agentPath, projectDir = '') {
|
|
322
|
+
const ap = String(agentPath ?? '').trim();
|
|
323
|
+
if (!ap) return [];
|
|
324
|
+
const family = agentFamily(ap);
|
|
325
|
+
const [userDirs, projDirs] = SKILL_DIRS_BY_FAMILY[family] || [[], []];
|
|
326
|
+
const found = new Map();
|
|
327
|
+
for (const d of userDirs) {
|
|
328
|
+
for (const s of scanSkillDir(expandUser(d), 'user')) {
|
|
329
|
+
if (!found.has(s.name)) found.set(s.name, s);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const pd = String(projectDir ?? '').trim();
|
|
333
|
+
if (pd) {
|
|
334
|
+
for (const d of projDirs) {
|
|
335
|
+
for (const s of scanSkillDir(path.join(pd, d), 'project')) {
|
|
336
|
+
found.set(s.name, s); // 项目级覆盖用户级
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return [...found.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
341
|
+
}
|