dsh-task-guard 0.0.0-stage → 0.6.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 CHANGED
@@ -1,3 +1,161 @@
1
- # Temporary Holding Version
1
+ # dsh-task-guard
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **任务级工具收窄**:任务开始时按 `groups` 收窄一次工具表,任务结束时恢复。零依赖,纯 Node 内置模块。
4
+
5
+ > 它不是"又一个任务系统"。它只守一条线:**工具表在一个任务里只准改一次**。
6
+ > 因为每改一次就是一次全价前缀重建——本机实测单次最高 56 万 token 全价。
7
+
8
+ ## 三条铁律(这插件全部价值都在这三条)
9
+
10
+ | 铁律 | 为什么 |
11
+ |---|---|
12
+ | **只用 `deny`,绝不用 `allow`** | `restrict({allow})` 会藏掉**点不出名字**的工具(作用域本地注册的 `read`/`write`/`send_message`)。实测 4/4 会话丢 `read`,其中一个连 `send_message` 都没了 → 子代理干完活回不来 → **死锁** |
13
+ | **一个任务只收窄一次** | 每次改工具表 = 前缀指纹变化 = 一次全价重建 |
14
+ | **永不 `deny` 逃生门** | `run_code` 写进列表会**直接抛错**(静默不裁剪);`subagent`/`send_message` 藏了就断掉委派;`route_*`/`guard_*`/`cache_probe_*` 是你出问题时唯一的退路 |
15
+
16
+ ## 三个工具
17
+
18
+ | 工具 | 作用 |
19
+ |---|---|
20
+ | `task_start({ groups, goal? })` | 抢锁 → 收窄一次 → 记账。`groups` 为空直接拒绝(歧义不猜) |
21
+ | `task_end()` | 拆限制 → 放锁 → 记账(耗时、摘了多少) |
22
+ | `task_status()` | 只读:当前任务、谁持有跨进程锁、状态目录在哪 |
23
+
24
+ ## 安装
25
+
26
+ ```bash
27
+ # 开发期(link 安装,改代码后 disable→enable 生效)
28
+ dsh plugin --profile desktop add link:C:/path/to/dsh-task-guard
29
+
30
+ # 或手工:把目录加进 profile 的 dependencies 与 dsh.profile.bundles
31
+ ```
32
+
33
+ `cordis.patch.yml` 顶层必须是数组:
34
+
35
+ ```yaml
36
+ - insert:
37
+ - id: task-guard
38
+ name: dsh-task-guard
39
+ config:
40
+ lockTtlHours: 6
41
+ ```
42
+
43
+ ## 状态文件(都在 `~/.dsh/task-guard/`)
44
+
45
+ | 文件 | 内容 |
46
+ |---|---|
47
+ | `active.lock` | 跨进程任务锁:`{ taskId, pid, startedAt, groups }`,原子写(tmp + rename) |
48
+ | `usage.jsonl` | 每次 `start` / `end` 一行,带 `taskId`、`denyCount`、`durationMs`,字段名与 minimal-router 的探针记录保持一致,便于一起分析 |
49
+ | `current.json` | 给 UI 的快照:`active`、`groups`、`denyCount`、`keptCount`、`keptTools`、`deniedTools`、`savingsPerTurn`… |
50
+ | `tool-registry.json` | **全量工具清单**(v0.5.0):`task_start`(**含 dryRun**)时写入,任务结束也不删。认领建议和徽章空态都靠它 |
51
+ | `mode.json` | `{"mode":"auto"\|"manual"}`(v0.6.0):**谁来决定工具表**。徽章写,minimal-router 读 |
52
+ | `request.json` | UI 发来的手动动作(v0.6.0):`{id, action:'start'\|'end', groups, ts}` |
53
+ | `request-result.json` | 上一次动作的结果:`{id, ok, error}`,UI 用它显示成功/失败原因 |
54
+
55
+ > 放家目录而不是包目录:包目录在 pnpm 安装下是只读的,写进去升级就丢。
56
+ > 测试可用 `TASK_GUARD_DIR` 环境变量覆盖。
57
+
58
+ ### UI 请求通道(v0.6.0)
59
+
60
+ 浏览器调不了工具,所以徽章的动作走"文件邮箱":
61
+
62
+ ```
63
+ 徽章面板 → POST /task-guard/action(badge 插件的 host 路由)
64
+ → 写 request.json
65
+ → 本插件在**下一次 tools/pre-execute** 里消费
66
+ → restrict({deny}) / dispose() → 写 request-result.json
67
+ ```
68
+
69
+ **为什么是 pre-execute**:`restrict()` 必须作用在 `agent.ctx.tools` 上,而 pre-execute 是
70
+ 唯一每轮都会发生、且能拿到那个作用域的位置(不用额外起定时器)。
71
+
72
+ 三条保护:超过 60 秒的请求不执行、同一个 `id` 只执行一次、失败把原因写进结果文件。
73
+
74
+
75
+ ## 成本闸门(v0.2.0)
76
+
77
+ 收窄 = **一次全价前缀重建**,而省下来的钱是**每轮**的。所以它有个回本点。
78
+
79
+ 本机实测(2026-10-31,一次真实收窄):
80
+
81
+ ```
82
+ 272 个工具 / 232,458 字节 → 78 个 / 57,883 字节 (摘掉 174,575 字节,−75.1%)
83
+ 那一次请求:promptTokens 285,701,cacheRead 仅 4,608 → 命中率 1.61%
84
+ 一次性成本 ≈ 28.1 万全价 token;每轮省 ≈ 5.2 万 → 回本 ≈ 5.4 轮
85
+ ```
86
+
87
+ 所以 `task_start` 加了闸门(实现见 `lib/cost-gate.js`):
88
+
89
+ | 条件 | 行为 |
90
+ |---|---|
91
+ | 上下文 > 200,000 token **且** 预估轮数 < 6 | **拒绝**,报 `COST_GATE`,提示跑不够就亏 |
92
+ | 同上但传 `force: true` | 放行(你自己认这笔账) |
93
+ | 没传 `contextTokens` | 放行,但如实说"算不出来",并告诉你数据从哪取 |
94
+ | 本来就没东西可摘 | 放行(没有切换成本) |
95
+
96
+ ```jsonc
97
+ task_start({
98
+ "groups": ["unity"],
99
+ "contextTokens": 285701, // 从 cache_probe_report 的 lastPromptTokens 取
100
+ "turns": 12, // 预估轮数
101
+ "force": false
102
+ })
103
+ ```
104
+
105
+ 返回里会带上这笔账:`cost.savingsPerTurn` / `cost.paybackTurns` / `cost.message`。
106
+
107
+ ## 先看后付:`dryRun`(v0.3.0)
108
+
109
+ **起因是一次真实事故**:一次 `groups:["unity"]` 摘掉 194 个工具——连带摘掉了
110
+ `plugin_manager`(标签 `plugin`)和 `cordis_inspect_*`(标签 `cordis`),
111
+ 而那正是当时在做的事。**是用户看截图发现的,插件自己不知道。**
112
+
113
+ 根因不是逻辑错,是**调用方不知道有哪些组可以选**:"开发 DSH 插件"这个领域在工具表里
114
+ 没有对应标签,它的工具散在 `plugin` / `cordis` / `web` / `skill` 里。
115
+
116
+ 所以加了 `dryRun`(**成本 0**:不抢锁、不动工具表、不写任务状态):
117
+
118
+ ```jsonc
119
+ task_start({ "groups": ["core", "plugin", "cordis"], "dryRun": true })
120
+ ```
121
+
122
+ | 返回字段 | 含义 |
123
+ |---|---|
124
+ | `availableGroups` | **这台机器上真实存在的组**(组名 → 工具数,降序) |
125
+ | `foundGroups` / `missingGroups` | 你点名的组里,哪些存在、哪些写错了 |
126
+ | `willKeep` / `willDeny` | 会保留几个、摘掉几个 |
127
+ | `keepSample` / `denySample` | 各给 8 个样例(不用翻全表) |
128
+ | `cost` | 回本账(即使闸门会拒,也照样算给你看) |
129
+
130
+ **正确姿势:先 `dryRun` 看清单 → 改对了再去掉 `dryRun` 真开。**
131
+
132
+ ## 多领域的唯一纪律
133
+
134
+ | 场景 | 做法 |
135
+ |---|---|
136
+ | 开头就知道要跨领域 | **开头取并集**(`["unity","art"]`),一次收窄 |
137
+ | 中途才跨领域 | **不要重开任务**(再付一次重建)。要么接受工具不够,要么 `task_end` 后重开 |
138
+ | 完全不确定 | 先 `dryRun` 看清楚,别急着开 |
139
+
140
+ > **铁律:任务中途永远不改工具表。** 多领域靠"开头想清楚",不靠"动态调整"。
141
+
142
+ > **为什么不做成"永远拦"**:数据不全时拦人 = 让人以为插件坏了。
143
+ > 拿不到就明说"算不出来",把判断权交回去。
144
+
145
+ ## 已知边界(写在前面)
146
+
147
+ 1. **跨进程锁是"全机器一把"**:同一台机器同时只允许一个任务。同进程多会话由内存 Map 守卫。
148
+ 2. **只按 `deny` 算**:如果某个工具不在这个 agent 的注册表里、但存在于更大的全局表,我们摘不掉它——结果是**欠收窄**(安全方向),不是错杀。
149
+ 3. **不碰 prompt**:它不往上下文里塞一个字(可选的尾巴提示默认关)。
150
+ 4. **`taskId` 写在自己文件的 `usage.jsonl` 里**,不写入 minimal-router 的探针文件(避免跨插件写别人的数据)。要对齐两边,用 `at` 时间窗口 join,或给 router 的探针加一个扩展点。
151
+
152
+ ## 测试
153
+
154
+ ```bash
155
+ node test/task-guard.test.mjs # 12 项,零依赖(node:test)
156
+ ```
157
+
158
+ ## 规模
159
+
160
+ - `index.js`:**347 行代码**(不含注释)+ 178 行中文注释
161
+ - 依赖:**0**
@@ -0,0 +1,10 @@
1
+ # dsh-task-guard · 任务级工具收窄
2
+ # 顶层必须是数组;新增插件写 - insert: 再挂一个列表(写成 mapping 会被 Loader 拒绝)。
3
+ - insert:
4
+ - id: task-guard
5
+ name: dsh-task-guard
6
+ config:
7
+ # 任务锁的存活上限(小时)。超过这个时间即使 pid 还活着,也当成僵尸锁回收。
8
+ lockTtlHours: 6
9
+ # 每轮末尾的尾巴提示开关(默认关:本插件不往上下文里塞东西)
10
+ hintEnabled: false
package/icon.svg ADDED
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="T">
2
+ <rect x="2" y="2" width="60" height="60" rx="16" fill="#22C55E"/>
3
+ <text x="32" y="43" text-anchor="middle" font-family="Segoe UI, Helvetica, Arial, sans-serif"
4
+ font-size="30" font-weight="600" fill="#ffffff">T</text>
5
+ </svg>
package/index.js ADDED
@@ -0,0 +1,814 @@
1
+ /**
2
+ * ============================================================================
3
+ * dsh-task-guard · 任务级工具收窄(300 行的护城河)
4
+ * ============================================================================
5
+ * 这个插件只干一件事:**任务开始时按 groups 收窄一次工具表,任务结束时恢复。**
6
+ *
7
+ * ---------------------------------------------------------------------------
8
+ * 为什么只做这一件事
9
+ * ---------------------------------------------------------------------------
10
+ * 在 DSH 里,改工具表 = 前缀指纹变化 = 一次**全价重建**。本机实测:一次重载
11
+ * 563,654 token 全价(命中率 0.97%);长会话里一次重建能到几十万 token。
12
+ * 所以"收窄"只在**任务开头**做**一次**,中途绝不调整——这就是它跟市面那堆
13
+ * "每轮按任务改工具表"的插件的根本区别。
14
+ *
15
+ * ---------------------------------------------------------------------------
16
+ * 为什么只用 deny,绝不用 allow(这是踩出来的,不是理论)
17
+ * ---------------------------------------------------------------------------
18
+ * restrict({ allow }) 的语义是"只留我点名的"。而 read / write / send_message
19
+ * 这类**作用域本地注册**的工具不在 restrictableNames 里——我们点不出名字,
20
+ * 它们就会被"只留点名的"一起藏掉。
21
+ * 实测 4/4 个会话丢掉 read(报 unknown tool "read"),其中一个连 send_message
22
+ * 都没了 → 子代理干完活无法回报 → **父代理永远等不到 → 死锁**。
23
+ *
24
+ * ---------------------------------------------------------------------------
25
+ * 为什么这些工具永远不 deny
26
+ * ---------------------------------------------------------------------------
27
+ * run_code DSH 硬拦截:把它写进 allow/deny 会直接抛错,而我们外层是
28
+ * try/catch,表现就是"静默不裁剪"
29
+ * subagent / 藏了它们 = 断掉委派与回报 = 死锁
30
+ * send_message
31
+ * route_* / 逃生门与仪表:出问题时你得能切回去、能看账单
32
+ * guard_* /
33
+ * cache_probe_*
34
+ * core 类工具 连读文件都禁掉,任务就没法干了
35
+ * ============================================================================
36
+ */
37
+ import { homedir } from 'node:os'
38
+ import { join } from 'node:path'
39
+ import {
40
+ mkdirSync,
41
+ readFileSync,
42
+ writeFileSync,
43
+ renameSync,
44
+ appendFileSync,
45
+ existsSync,
46
+ } from 'node:fs'
47
+ import { estimateTaskCost, measureDeniedBytes } from './lib/cost-gate.js'
48
+ import { buildPreview, summarizeGroups } from './lib/scope-preview.js'
49
+
50
+ /** 插件名(DSH 里用于配置定位) */
51
+ export const name = 'task-guard'
52
+ /**
53
+ * 依赖声明。
54
+ * 【必须是硬依赖】apply() 里要调 ctx.tools.register,而 Cordis 规定:
55
+ * 没在 inject 里声明的服务,访问时会抛 `cannot get property "tools" without inject`。
56
+ * (第一版我写成 inject = [],装上去直接报这个错——所以这条不是理论。)
57
+ * 不声明 systemPrompt:本插件**一个字节都不往上下文里塞**。
58
+ */
59
+ export const inject = ['tools']
60
+
61
+ /**
62
+ * 状态目录。
63
+ * 【为什么不在插件目录里】pnpm 安装的包目录是只读/会被覆盖的,
64
+ * 往包内写文件 = 升级即丢数据。必须放到用户家目录。
65
+ * 【为什么允许环境变量覆盖】测试要隔离,不能污染真实状态。
66
+ */
67
+ export const STATE_DIR =
68
+ typeof process.env.TASK_GUARD_DIR === 'string' && process.env.TASK_GUARD_DIR
69
+ ? process.env.TASK_GUARD_DIR
70
+ : join(homedir(), '.dsh', 'task-guard')
71
+
72
+ export const LOCK_FILE = join(STATE_DIR, 'active.lock')
73
+ export const USAGE_FILE = join(STATE_DIR, 'usage.jsonl')
74
+
75
+ /**
76
+ * "当前任务"快照文件。
77
+ * 【为什么要有它】客户端 UI(浏览器里跑的徽章)读不到 Host 进程的内存,
78
+ * 只能靠 HTTP 接口取状态。所以每次任务开始/结束时把快照写下来,UI 去读这个文件。
79
+ */
80
+ export const CURRENT_FILE = join(STATE_DIR, 'current.json')
81
+ /**
82
+ * 全量工具清单(v0.5.0)。
83
+ * task_start(含 dryRun)会把它写下来;task_end 不影响它。
84
+ * 用途:认领建议、徽章空态显示工具数——这些都不该依赖"当前是否有任务"。
85
+ */
86
+ export const REGISTRY_FILE = join(STATE_DIR, 'tool-registry.json')
87
+ /**
88
+ * UI 请求通道(v0.6.0)。
89
+ * 浏览器没法直接调工具,所以徽章把「收窄 / 恢复」写进 request.json,
90
+ * 本插件在下一次工具调用前消费掉(不额外起定时器)。
91
+ */
92
+ export const REQUEST_FILE = join(STATE_DIR, 'request.json')
93
+ /** 处理结果,供 UI 显示"成功/失败原因" */
94
+ export const RESULT_FILE = join(STATE_DIR, 'request-result.json')
95
+
96
+ /** 任务锁存活上限(毫秒):超过就算僵尸锁,直接回收 */
97
+ export const LOCK_TTL_MS = 6 * 3600 * 1000
98
+
99
+ /** 永远不 deny 的**标签**:task/route/guard/cache 这几组是逃生门与仪表 */
100
+ export const KEEP_TAGS = Object.freeze(new Set(['task', 'route', 'guard', 'cache']))
101
+
102
+ /** 永远不 deny 的**具体工具名**(原因见文件头注释) */
103
+ export const KEEP_NAMES = Object.freeze(
104
+ new Set(['run_code', 'subagent', 'subagent_fork', 'workflow', 'send_message', 'todo_write']),
105
+ )
106
+
107
+ /**
108
+ * 【DSH 强制】每个工具必须声明 output:{ schema, render }。
109
+ * 少了它注册直接抛 TypeError: tool "x" must declare output { schema, render, presentationMeta? }
110
+ * 形状和 dsh-minimal-router 里的一致(纯 JSON 文本输出)。
111
+ */
112
+ const RENDER = {
113
+ schema: { type: 'object', additionalProperties: true },
114
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
115
+ }
116
+
117
+ // ============================================================================
118
+ // 一、纯函数层(可单测,不碰 IO)
119
+ // ============================================================================
120
+
121
+ /**
122
+ * 从工具名推标签。规则和 minimal-router 保持一致:
123
+ * read → core (没有下划线)
124
+ * unity_object_create → unity
125
+ * task_start → task
126
+ * mcp__meshy__meshy_image_to_3d → meshy (MCP 的组在中间)
127
+ * @param {string} toolName
128
+ * @returns {string}
129
+ */
130
+ export function tagOf(toolName) {
131
+ if (typeof toolName !== 'string' || toolName.length === 0) return 'unknown'
132
+ if (toolName.startsWith('mcp__')) {
133
+ const parts = toolName.split('__')
134
+ return parts.length >= 3 && parts[1] ? parts[1] : 'mcp'
135
+ }
136
+ const cut = toolName.indexOf('_')
137
+ return cut <= 0 ? 'core' : toolName.slice(0, cut)
138
+ }
139
+
140
+ /**
141
+ * 算出要摘掉哪些工具。
142
+ *
143
+ * 【关键】这里只返回 deny 列表,**永远不构造 allow 列表**。
144
+ *
145
+ * @param {string[]} toolNames 当前这个会话能看见的工具名
146
+ * @param {string[]} groups 任务要保留的组
147
+ * @returns {{ deny: string[], kept: string[], unknownGroups: string[] }}
148
+ */
149
+ export function computeDeny(toolNames, groups) {
150
+ const want = new Set(
151
+ (Array.isArray(groups) ? groups : [])
152
+ .map((g) => (typeof g === 'string' ? g.trim().toLowerCase() : ''))
153
+ .filter(Boolean),
154
+ )
155
+ const names = Array.isArray(toolNames) ? toolNames : []
156
+ const deny = []
157
+ const kept = []
158
+ const seenGroups = new Set()
159
+
160
+ for (const toolName of names) {
161
+ if (typeof toolName !== 'string' || toolName.length === 0) continue
162
+ const tag = tagOf(toolName)
163
+ seenGroups.add(tag)
164
+ const alwaysKeep =
165
+ KEEP_NAMES.has(toolName) || tag === 'core' || KEEP_TAGS.has(tag) || want.has(tag)
166
+ if (alwaysKeep) kept.push(toolName)
167
+ else deny.push(toolName)
168
+ }
169
+
170
+ // 用户写的组名如果在这台机器上根本不存在,要明确告诉他(否则"装了没效果")
171
+ const unknownGroups = [...want].filter((g) => !seenGroups.has(g))
172
+ return { deny, kept, unknownGroups }
173
+ }
174
+
175
+ // ============================================================================
176
+ // 二、IO 层(原子写 + 锁)
177
+ // ============================================================================
178
+
179
+ /** 目录按需创建(幂等) */
180
+ export function ensureDir() {
181
+ mkdirSync(STATE_DIR, { recursive: true })
182
+ }
183
+
184
+ /**
185
+ * 原子写 JSON:先写 tmp 再 rename。
186
+ * 【为什么】直接 writeFileSync 在写入过程中崩溃会留下半个文件,
187
+ * 下次读 JSON.parse 直接抛,整个插件起不来。rename 在同一分区上是原子的。
188
+ */
189
+ export function atomicWriteJson(file, value) {
190
+ ensureDir()
191
+ const tmp = `${file}.${process.pid}.tmp`
192
+ writeFileSync(tmp, JSON.stringify(value, null, 2), 'utf8')
193
+ renameSync(tmp, file)
194
+ }
195
+
196
+ /**
197
+ * pid 还活着吗?
198
+ * 【注意 EPERM】kill(pid,0) 在无权限时抛 EPERM,但那说明进程**存在**,要当活着。
199
+ */
200
+ export function pidAlive(pid) {
201
+ if (!Number.isInteger(pid) || pid <= 0) return false
202
+ try {
203
+ process.kill(pid, 0)
204
+ return true
205
+ } catch (error) {
206
+ return error && error.code === 'EPERM'
207
+ }
208
+ }
209
+
210
+ /** 读当前锁(没有/坏文件都返回 null,不抛) */
211
+ export function readLock() {
212
+ try {
213
+ if (!existsSync(LOCK_FILE)) return null
214
+ return JSON.parse(readFileSync(LOCK_FILE, 'utf8'))
215
+ } catch {
216
+ return null
217
+ }
218
+ }
219
+
220
+ /**
221
+ * 抢锁。已有一个**活着且没超时**的任务 → 抛 TASK_LOCKED。
222
+ * @param {{taskId:string, groups:string[], cwd?:string}} info
223
+ */
224
+ export function acquireLock(info) {
225
+ const current = readLock()
226
+ if (current && current.taskId !== info.taskId) {
227
+ const ageMs = Date.now() - Number(current.startedAt || 0)
228
+ // 同进程内允许多次(不同会话各自有 Map 守卫),跨进程才靠这把锁
229
+ if (pidAlive(Number(current.pid)) && current.pid !== process.pid && ageMs < LOCK_TTL_MS) {
230
+ const error = new Error(
231
+ `另一个任务还在跑:${current.taskId}(pid ${current.pid},已 ${Math.round(ageMs / 1000)} 秒)`,
232
+ )
233
+ error.code = 'TASK_LOCKED'
234
+ throw error
235
+ }
236
+ }
237
+ atomicWriteJson(LOCK_FILE, {
238
+ taskId: info.taskId,
239
+ pid: process.pid,
240
+ startedAt: Date.now(),
241
+ groups: info.groups,
242
+ cwd: info.cwd || '',
243
+ })
244
+ }
245
+
246
+ /**
247
+ * 放锁。只放自己那把(taskId 对不上就不动,避免把别人的锁删了)。
248
+ * @param {string} taskId
249
+ */
250
+ export function releaseLock(taskId) {
251
+ const current = readLock()
252
+ if (!current) return false
253
+ if (taskId && current.taskId !== taskId) return false
254
+ try {
255
+ if (existsSync(LOCK_FILE)) writeFileSync(LOCK_FILE, '', 'utf8')
256
+ return true
257
+ } catch {
258
+ return false
259
+ }
260
+ }
261
+
262
+ /**
263
+ * 往任务流水里追加一条记录(JSONL,一行一条)。
264
+ * 字段刻意和 minimal-router 的探针记录保持同一套命名,方便一起分析。
265
+ * @param {object} record
266
+ */
267
+ export function appendRecord(record) {
268
+ ensureDir()
269
+ const line = JSON.stringify({
270
+ v: 1,
271
+ at: Date.now(),
272
+ iso: new Date().toISOString(),
273
+ pid: process.pid,
274
+ ...record,
275
+ })
276
+ appendFileSync(USAGE_FILE, `${line}\n`, 'utf8')
277
+ }
278
+
279
+ // ============================================================================
280
+ // 三、会话状态
281
+ // ============================================================================
282
+
283
+ /**
284
+ * 写"当前任务"快照(给 UI 读)。
285
+ * 失败不抛:UI 是锦上添花,不能因为它让任务开不起来。
286
+ * @param {object} state
287
+ */
288
+ export function writeCurrent(state) {
289
+ try {
290
+ atomicWriteJson(CURRENT_FILE, { ...state, at: Date.now() })
291
+ } catch {
292
+ /* UI 快照失败不影响主流程 */
293
+ }
294
+ }
295
+
296
+ /** 每个会话当前在跑的任务:key → { taskId, groups, startedAt, dispose, applied, denyCount } */
297
+ const activeTasks = new Map()
298
+
299
+ /**
300
+ * 给 agent 对象分配稳定 key。
301
+ * 【为什么用 WeakMap】没有 id 的 agent(测试里常见)如果退化成同一个字符串,
302
+ * 多会话状态会互相污染——minimal-router 在这上面踩过坑。
303
+ */
304
+ const agentKeys = new WeakMap()
305
+ let agentSeq = 0
306
+
307
+ export function keyOf(exec) {
308
+ const agent = exec && exec.agent
309
+ if (agent && typeof agent === 'object') {
310
+ let key = agentKeys.get(agent)
311
+ if (!key) {
312
+ agentSeq += 1
313
+ key = `agent-${agentSeq}`
314
+ agentKeys.set(agent, key)
315
+ }
316
+ return key
317
+ }
318
+ const sessionId = exec && exec.sessionId
319
+ return `session-${typeof sessionId === 'string' && sessionId ? sessionId : 'unknown'}`
320
+ }
321
+
322
+ /** 生成任务 id:时间可读 + 随机后缀防撞 */
323
+ function newTaskId() {
324
+ return `tg-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`
325
+ }
326
+
327
+ // ============================================================================
328
+ // 四、插件入口
329
+ // ============================================================================
330
+
331
+ /**
332
+ * @param {import('@deepseek-ai/cordis').Context} ctx
333
+ * @param {{lockTtlHours?: number}} config
334
+ */
335
+ export function apply(ctx, config = {}) {
336
+ ensureDir()
337
+
338
+ // 【卸载保护】插件被禁用/热重载时,必须把工具表还回去。
339
+ // 否则会出现"任务没结束但限制永远留着"——最恶心的一种残留。
340
+ ctx.effect(
341
+ () => () => {
342
+ for (const [key, task] of activeTasks) {
343
+ try {
344
+ if (typeof task.dispose === 'function') task.dispose()
345
+ } catch {
346
+ /* 卸载时的异常不该影响主流程 */
347
+ }
348
+ releaseLock(task.taskId)
349
+ appendRecord({ event: 'end', taskId: task.taskId, reason: 'plugin-teardown', key })
350
+ activeTasks.delete(key)
351
+ }
352
+ },
353
+ )
354
+
355
+ // --------------------------------------------------------------------------
356
+ // task_start —— 开任务 + 收窄一次
357
+ // --------------------------------------------------------------------------
358
+ ctx.effect(() =>
359
+ ctx.tools.register({
360
+ name: 'task_start',
361
+ output: RENDER,
362
+ description:
363
+ 'Start a task and narrow the visible tool groups once (deny-only). Tools outside the named groups are removed for the duration of the task and restored by task_end. Call this once at the beginning — do not re-narrow mid-task (each change costs a full-price prefix rebuild).',
364
+ parameters: {
365
+ type: 'object',
366
+ additionalProperties: false,
367
+ properties: {
368
+ groups: {
369
+ type: 'array',
370
+ items: { type: 'string' },
371
+ description: 'Tool groups to keep for this task, e.g. ["unity","asset"]. Base tools, route_*/guard_*/cache_probe_* are always kept.',
372
+ },
373
+ goal: {
374
+ type: 'string',
375
+ description: 'Optional one-line goal, recorded in the usage log.',
376
+ },
377
+ contextTokens: {
378
+ type: 'integer',
379
+ description:
380
+ 'Current context size in tokens (take lastPromptTokens from cache_probe_report). Narrowing costs one full-price rebuild of about this size, so it is needed to decide whether narrowing pays off.',
381
+ },
382
+ turns: {
383
+ type: 'integer',
384
+ description:
385
+ 'Roughly how many turns this task will take. In a large context, fewer than 6 turns cannot pay back the rebuild and is refused unless force is set.',
386
+ },
387
+ force: {
388
+ type: 'boolean',
389
+ description: 'Narrow even when the cost gate says it will not pay back — you accept one full-price rebuild.',
390
+ },
391
+ dryRun: {
392
+ type: 'boolean',
393
+ description:
394
+ 'Preview only: report what would be kept or removed plus availableGroups, without touching the tool table or taking the lock. Costs nothing. Use it to pick the right groups before committing.',
395
+ },
396
+ },
397
+ required: ['groups'],
398
+ },
399
+ async execute(args, exec) {
400
+ const key = keyOf(exec)
401
+ if (activeTasks.has(key)) {
402
+ const running = activeTasks.get(key)
403
+ return { ok: false, code: 'ALREADY_ACTIVE', taskId: running.taskId, hint: '先 task_end 结束当前任务' }
404
+ }
405
+
406
+ const groups = (Array.isArray(args?.groups) ? args.groups : [])
407
+ .map((g) => (typeof g === 'string' ? g.trim().toLowerCase() : ''))
408
+ .filter(Boolean)
409
+
410
+ // 【歧义就报错,不要猜】groups: [] 可以理解成"只留基础工具",也可以理解成"不收窄"。
411
+ // 两种都合理,但选错一次就把任务需要的工具全摘了。所以直接拒绝,让调用方写清楚。
412
+ if (groups.length === 0) {
413
+ return {
414
+ ok: false,
415
+ code: 'EMPTY_GROUPS',
416
+ message: 'groups 不能为空:要收窄就写至少一个组名;不想收窄就别开任务。',
417
+ }
418
+ }
419
+
420
+ // 拿到这个会话真实的工具清单(优先 agent 作用域)
421
+ const agent = exec && exec.agent
422
+ const scoped = agent && agent.ctx && agent.ctx.tools
423
+ let schemas = []
424
+ try {
425
+ if (scoped && typeof scoped.schemas === 'function') schemas = scoped.schemas()
426
+ else if (typeof ctx.tools.schemas === 'function') schemas = ctx.tools.schemas()
427
+ } catch {
428
+ schemas = []
429
+ }
430
+ // 只要名字给算法层;原始 schema 留着给成本估算量字节
431
+ const toolNames = (Array.isArray(schemas) ? schemas : [])
432
+ .map((t) => (t && typeof t.name === 'string' ? t.name : ''))
433
+ .filter(Boolean)
434
+
435
+ const { deny, kept, unknownGroups } = computeDeny(toolNames, groups)
436
+
437
+ // 【v0.5.0】持久工具清单:dryRun 也会写(不花重建成本)。
438
+ // 为什么需要:task_end 之后的快照里没有名单,而"认领建议 / 空态显示工具数"
439
+ // 都需要一份全量工具名。这份文件是唯一的长期来源。
440
+ try {
441
+ atomicWriteJson(REGISTRY_FILE, { at: Date.now(), count: toolNames.length, tools: toolNames })
442
+ } catch {
443
+ // 写不了就算了,不影响任务
444
+ }
445
+
446
+ // 【B】把这台机器上真实存在的组列出来——否则调用方只能凭记忆猜组名。
447
+ const availableGroups = summarizeGroups(schemas, tagOf)
448
+
449
+ // 【成本闸门】收窄 = 一次全价重建(实测 33 万上下文 ≈ 28 万全价 token)。
450
+ // 数据由调用方给,拿不到就如实说"算不出来",绝不猜。
451
+ const deniedBytes = measureDeniedBytes(schemas, new Set(deny))
452
+ const cost = estimateTaskCost({
453
+ contextTokens: args?.contextTokens,
454
+ turns: args?.turns,
455
+ deniedBytes,
456
+ })
457
+
458
+ // 【A】dryRun:只看不碰。不抢锁、不动工具表、不写任务状态,成本 0。
459
+ // 连成本闸门的拒绝也一并展示出来——你需要看到"为什么不该切"。
460
+ if (args?.dryRun === true) {
461
+ const preview = buildPreview({ deny, kept, availableGroups, groups })
462
+ appendRecord({ event: 'dry-run', groups, denyCount: deny.length, deniedBytes, gate: cost.reason })
463
+ return {
464
+ ok: true,
465
+ dryRun: true,
466
+ ...preview,
467
+ unknownGroups,
468
+ cost,
469
+ message:
470
+ `预览:会保留 ${preview.willKeep} 个、摘掉 ${preview.willDeny} 个。` +
471
+ (preview.missingGroups.length > 0 ? `这些组名在这台机器上不存在:${preview.missingGroups.join(', ')}。` : '') +
472
+ '确认无误后去掉 dryRun 再调一次。',
473
+ }
474
+ }
475
+
476
+ if (!cost.allow && args?.force !== true) {
477
+ appendRecord({ event: 'gate-refused', groups, denyCount: deny.length, deniedBytes, cost })
478
+ return { ok: false, code: 'COST_GATE', message: cost.message, cost, availableGroups }
479
+ }
480
+
481
+ const taskId = newTaskId()
482
+
483
+ // 先抢锁(跨进程唯一),失败就不要动工具表
484
+ try {
485
+ acquireLock({ taskId, groups, cwd: process.cwd() })
486
+ } catch (error) {
487
+ return { ok: false, code: error.code || 'LOCK_FAILED', message: String(error.message || error) }
488
+ }
489
+
490
+ // 只在 agent.ctx.tools 上调(全局调用 DSH 会直接拒绝)
491
+ let dispose = null
492
+ let restrictError = ''
493
+ if (deny.length > 0) {
494
+ if (scoped && typeof scoped.restrict === 'function') {
495
+ try {
496
+ // 只用 deny。allow 会藏掉点不出名字的工具(实测 4/4 丢 read)
497
+ dispose = scoped.restrict({ deny })
498
+ } catch (error) {
499
+ restrictError = String((error && error.message) || error)
500
+ }
501
+ } else {
502
+ restrictError = 'no-scoped-tools'
503
+ }
504
+ }
505
+
506
+ // 收窄失败就别占着锁(否则后面的任务都进不来)
507
+ if (!dispose && deny.length > 0) {
508
+ releaseLock(taskId)
509
+ appendRecord({ event: 'start-failed', taskId, groups, denyCount: deny.length, error: restrictError })
510
+ return { ok: false, code: 'RESTRICT_FAILED', message: restrictError, denyCount: deny.length }
511
+ }
512
+
513
+ const task = {
514
+ taskId,
515
+ groups,
516
+ goal: typeof args?.goal === 'string' ? args.goal.slice(0, 200) : '',
517
+ startedAt: Date.now(),
518
+ dispose,
519
+ applied: Boolean(dispose),
520
+ denyCount: deny.length,
521
+ knownTools: toolNames.length,
522
+ cost,
523
+ }
524
+ activeTasks.set(key, task)
525
+
526
+ // 给 UI 的快照(浏览器读不到内存,只能读文件)
527
+ // 【v0.4.0】除了数字,也把**具体工具名**写进去:面板要能回答"保留了什么、砍了什么"。
528
+ const countByTag = (names) => {
529
+ const out = {}
530
+ for (const name of names) {
531
+ const tag = tagOf(name)
532
+ out[tag] = (out[tag] || 0) + 1
533
+ }
534
+ return out
535
+ }
536
+ writeCurrent({
537
+ active: true,
538
+ taskId,
539
+ groups,
540
+ goal: task.goal,
541
+ denyCount: deny.length,
542
+ keptCount: kept.length,
543
+ keptTools: kept,
544
+ deniedTools: deny,
545
+ deniedByTag: countByTag(deny),
546
+ deniedBytes,
547
+ startedAt: task.startedAt,
548
+ applied: task.applied,
549
+ savingsPerTurn: cost.savingsPerTurn,
550
+ paybackTurns: cost.paybackTurns,
551
+ })
552
+
553
+ appendRecord({
554
+ event: 'start',
555
+ taskId,
556
+ key,
557
+ goal: task.goal,
558
+ groups,
559
+ denyCount: deny.length,
560
+ keptCount: kept.length,
561
+ knownTools: toolNames.length,
562
+ applied: task.applied,
563
+ unknownGroups,
564
+ // 经济账也一起记下来,事后能复盘"这次收窄到底值不值"
565
+ deniedBytes,
566
+ costTokens: cost.costTokens,
567
+ savingsPerTurn: cost.savingsPerTurn,
568
+ paybackTurns: cost.paybackTurns,
569
+ gate: cost.reason,
570
+ })
571
+
572
+ return {
573
+ ok: true,
574
+ taskId,
575
+ groups,
576
+ // 注意:这是"我们摘掉了多少",不是"还剩多少"。真实剩余以探针为准。
577
+ denyCount: deny.length,
578
+ keptCount: kept.length,
579
+ applied: task.applied,
580
+ unknownGroups,
581
+ // 【B】也带一份组清单:任务跑起来后你还想知道"当初如果有别的组可不可以选"
582
+ availableGroups,
583
+ cost,
584
+ note: '工具表已按任务收窄;结束时调 task_end 恢复。中途不要改。',
585
+ }
586
+ },
587
+ }),
588
+ )
589
+
590
+ // --------------------------------------------------------------------------
591
+ // task_end —— 结束任务 + 恢复全量
592
+ // --------------------------------------------------------------------------
593
+ ctx.effect(() =>
594
+ ctx.tools.register({
595
+ name: 'task_end',
596
+ output: RENDER,
597
+ description:
598
+ 'End the current task: dispose the tool restriction (restore every tool) and release the task lock. Returns the task summary recorded in the usage log.',
599
+ parameters: { type: 'object', additionalProperties: false, properties: {} },
600
+ async execute(_args, exec) {
601
+ const key = keyOf(exec)
602
+ const task = activeTasks.get(key)
603
+ if (!task) return { ok: false, code: 'NO_ACTIVE_TASK', hint: '当前会话没有在跑的任务' }
604
+
605
+ let restored = false
606
+ try {
607
+ if (typeof task.dispose === 'function') {
608
+ task.dispose()
609
+ restored = true
610
+ }
611
+ } catch (error) {
612
+ appendRecord({ event: 'end-error', taskId: task.taskId, key, error: String((error && error.message) || error) })
613
+ }
614
+ releaseLock(task.taskId)
615
+ activeTasks.delete(key)
616
+
617
+ // 告诉 UI:任务结束了(保留工具总数,好让徽章显示"工具表:全开(N 个)")
618
+ writeCurrent({
619
+ active: false,
620
+ taskId: task.taskId,
621
+ groups: task.groups,
622
+ denyCount: task.denyCount,
623
+ keptCount: Math.max(0, (task.knownTools || 0) - task.denyCount),
624
+ knownTools: task.knownTools,
625
+ endedAt: Date.now(),
626
+ })
627
+
628
+ const durationMs = Date.now() - task.startedAt
629
+ appendRecord({
630
+ event: 'end',
631
+ taskId: task.taskId,
632
+ key,
633
+ groups: task.groups,
634
+ denyCount: task.denyCount,
635
+ durationMs,
636
+ restored,
637
+ })
638
+
639
+ return {
640
+ ok: true,
641
+ taskId: task.taskId,
642
+ durationMs,
643
+ restored,
644
+ denyCount: task.denyCount,
645
+ note: restored ? '工具表已恢复全量' : '本来就没收窄(或限制已被外部拆除)',
646
+ }
647
+ },
648
+ }),
649
+ )
650
+
651
+ // --------------------------------------------------------------------------
652
+ // task_status —— 只读(没有它,出问题只能靠猜)
653
+ // --------------------------------------------------------------------------
654
+ ctx.effect(() =>
655
+ ctx.tools.register({
656
+ name: 'task_status',
657
+ output: RENDER,
658
+ description:
659
+ 'Read-only: the active task for this session (id, groups, how many tools were denied, elapsed), plus whoever holds the cross-process lock. Changes nothing.',
660
+ parameters: { type: 'object', additionalProperties: false, properties: {} },
661
+ async execute(_args, exec) {
662
+ const key = keyOf(exec)
663
+ const task = activeTasks.get(key)
664
+ const lock = readLock()
665
+ return {
666
+ ok: true,
667
+ active: task
668
+ ? {
669
+ taskId: task.taskId,
670
+ groups: task.groups,
671
+ goal: task.goal,
672
+ applied: task.applied,
673
+ denyCount: task.denyCount,
674
+ knownTools: task.knownTools,
675
+ durationMs: Date.now() - task.startedAt,
676
+ }
677
+ : null,
678
+ lock: lock
679
+ ? {
680
+ taskId: lock.taskId,
681
+ pid: lock.pid,
682
+ alive: pidAlive(Number(lock.pid)),
683
+ ageMs: Date.now() - Number(lock.startedAt || 0),
684
+ mine: lock.pid === process.pid,
685
+ }
686
+ : null,
687
+ stateDir: STATE_DIR,
688
+ }
689
+ },
690
+ }),
691
+ )
692
+
693
+ // --------------------------------------------------------------------------
694
+ // UI 请求通道(v0.6.0):浏览器写 request.json,这里在下一次工具调用前消费
695
+ // --------------------------------------------------------------------------
696
+ // 为什么挂在 pre-execute:浏览器没有调用工具的权限,而 pre-execute 是唯一
697
+ // 每轮都会发生、且能拿到 agent 作用域(restrict 必须作用在 agent.ctx.tools)的位置。
698
+ let lastUiRequestId = ''
699
+ ctx.effect(() =>
700
+ ctx.on('tools/pre-execute', (...args) => {
701
+ const downstream = typeof args[args.length - 1] === 'function' ? args[args.length - 1] : null
702
+ const exec = args[0]
703
+ try {
704
+ consumeUiRequest(exec)
705
+ } catch {
706
+ // 消费失败不能影响工具调用
707
+ }
708
+ return downstream ? downstream() : { kind: 'allow' }
709
+ }),
710
+ )
711
+
712
+ function consumeUiRequest(exec) {
713
+ let request = null
714
+ try {
715
+ request = JSON.parse(readFileSync(REQUEST_FILE, 'utf8'))
716
+ } catch {
717
+ return
718
+ }
719
+ if (!request || typeof request.id !== 'string' || request.id === lastUiRequestId) return
720
+ const age = Date.now() - (Number(request.ts) || 0)
721
+ if (!(age >= 0 && age < 60 * 1000)) return // 过期请求不执行
722
+
723
+ const key = keyOf(exec)
724
+ const agent = exec && exec.agent && typeof exec.agent === 'object' ? exec.agent : null
725
+ const scoped = agent && agent.ctx && agent.ctx.tools ? agent.ctx.tools : null
726
+ const result = { id: request.id, action: String(request.action || ''), at: Date.now(), ok: false }
727
+
728
+ try {
729
+ if (request.action === 'end') {
730
+ const task = activeTasks.get(key)
731
+ if (!task) {
732
+ result.error = '当前会话没有进行中的任务'
733
+ } else {
734
+ try {
735
+ if (typeof task.dispose === 'function') task.dispose()
736
+ } catch {
737
+ /* 释放失败也要把状态清掉 */
738
+ }
739
+ releaseLock(task.taskId)
740
+ activeTasks.delete(key)
741
+ writeCurrent({
742
+ active: false,
743
+ taskId: task.taskId,
744
+ groups: task.groups,
745
+ denyCount: task.denyCount,
746
+ keptCount: Math.max(0, (task.knownTools || 0) - task.denyCount),
747
+ knownTools: task.knownTools,
748
+ endedAt: Date.now(),
749
+ })
750
+ appendRecord({ event: 'end', taskId: task.taskId, key, reason: 'ui-request' })
751
+ result.ok = true
752
+ }
753
+ } else if (request.action === 'start') {
754
+ if (!scoped || typeof scoped.restrict !== 'function') {
755
+ result.error = '拿不到 agent 作用域的工具表(这条消息还没调用过工具?)'
756
+ } else {
757
+ const groups = Array.isArray(request.groups) ? request.groups.filter((g) => typeof g === 'string' && g) : []
758
+ const schemas = typeof scoped.schemas === 'function' ? scoped.schemas() : []
759
+ const toolNames = schemas.map((t) => (t && typeof t.name === 'string' ? t.name : '')).filter(Boolean)
760
+ const { deny, kept } = computeDeny(toolNames, new Set(groups))
761
+ const deniedBytes = measureDeniedBytes(schemas, new Set(deny))
762
+ const taskId = newTaskId()
763
+ const dispose = deny.length > 0 ? scoped.restrict({ deny: deny.filter((n) => n !== 'run_code') }) : null
764
+ activeTasks.set(key, {
765
+ taskId,
766
+ groups,
767
+ goal: '(UI 手动收窄)',
768
+ startedAt: Date.now(),
769
+ dispose,
770
+ applied: Boolean(dispose),
771
+ denyCount: deny.length,
772
+ knownTools: toolNames.length,
773
+ })
774
+ try {
775
+ acquireLock({ taskId, groups, cwd: process.cwd() })
776
+ } catch {
777
+ // 锁被别的进程占着也允许 UI 收窄,不阻塞
778
+ }
779
+ atomicWriteJson(REGISTRY_FILE, { at: Date.now(), count: toolNames.length, tools: toolNames })
780
+ writeCurrent({
781
+ active: true,
782
+ taskId,
783
+ groups,
784
+ goal: '(UI 手动收窄)',
785
+ denyCount: deny.length,
786
+ keptCount: kept.length,
787
+ keptTools: kept,
788
+ deniedTools: deny,
789
+ deniedBytes,
790
+ startedAt: Date.now(),
791
+ applied: Boolean(dispose),
792
+ savingsPerTurn: Math.round(deniedBytes * 0.3),
793
+ paybackTurns: null,
794
+ })
795
+ appendRecord({ event: 'start', taskId, key, groups, denyCount: deny.length, by: 'ui-request' })
796
+ result.ok = true
797
+ result.taskId = taskId
798
+ result.denyCount = deny.length
799
+ }
800
+ } else {
801
+ result.error = `未知动作:${request.action}`
802
+ }
803
+ } catch (error) {
804
+ result.error = String((error && error.message) || error).slice(0, 160)
805
+ }
806
+
807
+ lastUiRequestId = request.id
808
+ try {
809
+ atomicWriteJson(RESULT_FILE, result)
810
+ } catch {
811
+ /* 结果写不进去不影响主流程 */
812
+ }
813
+ }
814
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * ============================================================================
3
+ * lib/cost-gate.js · 收窄的"经济账"
4
+ * ============================================================================
5
+ * 任务收窄是**一次性全价重建 + 每轮持续省钱**。所以它有个回本点:
6
+ *
7
+ * 成本 = 切换那一刻的整段上下文(contextTokens,全价重算)
8
+ * 每轮省 = 被摘掉工具声明的字节数 × 每字节 token 数(0.3,和 router 同一口径)
9
+ * 回本 = 成本 / 每轮省
10
+ *
11
+ * 实测依据(本机,2026-10-31,一次真实收窄):
12
+ * 272 个工具 / 232,458 字节 → 78 个 / 57,883 字节(摘掉 174,575 字节)
13
+ * 那一次请求:promptTokens 285,701,cacheRead 只有 4,608 → 命中率 1.61%
14
+ * → 一次性成本 ≈ 28.1 万全价 token,按 0.3 算每轮省 ≈ 5.2 万 → 回本 ≈ 5.4 轮
15
+ *
16
+ * 结论:**任务跑不够约 6 轮,收窄就是纯亏。** 这个文件就是把这条账做成门槛。
17
+ * ============================================================================
18
+ */
19
+
20
+ /** 上下文超过这个量级,一次重建就非常贵了(实测 33 万上下文 ≈ 28 万全价 token) */
21
+ export const FULL_PRICE_CONTEXT = 200_000
22
+
23
+ /** 低于这个预估轮数、且上下文已经很大时,收窄不划算 */
24
+ export const MIN_TURNS_TO_PAYBACK = 6
25
+
26
+ /**
27
+ * 每字节约等于多少 token。
28
+ *
29
+ * 【实测校准(2026-10-31)】Web UI 的「用量 → 工具定义」显示约 58.3K token,
30
+ * 而探针的 toolsChars 是 232,458 字节 → 232,458 / 58,300 ≈ 3.99 字节/token,
31
+ * 也就是 **0.25 token/字节**。
32
+ *
33
+ * 原来按 router 的 switchSavingsPerByte = 0.3 填的,**高估约 20%**:
34
+ * 每轮省会被算大,回本被算短。这里改成实测值,宁可保守也不要乐观。
35
+ */
36
+ export const SAVINGS_PER_BYTE = 0.25
37
+
38
+ /**
39
+ * 量一下"这次要摘掉多少字节的工具声明"。
40
+ * 和 router 里的算法一致:把 name/description/parameters 序列化后计长。
41
+ * @param {Array<{name?:string, description?:string, parameters?:object}>} schemas
42
+ * @param {Set<string>} denySet 要摘掉的名字集合
43
+ * @returns {number} 字节数
44
+ */
45
+ export function measureDeniedBytes(schemas, denySet) {
46
+ try {
47
+ const list = Array.isArray(schemas) ? schemas : []
48
+ if (!(denySet instanceof Set) || denySet.size === 0) return 0
49
+ let bytes = 0
50
+ for (const tool of list) {
51
+ if (!tool || typeof tool.name !== 'string' || !denySet.has(tool.name)) continue
52
+ bytes += JSON.stringify({
53
+ name: tool.name,
54
+ description: tool.description,
55
+ parameters: tool.parameters,
56
+ }).length
57
+ }
58
+ return bytes
59
+ } catch {
60
+ return 0
61
+ }
62
+ }
63
+
64
+ /**
65
+ * 算这笔账。
66
+ *
67
+ * 【诚实原则】`contextTokens` 和 `turns` 都是**调用方给的**,我们不会去猜,也不会去读
68
+ * 别的插件的数据文件(跨插件读别人的账单属于隐性耦合)。拿不到就如实说"数据不足"。
69
+ *
70
+ * @param {{contextTokens?: number|null, turns?: number|null, deniedBytes?: number}} input
71
+ * @returns {{
72
+ * allow: boolean,
73
+ * reason: 'ok'|'not-worth-it'|'insufficient-data'|'nothing-to-deny',
74
+ * costTokens: number|null,
75
+ * savingsPerTurn: number,
76
+ * paybackTurns: number|null,
77
+ * deniedBytes: number,
78
+ * message: string,
79
+ * }}
80
+ */
81
+ export function estimateTaskCost(input) {
82
+ const rawContext = Number(input && input.contextTokens)
83
+ const contextTokens = Number.isFinite(rawContext) && rawContext > 0 ? Math.trunc(rawContext) : null
84
+ const rawTurns = Number(input && input.turns)
85
+ const turns = Number.isFinite(rawTurns) && rawTurns > 0 ? Math.trunc(rawTurns) : null
86
+ const deniedBytes = Math.max(0, Math.trunc(Number(input && input.deniedBytes) || 0))
87
+
88
+ const savingsPerTurn = Math.round(deniedBytes * SAVINGS_PER_BYTE)
89
+
90
+ // 没什么可摘的 → 不用算,也不会亏
91
+ if (deniedBytes === 0) {
92
+ return {
93
+ allow: true,
94
+ reason: 'nothing-to-deny',
95
+ costTokens: contextTokens,
96
+ savingsPerTurn: 0,
97
+ paybackTurns: null,
98
+ deniedBytes: 0,
99
+ message: '这个任务本来就不需要摘工具,没有切换成本。',
100
+ }
101
+ }
102
+
103
+ // 数据不足 → 不拦(拦了会让人以为插件坏了),但明确说"没算出来"
104
+ if (contextTokens === null) {
105
+ return {
106
+ allow: true,
107
+ reason: 'insufficient-data',
108
+ costTokens: null,
109
+ savingsPerTurn,
110
+ paybackTurns: null,
111
+ deniedBytes,
112
+ message: `没拿到当前上下文大小,无法估算回本。每轮约省 ${savingsPerTurn} token;想让我算清楚就传 contextTokens(可从 cache_probe_report 的 lastPromptTokens 取)。`,
113
+ }
114
+ }
115
+
116
+ const paybackTurns = savingsPerTurn > 0 ? Math.ceil(contextTokens / savingsPerTurn) : null
117
+
118
+ // 【主判据】用户的显式规则:上下文已经很大 + 任务又短 → 拒绝
119
+ const tooExpensive = contextTokens > FULL_PRICE_CONTEXT
120
+ const tooShort = turns !== null && turns < MIN_TURNS_TO_PAYBACK
121
+ const notWorthIt = tooExpensive && tooShort
122
+
123
+ if (notWorthIt) {
124
+ return {
125
+ allow: false,
126
+ reason: 'not-worth-it',
127
+ costTokens: contextTokens,
128
+ savingsPerTurn,
129
+ paybackTurns,
130
+ deniedBytes,
131
+ message:
132
+ `【拒绝收窄】当前上下文 ${contextTokens} token,收窄会触发一次约 ${contextTokens} token 的全价重建;` +
133
+ `而这次任务预估只有 ${turns} 轮,每轮约省 ${savingsPerTurn} token,` +
134
+ `需要 ${paybackTurns ?? '>99'} 轮才回本(阈值 ${MIN_TURNS_TO_PAYBACK} 轮)——跑不够就是亏。` +
135
+ '确实要切就传 force: true。',
136
+ }
137
+ }
138
+
139
+ return {
140
+ allow: true,
141
+ reason: 'ok',
142
+ costTokens: contextTokens,
143
+ savingsPerTurn,
144
+ paybackTurns,
145
+ deniedBytes,
146
+ message:
147
+ `收窄一次性成本约 ${contextTokens} token,每轮约省 ${savingsPerTurn} token` +
148
+ (paybackTurns === null ? '。' : `,约 ${paybackTurns} 轮回本。`),
149
+ }
150
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * ============================================================================
3
+ * lib/scope-preview.js · "先看后付"用的纯函数
4
+ * ============================================================================
5
+ * 为什么需要它(真实事故,2026-10-31):
6
+ * 一次 task_start 用了 groups:["unity"],结果把 `plugin_manager`(标签 plugin)
7
+ * 和 `cordis_inspect_*`(标签 cordis)一起摘掉了 —— 而那正是当时在做的活。
8
+ * 根因不是逻辑错,是**调用方不知道有哪些组可以选**:领域("开发 DSH 插件")
9
+ * 这个说法在工具表里根本没有对应标签,它的工具散在 plugin / cordis / web / skill 里。
10
+ *
11
+ * 所以这里提供两件东西:
12
+ * 1. summarizeGroups —— 把"这台机器上真实存在的组"列出来(组名 → 工具数)
13
+ * 2. buildPreview —— 一次收窄会保留什么、摘掉什么(含少量样例)
14
+ *
15
+ * 两者都是**纯函数**,不碰 IO、不碰工具表,可以在 dryRun 里零成本调用。
16
+ * ============================================================================
17
+ */
18
+
19
+ /** 预览里每边最多列几个名字(全列会刷屏,也没人看) */
20
+ export const SAMPLE_SIZE = 8
21
+
22
+ /**
23
+ * 统计这台机器上真实存在的工具组。
24
+ * @param {Array<{name?: string}>} schemas
25
+ * @param {(name: string) => string} tagOf
26
+ * @returns {Record<string, number>} 组名 → 工具数,按数量降序
27
+ */
28
+ export function summarizeGroups(schemas, tagOf) {
29
+ const counts = new Map()
30
+ try {
31
+ for (const tool of Array.isArray(schemas) ? schemas : []) {
32
+ const name = tool && typeof tool.name === 'string' ? tool.name : ''
33
+ if (!name) continue
34
+ const tag = tagOf(name)
35
+ counts.set(tag, (counts.get(tag) || 0) + 1)
36
+ }
37
+ } catch {
38
+ return {}
39
+ }
40
+ const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
41
+ return Object.fromEntries(sorted)
42
+ }
43
+
44
+ /**
45
+ * 组一次收窄的预览。
46
+ * @param {{deny: string[], kept: string[], availableGroups: Record<string, number>, groups: string[]}} input
47
+ * @returns {object}
48
+ */
49
+ export function buildPreview(input) {
50
+ const deny = Array.isArray(input && input.deny) ? input.deny : []
51
+ const kept = Array.isArray(input && input.kept) ? input.kept : []
52
+ const availableGroups = (input && input.availableGroups) || {}
53
+ const groups = Array.isArray(input && input.groups) ? input.groups : []
54
+
55
+ // 用户点名的组里,哪些在这台机器上真的存在
56
+ const foundGroups = groups.filter((g) => Object.hasOwn(availableGroups, g))
57
+ const missingGroups = groups.filter((g) => !Object.hasOwn(availableGroups, g))
58
+
59
+ return {
60
+ groups,
61
+ foundGroups,
62
+ missingGroups,
63
+ availableGroups,
64
+ willKeep: kept.length,
65
+ willDeny: deny.length,
66
+ keepSample: kept.slice(0, SAMPLE_SIZE),
67
+ denySample: deny.slice(0, SAMPLE_SIZE),
68
+ }
69
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Task guard",
4
+ "description": "Narrows the tool table to the named groups at task start (deny-only) and restores it at task end; ships a cross-process lock, a cost gate and a usage ledger."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "任务收窄锁",
4
+ "description": "任务开始时按组收窄工具表(只用 deny)、结束时恢复;自带跨进程锁、成本闸门与任务台账。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "dsh-task-guard",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.6.2",
4
+ "type": "module",
5
+ "main": "./index.js",
6
+ "description": "Task-scoped tool narrowing for DSH: narrow the tool table once when a task starts (deny-only), restore it when the task ends. Ships a cross-process task lock and a taskId-tagged usage log. Zero dependencies.",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./package.json": "./package.json",
10
+ "./locale/*.json": "./locale/*.json",
11
+ "./icon": "./icon.svg"
12
+ },
13
+ "dsh": {
14
+ "bundle": {
15
+ "patch": "./cordis.patch.yml"
16
+ }
17
+ },
18
+ "engines": {
19
+ "dsh": ">=0.2.1-rc.4"
20
+ },
21
+ "files": [
22
+ "index.js",
23
+ "lib/*.js",
24
+ "cordis.patch.yml",
25
+ "README.md",
26
+ "locale/*.json",
27
+ "icon.svg"
28
+ ],
29
+ "scripts": {
30
+ "test": "node test/task-guard.test.mjs"
31
+ },
32
+ "keywords": [
33
+ "dsh-plugin",
34
+ "context-caching",
35
+ "task-scope",
36
+ "deepseek-harness",
37
+ "tools",
38
+ "context-cache"
39
+ ],
40
+ "license": "MIT",
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/qwq1223211/dsh-task-guard.git"
44
+ },
45
+ "homepage": "https://github.com/qwq1223211/dsh-task-guard#readme",
46
+ "bugs": {
47
+ "url": "https://github.com/qwq1223211/dsh-task-guard/issues"
48
+ }
49
+ }