dsh-semble 2.0.0 → 2.1.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 CHANGED
@@ -24,6 +24,9 @@ agent decides when the index is worth a call.
24
24
  | `mcp__semble__search` | a sentence describing the code or where it lives | ranked `file:line` hits with snippets |
25
25
  | `mcp__semble__find_related` | one location you already know | its neighbours: other implementations, callers, tests |
26
26
 
27
+ The bundle also registers one slash command, `/semble`, for the two reporting subcommands that MCP
28
+ does not expose: `/semble savings` and `/semble file-types`. See [Commands](#commands).
29
+
27
30
  A typical exchange:
28
31
 
29
32
  > **You:** Where does DSH decide that a plugin is "loaded"?
@@ -54,7 +57,7 @@ than "the best answer".
54
57
  dsh-semble
55
58
  ```
56
59
 
57
- Pin a version with `dsh-semble@2.0.0`. The settings page is the only supported route for the
60
+ Pin a version with `dsh-semble@2.1.0`. The settings page is the only supported route for the
58
61
  `desktop` profile — the CLI refuses it because the Electron app owns that profile's `node_modules`
59
62
  and lockfile.
60
63
 
@@ -113,11 +116,83 @@ Edit the `semble-mcp` row in **Settings → Plugins**:
113
116
  | `args` | `--from 'semble[mcp]' semble` | append `--content all` to index docs and config too |
114
117
  | `args` (version) | floating | `semble[mcp]==X.Y.Z` pins a release |
115
118
  | `env` | empty | `HF_ENDPOINT` on restricted networks, `SEMBLE_CACHE_LOCATION` to move the index |
119
+ | `toolCallTimeoutMs` | `300000` | the first call builds the index and downloads the model; lower it to fail faster |
116
120
  | `cwd` | inherited | keep unset; per-call `repo` values should be absolute paths |
117
121
  | `command` | `uvx` | point it at `uvx.exe` if only a `.cmd` shim is installed |
118
122
 
119
123
  Changing settings restarts the MCP connection; disk index caches survive.
120
124
 
125
+ ## Tuning
126
+
127
+ Semble reads its configuration from the environment of the process that runs it. For the MCP server,
128
+ put these in the `env` field of the `semble-mcp` row. `/semble` spawns a separate process and inherits
129
+ the host environment instead, so export a value in both places when a report must agree with search —
130
+ `SEMBLE_CACHE_LOCATION` is the one that matters.
131
+
132
+ | Variable | Default | Effect |
133
+ | --- | --- | --- |
134
+ | `SEMBLE_INDEX_WORKERS` | `4` | worker processes that chunk a repository during the first index; `0` chunks in-process. Raise it on a many-core machine, lower it to stop a background search from saturating the CPU |
135
+ | `SEMBLE_MCP_IDLE_TIMEOUT` | `0` (never) | seconds an idle repository index stays in the in-memory cache; with the 10-index cap, `1800` releases repositories you have moved on from |
136
+ | `SEMBLE_MODEL_NAME` | `minishlab/potion-code-16M-v2` | alternative Model2Vec embedding model, as a local path or a Hugging Face repository |
137
+ | `SEMBLE_MAX_FILE_BYTES` | `1000000` | files above this size are skipped; raise it for generated code worth indexing |
138
+ | `SEMBLE_CLONE_TIMEOUT` | `60` | seconds allowed for cloning an `https://` repository |
139
+ | `HF_ENDPOINT` | `https://huggingface.co` | mirror used for the one-time model download |
140
+
141
+ ### Excluding files
142
+
143
+ The indexer honours `.gitignore`, plus a `.sembleignore` at the repository root for files git keeps but
144
+ search should skip: generated clients, fixtures, vendored trees. The syntax is gitignore's, negation
145
+ included:
146
+
147
+ ```gitignore
148
+ # .sembleignore
149
+ packages/client/generated/
150
+ test/fixtures/
151
+ *.min.js
152
+ !*.proto # force-include a file .gitignore excludes
153
+ ```
154
+
155
+ A pattern that only ever needs one repository should stay in that repository's `.sembleignore`; keep
156
+ the plugin settings free of per-repository rules.
157
+
158
+ ### Several repositories
159
+
160
+ `repo` accepts a list, so one call can compare a service with the shared library it depends on:
161
+
162
+ ```json
163
+ { "query": "where is the retry policy applied", "repo": ["D:\\work\\service", "D:\\work\\shared"] }
164
+ ```
165
+
166
+ Git URLs work the same way and are cloned once into the cache. Each repository gets its own index; a
167
+ list costs one first-time index build per repository.
168
+
169
+ ## Commands
170
+
171
+ MCP exposes `search` and `find_related`; Semble's two reporting subcommands only exist in its CLI. The
172
+ bundle registers `/semble` for them:
173
+
174
+ | Command | Prints |
175
+ | --- | --- |
176
+ | `/semble savings` | tokens saved by returning snippets instead of whole files, per period and per call type |
177
+ | `/semble file-types` | the extensions Semble indexes, as JSON grouped by content type |
178
+
179
+ Both run in the host process: nothing is sent to the model, no approval prompt appears, and the output
180
+ is the CLI's own (colour disabled). The same `uvx --from "semble[mcp]" semble` installation is used, so
181
+ `/semble` works exactly where the MCP server works.
182
+
183
+ `savings` reads the cache that the MCP server wrote, so it only counts searches made against the same
184
+ `SEMBLE_CACHE_LOCATION`. To see a summary of everything, and for maintenance, stay in a terminal:
185
+
186
+ ```sh
187
+ uvx --from "semble[mcp]" semble savings # same report, from a shell
188
+ uvx --from "semble[mcp]" semble clear # drop caches
189
+ uvx --from "semble[mcp]" semble clear index # force a rebuild
190
+ ```
191
+
192
+ `/semble` is deliberately not a search shortcut — that is what the MCP tools are for — and it has no
193
+ destructive subcommands, because a slash command cannot ask for confirmation. Type `/semble` alone for
194
+ the usage line.
195
+
121
196
  ## Platform notes
122
197
 
123
198
  The bundle ships no OS-specific path and no region-specific endpoint, so one installation serves all
@@ -144,8 +219,13 @@ DSH loads skills lazily:
144
219
  | Model calls `skill` | the full `skills/semble/SKILL.md` body | ~2 KB, only when used |
145
220
  | You type `/semble-code-search` | same body, injected into that step | manual |
146
221
 
222
+ `/semble-code-search` loads *instructions*: the search itself still runs through the MCP tools. The
223
+ real executable command is `/semble` (see [Commands](#commands)), which runs a CLI report in the host
224
+ and costs no model tokens.
225
+
147
226
  The body never enters the system prompt. To keep the tools and drop the skill, delete the
148
- `ctx.skills.register(...)` call in `lib/index.js`.
227
+ `ctx.skills.register(...)` block in `lib/index.js`; `/semble` keeps working because it is registered
228
+ separately.
149
229
 
150
230
  ## How it works
151
231
 
@@ -167,11 +247,11 @@ the CLI surface would need an edit here.
167
247
  Re-run the install with a newer version, or press 更新 in settings:
168
248
 
169
249
  ```sh
170
- dsh plugin --profile web add dsh-semble@2.0.0
250
+ dsh plugin --profile web add dsh-semble@2.1.0
171
251
  ```
172
252
 
173
253
  Source installs re-run with the new ref, e.g.
174
- `dsh plugin --profile web add github:CodeIntegrity/dsh-semble#v2.0.0`.
254
+ `dsh plugin --profile web add github:CodeIntegrity/dsh-semble#v2.1.0`.
175
255
 
176
256
  ## Uninstall
177
257
 
@@ -195,6 +275,10 @@ The cached index and the HuggingFace model stay on disk and are reused after a r
195
275
  | Tools work, row reads 未加载 | `cordis.patch.yml` lost the anchor row | reinstall this version or newer |
196
276
  | `mcp__semble__*` never appears | `uvx` missing, or the first download failed | run `uvx --from "semble[mcp]" semble --version` and read the error |
197
277
  | First search takes minutes | one-time model download | wait, or set `HF_ENDPOINT` |
278
+ | First search fails on `toolCallTimeoutMs` | index build + model download outran the limit | raise `toolCallTimeoutMs`, or warm the cache once with a CLI search |
279
+ | Search looks stale after a large refactor | the disk index reuses files it believes unchanged | `uvx --from "semble[mcp]" semble clear index`, then search again |
280
+ | `/semble` answers "Could not start the Semble CLI" | `uvx` is not on the host's PATH | install uv, restart DSH |
281
+ | `savings` counts fewer calls than you made | the report reads the cache of the MCP row's `env`, which `/semble` does not inherit | set `SEMBLE_CACHE_LOCATION` in the host environment as well |
198
282
  | `ERR_PNPM_GIT_RESOLVE_FAILED` | no network route to github.com | use a tarball URL or a mirror |
199
283
 
200
284
  ## Development
@@ -204,9 +288,33 @@ git clone https://github.com/CodeIntegrity/dsh-semble
204
288
  dsh plugin --profile web add link:$(pwd) # then edit and restart DSH
205
289
  ```
206
290
 
207
- No build step: `lib/index.js` is plain ESM and `cordis.patch.yml` is read directly. The only runtime
291
+ No build step: `lib/` is plain ESM and `cordis.patch.yml` is read directly. The only runtime
208
292
  dependency is `uv`.
209
293
 
294
+ `npm test` runs the suite with Node's built-in runner. The spawn tests replace `uvx` with the Node
295
+ binary, so they neither download Semble nor touch your cache.
296
+
297
+ ## Publishing
298
+
299
+ Releases are tag-driven by `.github/workflows/publish.yml`:
300
+
301
+ ```sh
302
+ npm version minor # bumps package.json and creates the vX.Y.Z tag
303
+ git push origin main --follow-tags
304
+ ```
305
+
306
+ The workflow runs the tests, refuses to continue when the tag and `package.json` disagree, skips a
307
+ version that is already on the registry, and publishes with npm provenance. It needs one repository
308
+ secret:
309
+
310
+ | Secret | What it is |
311
+ | --- | --- |
312
+ | `NPM_TOKEN` | an npm granular access token with read/write access to `dsh-semble` and 2FA bypass enabled for automation |
313
+
314
+ Provenance requires a public repository; on a private one, drop `--provenance` from the publish step.
315
+ A manual run of the workflow (`workflow_dispatch`) defaults to publishing the current `package.json`
316
+ version; tick *dry_run* to only run `npm pack --dry-run`.
317
+
210
318
  ## License
211
319
 
212
320
  MIT
package/README.zh-CN.md CHANGED
@@ -21,6 +21,9 @@
21
21
  | `mcp__semble__search` | 一句话描述代码做什么、或者它在哪儿 | 按相关度排序的 `file:line` 命中 + 片段 |
22
22
  | `mcp__semble__find_related` | 一个你已经知道的位置 | 它的邻居:其他实现、调用方、测试 |
23
23
 
24
+ 另外还注册了一个 slash command:`/semble savings` 和 `/semble file-types`,用来跑 MCP 没有暴露的
25
+ 两个 CLI 报表,见[命令](#命令)。
26
+
24
27
  一次典型对话:
25
28
 
26
29
  > **你:** DSH 判断「插件已加载」是在哪儿决定的?
@@ -48,7 +51,7 @@
48
51
  dsh-semble
49
52
  ```
50
53
 
51
- 钉住版本写 `dsh-semble@2.0.0`。`desktop` profile 只支持从设置页安装——CLI 会拒绝,
54
+ 钉住版本写 `dsh-semble@2.1.0`。`desktop` profile 只支持从设置页安装——CLI 会拒绝,
52
55
  因为这个 profile 的 `node_modules` 与 lockfile 由 Electron 应用独占。
53
56
 
54
57
  ### 命令行
@@ -105,11 +108,77 @@ curl -LsSf https://astral.sh/uv/install.sh | sh # macOS / Linux
105
108
  | `args` | `--from 'semble[mcp]' semble` | 追加 `--content all` 让文档和配置也进索引 |
106
109
  | `args`(版本) | 浮动 | 改成 `semble[mcp]==X.Y.Z` 钉版本 |
107
110
  | `env` | 空 | 受限网络加 `HF_ENDPOINT`;搬索引加 `SEMBLE_CACHE_LOCATION` |
111
+ | `toolCallTimeoutMs` | `300000` | 首次调用要建索引并下载模型;想更快失败就调小 |
108
112
  | `cwd` | 继承 | 保持不设;每次调用的 `repo` 用绝对路径 |
109
113
  | `command` | `uvx` | 如果只装了 `.cmd` 包装,改指向 `uvx.exe` |
110
114
 
111
115
  改设置会重启 MCP 连接;磁盘上的索引缓存不受影响。
112
116
 
117
+ ## 调优
118
+
119
+ Semble 从**运行它的进程**的环境变量读配置。MCP server 的变量写在 `semble-mcp` 行的 `env` 里。
120
+ `/semble` 是另起进程、继承宿主环境,所以要让报表和检索看到同一份配置时,两处都要设——真正会出问题的
121
+ 只有 `SEMBLE_CACHE_LOCATION`。
122
+
123
+ | 变量 | 默认值 | 作用 |
124
+ | --- | --- | --- |
125
+ | `SEMBLE_INDEX_WORKERS` | `4` | 首次建索引时切块用的工作进程数;`0` 表示在主进程里做。多核机器可调大,不想让后台检索吃满 CPU 就调小 |
126
+ | `SEMBLE_MCP_IDLE_TIMEOUT` | `0`(永不过期) | 空闲索引在内存缓存里保留多少秒;配合 10 个索引的上限,设 `1800` 可以释放已经不看的仓库 |
127
+ | `SEMBLE_MODEL_NAME` | `minishlab/potion-code-16M-v2` | 换一个 Model2Vec 嵌入模型:本地路径或 Hugging Face 仓库 |
128
+ | `SEMBLE_MAX_FILE_BYTES` | `1000000` | 超过这个大小的文件跳过;生成代码也想进索引就调大 |
129
+ | `SEMBLE_CLONE_TIMEOUT` | `60` | clone `https://` 仓库的超时秒数 |
130
+ | `HF_ENDPOINT` | `https://huggingface.co` | 一次性模型下载走的镜像 |
131
+
132
+ ### 排除文件
133
+
134
+ 索引器认 `.gitignore`,另外认仓库根目录的 `.sembleignore`,用来排除「git 要留、但检索不该看」的文件:
135
+ 生成的客户端、fixtures、vendored 目录。语法同 gitignore,支持取反:
136
+
137
+ ```gitignore
138
+ # .sembleignore
139
+ packages/client/generated/
140
+ test/fixtures/
141
+ *.min.js
142
+ !*.proto # 把被 .gitignore 排除的文件强行拉回索引
143
+ ```
144
+
145
+ 只对某一个仓库成立的规则就留在那个仓库的 `.sembleignore` 里,别塞进插件设置。
146
+
147
+ ### 检索多个仓库
148
+
149
+ `repo` 接受数组,所以一次调用就能把服务端和它依赖的公共库放在一起比:
150
+
151
+ ```json
152
+ { "query": "重试策略在哪儿生效", "repo": ["D:\\work\\service", "D:\\work\\shared"] }
153
+ ```
154
+
155
+ git 地址同理,首次会 clone 进缓存。每个仓库各有一份索引;传数组时,每个仓库各付一次首次建索引的成本。
156
+
157
+ ## 命令
158
+
159
+ MCP 只暴露 `search` 与 `find_related`;Semble 的两个报表子命令只存在于 CLI。bundle 为它们注册了
160
+ `/semble`:
161
+
162
+ | 命令 | 输出 |
163
+ | --- | --- |
164
+ | `/semble savings` | 用片段代替整文件省下的 token,按时间区间和调用类型分列 |
165
+ | `/semble file-types` | Semble 会索引的扩展名,按内容类型分组的 JSON |
166
+
167
+ 两者都在宿主进程里跑:不进模型、不弹审批,输出就是 CLI 原文(已关闭颜色)。用的是同一份
168
+ `uvx --from "semble[mcp]" semble` 安装,所以 MCP server 能用的地方它就能用。
169
+
170
+ `savings` 读的是 MCP server 写的缓存,因此只统计同一个 `SEMBLE_CACHE_LOCATION` 下的检索。要看全量汇总
171
+ 或做维护,回终端:
172
+
173
+ ```sh
174
+ uvx --from "semble[mcp]" semble savings # 同一份报表,在 shell 里看
175
+ uvx --from "semble[mcp]" semble clear # 清缓存
176
+ uvx --from "semble[mcp]" semble clear index # 强制重建索引
177
+ ```
178
+
179
+ `/semble` 故意不做检索捷径——那是 MCP 工具的事;也故意不带破坏性子命令,因为 slash command 没法弹确认。
180
+ 只敲 `/semble` 会显示用法。
181
+
113
182
  ## 平台差异
114
183
 
115
184
  bundle 不含任何 OS 专属路径、也不含区域专属 endpoint,一份安装三平台通用。真正不同的只有你敲进去的东西:
@@ -134,8 +203,11 @@ DSH 的 skill 惰性加载:
134
203
  | 模型调 `skill` | 完整 `skills/semble/SKILL.md` 正文 | 约 2 KB,只在实际用到时 |
135
204
  | 你输入 `/semble-code-search` | 同一份正文,注入当步 | 手动 |
136
205
 
206
+ `/semble-code-search` 注入的是**说明**:真正的检索仍然走 MCP 工具。可执行的命令是 `/semble`
207
+ (见[命令](#命令)),它在宿主里跑 CLI 报表,不花模型 token。
208
+
137
209
  正文不进系统提示词。只想保留工具、不要 skill,删掉 `lib/index.js` 里的 `ctx.skills.register(...)`
138
- 调用即可。
210
+ 代码块即可;`/semble` 是单独注册的,不受影响。
139
211
 
140
212
  ## 工作原理
141
213
 
@@ -155,11 +227,11 @@ Semble 本体**不做 vendored 内嵌**。插件只声明如何从 PyPI 拉起
155
227
  用更新的版本重跑安装,或在设置页点「更新」:
156
228
 
157
229
  ```sh
158
- dsh plugin --profile web add dsh-semble@2.0.0
230
+ dsh plugin --profile web add dsh-semble@2.1.0
159
231
  ```
160
232
 
161
233
  源码安装则换 ref 重跑,例如
162
- `dsh plugin --profile web add github:CodeIntegrity/dsh-semble#v2.0.0`。
234
+ `dsh plugin --profile web add github:CodeIntegrity/dsh-semble#v2.1.0`。
163
235
 
164
236
  ## 卸载
165
237
 
@@ -182,6 +254,10 @@ dsh plugin --profile web remove dsh-semble
182
254
  | 工具能用但显示「未加载」 | `cordis.patch.yml` 缺了锚点行 | 重装本版本或更新版本 |
183
255
  | `mcp__semble__*` 一直不出现 | 缺 `uvx`,或首次下载失败 | 手动跑 `uvx --from "semble[mcp]" semble --version` 看报错 |
184
256
  | 首次检索卡几分钟 | 模型一次性下载 | 等待,或设 `HF_ENDPOINT` |
257
+ | 首次检索报 `toolCallTimeoutMs` 超时 | 建索引 + 下载模型超过了上限 | 调大 `toolCallTimeoutMs`,或先用 CLI 检索热身 |
258
+ | 大改之后检索结果偏旧 | 磁盘索引复用了「自认为没变」的文件 | `uvx --from "semble[mcp]" semble clear index` 后再检索 |
259
+ | `/semble` 答「Could not start the Semble CLI」 | 宿主 PATH 上没有 `uvx` | 装 uv,重启 DSH |
260
+ | `savings` 统计的调用次数偏少 | 报表读的是 MCP 行 `env` 里的缓存位置,`/semble` 不会继承 | 在宿主环境里也设一次 `SEMBLE_CACHE_LOCATION` |
185
261
  | 安装时报 `ERR_PNPM_GIT_RESOLVE_FAILED` | 到 github.com 没有网络通路 | 改用 tarball 链接或镜像 |
186
262
 
187
263
  ## 开发
@@ -191,7 +267,29 @@ git clone https://github.com/CodeIntegrity/dsh-semble
191
267
  dsh plugin --profile web add link:$(pwd) # 之后改代码、重启 DSH
192
268
  ```
193
269
 
194
- 没有构建步骤:`lib/index.js` 是纯 ESM,`cordis.patch.yml` 直接读取。唯一运行时依赖是 `uv`。
270
+ 没有构建步骤:`lib/` 是纯 ESM,`cordis.patch.yml` 直接读取。唯一运行时依赖是 `uv`。
271
+
272
+ `npm test` 用 Node 自带的 test runner 跑测试。涉及子进程的用例用 Node 可执行文件代替 `uvx`,
273
+ 所以不会下载 Semble,也不会碰你的缓存。
274
+
275
+ ## 发布
276
+
277
+ 发版由 tag 驱动,见 `.github/workflows/publish.yml`:
278
+
279
+ ```sh
280
+ npm version minor # 改 package.json 并打 vX.Y.Z tag
281
+ git push origin main --follow-tags
282
+ ```
283
+
284
+ 工作流会先跑测试,tag 与 `package.json` 不一致时直接失败,版本已在 registry 上则跳过发布,最后带 npm
285
+ provenance 发布。需要配一个仓库 secret:
286
+
287
+ | Secret | 内容 |
288
+ | --- | --- |
289
+ | `NPM_TOKEN` | npm 细粒度访问令牌,对 `dsh-semble` 有读写权限,并为自动化开启 2FA bypass |
290
+
291
+ provenance 要求仓库公开;私有仓库请在发布步骤去掉 `--provenance`。手动触发(`workflow_dispatch`)
292
+ 默认发布当前 `package.json` 版本;勾上 *dry_run* 只跑 `npm pack --dry-run`。
195
293
 
196
294
  ## 许可
197
295
 
package/cordis.patch.yml CHANGED
@@ -36,6 +36,13 @@
36
36
  - 'semble[mcp]'
37
37
  - semble
38
38
  failOnStartupError: true
39
+ # The first search in a repository also builds that repository's index:
40
+ # chunking runs locally and the embedding model is downloaded once from
41
+ # HuggingFace. That routinely exceeds the MCP client's 60 s default, which
42
+ # would fail the first call even though the server is healthy. Five
43
+ # minutes covers a cold start on a slow link; once an index exists, calls
44
+ # answer in seconds and the higher ceiling costs nothing.
45
+ toolCallTimeoutMs: 300000
39
46
  # No `env` defaults on purpose: the embedding model
40
47
  # (minishlab/potion-code-16M-v2) is fetched from huggingface.co unless the
41
48
  # operator asks otherwise. On networks where huggingface.co is unreachable,
package/lib/cli.js ADDED
@@ -0,0 +1,221 @@
1
+ /**
2
+ * Runner for the Semble command line, used by the `/semble` command.
3
+ *
4
+ * The bundle talks to Semble through MCP for search, but a few reports only
5
+ * exist in the CLI: token savings (`semble savings`) and the indexed file
6
+ * types (`semble file-types`). Those are read-only and cheap, so the command
7
+ * shells out to the same uvx installation the MCP server uses.
8
+ *
9
+ * Output is captured through temporary files rather than pipes: pipes make a
10
+ * sandboxed host refuse the spawn (EPERM on the named pipe), while file
11
+ * descriptors keep `/semble` working wherever the plugin loads, and a file also
12
+ * bounds the capture without stalling the child on a full pipe buffer.
13
+ *
14
+ * Only an allow-list is reachable from the command. Destructive subcommands
15
+ * such as `clear` stay out on purpose: a slash command has no confirmation
16
+ * step, and the cache it would delete is what makes the next search fast.
17
+ */
18
+
19
+ import { spawn } from 'node:child_process'
20
+ import { closeSync, mkdtempSync, openSync, readFileSync, rmSync, statSync } from 'node:fs'
21
+ import { tmpdir } from 'node:os'
22
+ import { join } from 'node:path'
23
+
24
+ /** How the MCP row launches Semble; kept identical so both share one install. */
25
+ export const SEMBLE_UVX_PREFIX = ['--from', 'semble[mcp]', 'semble']
26
+
27
+ /** Bumped from the MCP client's 60 s default: savings only reads a jsonl file. */
28
+ export const DEFAULT_TIMEOUT_MS = 60_000
29
+
30
+ /** Trace reports are tiny; a cap keeps a runaway child from filling the transcript. */
31
+ export const CAPTURE_LIMIT_BYTES = 256 * 1024
32
+
33
+ const TRUNCATED = '\n… output truncated by dsh-semble …\n'
34
+
35
+ /** Subcommands exposed by `/semble`, mapped to the argv handed to Semble. */
36
+ const SUBCOMMANDS = new Map([
37
+ ['savings', ['savings']],
38
+ ['file-types', ['file-types']],
39
+ ])
40
+
41
+ /**
42
+ * Parse the raw text typed after `/semble`.
43
+ *
44
+ * Strict on purpose: an unknown token is reported instead of being forwarded,
45
+ * so the command can never reach a subcommand the allow-list excludes.
46
+ *
47
+ * @param {string | undefined} rawInput
48
+ * @returns {{ kind: 'usage' } | { kind: 'unknown', name: string } | { kind: 'run', argv: string[] }}
49
+ */
50
+ export function parseSembleArgs(rawInput) {
51
+ const input = typeof rawInput === 'string' ? rawInput.trim() : ''
52
+ if (input === '') {
53
+ return { kind: 'usage' }
54
+ }
55
+
56
+ const [name, ...extra] = input.split(/\s+/)
57
+ const argv = SUBCOMMANDS.get(name)
58
+ if (argv === undefined || extra.length > 0) {
59
+ return { kind: 'unknown', name: input }
60
+ }
61
+
62
+ return { kind: 'run', argv: [...argv] }
63
+ }
64
+
65
+ /**
66
+ * Run one Semble CLI invocation and capture its output.
67
+ *
68
+ * Never rejects: every failure mode (missing uvx, timeout, abort, non-zero
69
+ * exit) is reported in the resolved value so the command can render it.
70
+ *
71
+ * @param {string[]} argv
72
+ * @param {{ timeoutMs?: number, signal?: AbortSignal, command?: string, prefix?: string[], env?: Record<string, string> }} [options]
73
+ * @returns {Promise<{ code: number | null, stdout: string, stderr: string, timedOut?: boolean, aborted?: boolean, error?: string }>}
74
+ */
75
+ export function runSemble(argv, options = {}) {
76
+ const {
77
+ timeoutMs = DEFAULT_TIMEOUT_MS,
78
+ signal,
79
+ command = 'uvx',
80
+ prefix = SEMBLE_UVX_PREFIX,
81
+ env,
82
+ } = options
83
+
84
+ if (signal?.aborted) {
85
+ return Promise.resolve({ code: null, stdout: '', stderr: '', aborted: true })
86
+ }
87
+
88
+ let capture
89
+ try {
90
+ capture = openCapture()
91
+ } catch (error) {
92
+ return Promise.resolve({ code: null, stdout: '', stderr: '', error: messageOf(error) })
93
+ }
94
+
95
+ return new Promise((resolve) => {
96
+ let child
97
+ try {
98
+ child = spawn(command, [...prefix, ...argv], {
99
+ // NO_COLOR/FORCE_COLOR keep ANSI escapes out of the transcript; the rest
100
+ // of the environment is inherited so SEMBLE_* tuning applies.
101
+ env: { ...process.env, ...env, NO_COLOR: '1', FORCE_COLOR: '0' },
102
+ stdio: ['ignore', capture.stdoutFd, capture.stderrFd],
103
+ windowsHide: true,
104
+ })
105
+ } catch (error) {
106
+ capture.dispose()
107
+ resolve({ code: null, stdout: '', stderr: '', error: messageOf(error) })
108
+ return
109
+ }
110
+
111
+ let timedOut = false
112
+ let aborted = false
113
+ let settled = false
114
+
115
+ const finish = (result) => {
116
+ if (settled) return
117
+ settled = true
118
+ clearTimeout(timer)
119
+ signal?.removeEventListener?.('abort', onAbort)
120
+ const { stdout, stderr } = capture.collect()
121
+ capture.dispose()
122
+ resolve({ ...result, stdout, stderr })
123
+ }
124
+
125
+ const kill = () => {
126
+ child.kill('SIGTERM')
127
+ // A child that ignores SIGTERM must not outlive the command; the
128
+ // unref'd escalation is dropped once the event loop is done with it.
129
+ setTimeout(() => child.kill('SIGKILL'), 2_000).unref?.()
130
+ }
131
+
132
+ const timer = setTimeout(() => {
133
+ timedOut = true
134
+ kill()
135
+ }, timeoutMs)
136
+
137
+ function onAbort() {
138
+ aborted = true
139
+ kill()
140
+ }
141
+ signal?.addEventListener?.('abort', onAbort, { once: true })
142
+
143
+ child.on('error', (error) => finish({ code: null, error: messageOf(error) }))
144
+ child.on('close', (code, closeSignal) =>
145
+ finish({
146
+ code: typeof code === 'number' ? code : null,
147
+ timedOut,
148
+ aborted,
149
+ ...(closeSignal ? { signal: closeSignal } : {}),
150
+ }),
151
+ )
152
+ })
153
+ }
154
+
155
+ /**
156
+ * A private directory holding the child's two output streams.
157
+ *
158
+ * The descriptors stay open for the child's lifetime and are closed before the
159
+ * files are read, so a child that writes until its last moment (or is killed)
160
+ * still leaves a complete prefix behind.
161
+ */
162
+ function openCapture() {
163
+ const dir = mkdtempSync(join(tmpdir(), 'dsh-semble-'))
164
+ const stdoutPath = join(dir, 'stdout.log')
165
+ const stderrPath = join(dir, 'stderr.log')
166
+
167
+ const stdoutFd = openSync(stdoutPath, 'w')
168
+ let stderrFd
169
+ try {
170
+ stderrFd = openSync(stderrPath, 'w')
171
+ } catch (error) {
172
+ closeSync(stdoutFd)
173
+ rmSync(dir, { recursive: true, force: true })
174
+ throw error
175
+ }
176
+
177
+ return {
178
+ stdoutFd,
179
+ stderrFd,
180
+ collect() {
181
+ closeSync(stdoutFd)
182
+ closeSync(stderrFd)
183
+ return { stdout: readCapped(stdoutPath), stderr: readCapped(stderrPath) }
184
+ },
185
+ dispose() {
186
+ try {
187
+ closeSync(stdoutFd)
188
+ } catch {}
189
+ try {
190
+ closeSync(stderrFd)
191
+ } catch {}
192
+ rmSync(dir, { recursive: true, force: true })
193
+ },
194
+ }
195
+ }
196
+
197
+ /** Read at most CAPTURE_LIMIT_BYTES and mark the cut, so output stays bounded. */
198
+ function readCapped(path) {
199
+ try {
200
+ if (statSync(path).size === 0) return ''
201
+ const text = readFileSync(path, 'utf8')
202
+ return text.length > CAPTURE_LIMIT_BYTES ? text.slice(0, CAPTURE_LIMIT_BYTES) + TRUNCATED : text
203
+ } catch {
204
+ return ''
205
+ }
206
+ }
207
+
208
+ function messageOf(error) {
209
+ return error instanceof Error ? error.message : String(error)
210
+ }
211
+
212
+ /** Text shown for `/semble` with no argument, and after an unknown one. */
213
+ export const SEMBLE_USAGE = [
214
+ 'Semble CLI reports',
215
+ '',
216
+ ' /semble savings tokens saved by snippets instead of whole files',
217
+ ' /semble file-types file extensions Semble indexes, per content type',
218
+ '',
219
+ 'Runs the same uvx installation the MCP server uses. Cache maintenance stays in a',
220
+ 'terminal: uvx --from "semble[mcp]" semble clear --help',
221
+ ].join('\n')
package/lib/index.js CHANGED
@@ -17,6 +17,11 @@
17
17
  * semble/SKILL.md) and is registered as a runtime skill, which keeps plugin
18
18
  * installation a single step: no copying files into ~/.dsh/skills.
19
19
  *
20
+ * 3. Register the `/semble` command, which shells out to the Semble CLI for the
21
+ * two read-only reports MCP does not expose (`savings`, `file-types`). The
22
+ * command runs in the host, so it works wherever the plugin works, and its
23
+ * output never costs a model turn.
24
+ *
20
25
  * The skill is loaded lazily by DSH: the session catalog carries only the name
21
26
  * and description, and the body text reaches the model only when it calls the
22
27
  * `skill` tool. Reading the file here costs one small synchronous read at host
@@ -24,6 +29,8 @@
24
29
  */
25
30
  import { readFileSync } from 'node:fs'
26
31
 
32
+ import { parseSembleArgs, runSemble, SEMBLE_USAGE } from './cli.js'
33
+
27
34
  export const name = 'semble-mcp'
28
35
 
29
36
  export const inject = ['skills']
@@ -48,23 +55,92 @@ function readSkillBody() {
48
55
  }
49
56
  }
50
57
 
51
- /** Apply: provide the loader anchor and register the bundled routing skill. */
58
+ /**
59
+ * Handler for `/semble`.
60
+ *
61
+ * Returns a CommandResult instead of throwing: the command surface renders
62
+ * `{ kind: 'error' }` inline, and a failed uvx run is an ordinary answer here,
63
+ * not a plugin fault.
64
+ */
65
+ export async function handleSembleCommand(invocation) {
66
+ const parsed = parseSembleArgs(invocation.rawInput)
67
+
68
+ if (parsed.kind === 'usage') {
69
+ return { kind: 'success', text: SEMBLE_USAGE }
70
+ }
71
+ if (parsed.kind === 'unknown') {
72
+ return { kind: 'error', text: `Unknown subcommand "${parsed.name}".\n\n${SEMBLE_USAGE}` }
73
+ }
74
+
75
+ const result = await runSemble(parsed.argv, { signal: invocation.signal })
76
+ const output = [result.stdout?.trim(), result.stderr?.trim()].filter(Boolean).join('\n')
77
+
78
+ if (result.error !== undefined) {
79
+ return {
80
+ kind: 'error',
81
+ text: `Could not start the Semble CLI (${result.error}). Install uv first: https://docs.astral.sh/uv/`,
82
+ }
83
+ }
84
+ if (result.aborted === true) {
85
+ return { kind: 'error', text: 'Semble CLI command cancelled.' }
86
+ }
87
+ if (result.timedOut === true) {
88
+ return {
89
+ kind: 'error',
90
+ text: `Semble CLI did not finish within its time limit; the first uvx run may still be downloading.\n\n${output}`.trim(),
91
+ }
92
+ }
93
+ if (result.code !== 0) {
94
+ return {
95
+ kind: 'error',
96
+ text: `semble ${parsed.argv.join(' ')} exited with code ${result.code ?? 'unknown'}.\n\n${output}`.trim(),
97
+ }
98
+ }
99
+
100
+ return { kind: 'success', text: output === '' ? 'Semble CLI returned no output.' : output }
101
+ }
102
+
103
+ /**
104
+ * Register `/semble` once the optional `commands` service is available.
105
+ *
106
+ * A UI-less composition (for example an ACP automation profile) provides no
107
+ * command surface; the MCP tools and the skill must keep working there, so the
108
+ * command is a child plugin rather than a hard dependency.
109
+ */
110
+ function registerSembleReports(ctx) {
111
+ ctx.commands.register({
112
+ name: 'semble',
113
+ description: 'Show Semble CLI reports (token savings, indexed file types)',
114
+ input: { hint: 'savings | file-types' },
115
+ handler: handleSembleCommand,
116
+ })
117
+ }
118
+
119
+ /** Apply: provide the loader anchor, the routing skill, and the CLI reports. */
52
120
  export default function sembleMcpBundle(ctx) {
53
121
  const content = readSkillBody()
54
- if (content === undefined) return
55
- // Registered into this plugin's layer, so teardown follows the fiber and the
56
- // returned disposer needs no bookkeeping here.
57
- ctx.skills.register({
58
- name: 'semble-code-search',
59
- description:
60
- 'Find code by what it does, not by exact text. Use when locating an implementation, a caller, or a related pattern before editing, or when a plain text search returns too much noise.',
61
- whenToUse:
62
- 'before a broad code search in a repository, and when a grep result is too noisy to be useful; gives the query shape, the semantic search workflow, and the cost limits of the Semble MCP tools.',
63
- content,
64
- source: 'bundle:dsh-semble',
65
- resourceBase: { kind: 'opaque', description: 'Exposed as the mcp__semble__search and mcp__semble__find_related MCP tools.' },
66
- invocation: { modelInvocable: true, userInvocable: true },
67
- })
122
+ if (content !== undefined) {
123
+ // Registered into this plugin's layer, so teardown follows the fiber and the
124
+ // returned disposer needs no bookkeeping here.
125
+ ctx.skills.register({
126
+ name: 'semble-code-search',
127
+ description:
128
+ 'Find code by what it does, not by exact text. Use when locating an implementation, a caller, or a related pattern before editing, or when a plain text search returns too much noise.',
129
+ whenToUse:
130
+ 'before a broad code search in a repository, and when a grep result is too noisy to be useful; gives the query shape, the semantic search workflow, and the cost limits of the Semble MCP tools.',
131
+ content,
132
+ source: 'bundle:dsh-semble',
133
+ resourceBase: { kind: 'opaque', description: 'Exposed as the mcp__semble__search and mcp__semble__find_related MCP tools.' },
134
+ invocation: { modelInvocable: true, userInvocable: true },
135
+ })
136
+ }
137
+
138
+ // `ctx.inject` starts a child plugin that loads only when the commands service
139
+ // exists, which keeps UI-less compositions usable while adding `/semble`
140
+ // wherever a command surface is present.
141
+ if (typeof ctx.inject === 'function') {
142
+ ctx.inject(['commands'], registerSembleReports)
143
+ }
68
144
  }
69
145
 
70
146
  /**
package/locale/en.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Semble Code Search (MCP)",
4
- "description": "Exposes the Semble agent code-search engine as native MCP tools (search / find_related). Starts the official Semble MCP server from PyPI via uvx; follows the latest upstream release automatically."
4
+ "description": "Exposes the Semble agent code-search engine as native MCP tools (search / find_related) and adds a /semble command for the CLI's token-savings and file-type reports. Starts the official Semble MCP server from PyPI via uvx; follows the latest upstream release automatically."
5
5
  }
6
6
  }
package/locale/zh.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Semble 代码搜索(MCP)",
4
- "description": "把 Semble 的 Agent 代码检索引擎接入为原生 MCP 工具(search / find_related)。通过 uvx 从 PyPI 启动官方 Semble MCP 服务器,自动跟随上游最新版本。"
4
+ "description": "把 Semble 的 Agent 代码检索引擎接入为原生 MCP 工具(search / find_related),并新增 /semble 命令输出 CLI 的 token 节约与文件类型报表。通过 uvx 从 PyPI 启动官方 Semble MCP 服务器,自动跟随上游最新版本。"
5
5
  }
6
6
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-semble",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "type": "module",
5
5
  "main": "lib/index.js",
6
6
  "description": "Semble 语义代码搜索(semantic code search)MCP bundle:安装即用,无需客户端配置。Semble semantic code search for DeepSeek Harness via MCP (search / find_related).",
@@ -44,6 +44,9 @@
44
44
  "README.md",
45
45
  "README.zh-CN.md"
46
46
  ],
47
+ "scripts": {
48
+ "test": "node --test"
49
+ },
47
50
  "dsh": {
48
51
  "bundle": {
49
52
  "patch": "./cordis.patch.yml"
@@ -62,4 +62,6 @@ Controls, all on both tools unless noted:
62
62
 
63
63
  `mcp__semble__search` and `mcp__semble__find_related` only exist when this bundle's MCP connection
64
64
  mounted in the current session. If they are absent from your toolset, say so and fall back to
65
- `grep`/`glob`; do not narrate a call you cannot make.
65
+ `grep`/`glob`; do not narrate a call you cannot make. The user can still run `/semble savings` and
66
+ `/semble file-types` from the composer — those are host commands and do not depend on the MCP
67
+ connection.