dsh-copilot-vision 1.0.0 → 1.3.1

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
@@ -6,23 +6,30 @@
6
6
 
7
7
  ### 双轨执行
8
8
 
9
- 本地轨道经 robot-ts 直连 Windows(屏幕捕获 + 鼠标注入 + 低层键盘钩子);`src/vp-ssh-core.js` 预留远程轨道接口。当前发版只启用本地轨道,远程不注册任何工具——接口在那里,能力按需放开。
9
+ 本地轨道经 robot-ts 直连 Windows(屏幕捕获 + 鼠标注入 + 键盘注入 + 低层键盘钩子);`src/vp-ssh-core.js` 预留远程轨道接口。当前发版只启用本地轨道,远程不注册任何工具——接口在那里,能力按需放开。
10
10
 
11
11
  ### 动态视觉感知:不传全屏,传差分
12
12
 
13
- `vp_screenshot_diff` 用 pixelmatch 逐像素比对,4 邻域连通块合并,小于 100 像素的噪声(光标、闪烁)直接丢弃,把「哪里变了」而不是「整屏是什么」交还给模型。`vp_wait_change` 更进一步:每 100ms 在内存里截屏比对,**轮询期零落盘**,变化出现的瞬间才落一张正式截图。「等页面加载」从反复截图碰运气,变成一次有明确语义的等待。换显示器或改缩放后坐标即不可信——宽高或 scaleFactor 不一致直接报错,绝不重采样硬算。
13
+ `vp_screenshot_diff` 用 pixelmatch 逐像素比对,4 邻域连通块合并,小于 100 像素的噪声(光标、闪烁)直接丢弃,把「哪里变了」而不是「整屏是什么」交还给模型。`vp_wait_change` 更进一步:每 100ms 在内存里截屏比对,**轮询期零落盘**,变化出现的瞬间才落一张正式截图。「等页面加载」从反复截图碰运气,变成一次有明确语义的等待。换显示器或改缩放后坐标即不可信——宽高或 scaleFactor 不一致直接报错,绝不重采样硬算。网格放大图不再固定 4 倍:按区域面积自适应倍率——≤50,000 物理像素 4x、≤200,000 2x、更大 1x 只画网格,大区域不再产出巨图;同时以「放大后单边 ≤2,000 像素」兜底,4,344×4 这类细长条不会被放大成 17,376 宽的畸形长图。每个区域以 `gridScale` 字段标明实际倍数。`coverage` 只是变色像素的比例,白底同类元素位移时可能仅 2% 却已改天换地——判断变化要看 `regions` 与 `gridPath` 的实际内容,不能只看覆盖率。上下文策略同样内置在工具 description 里(「长程上下文纪律」):旧图会被清理、历史路径随时可能失效,模型必须以最新一次观察为准,偏离预期就直接重新截图,不翻历史记录。
14
14
 
15
- ### 人机共驾安全
15
+ ### 人机共驾安全:持续授权 + 物理热键急停
16
16
 
17
- 授权是一个五态状态机(unauthorized / pending-once / authorized-once / pending-persistent / persistent),铁律:**授权只由人的动作进入和撤销,不由时钟改变**——没有到期时间,没有剩余毫秒,持续授权等再久都不失效(回归测试用 35 秒等待证明这一点)。人有三条通道:问答卡片上点「允许」、Shift+Space 热键随时接管或撤销、JSON Lines 审计日志事后追溯(只记状态机动作与来源,不记任何按键内容)。点击闸门顺序是授权 → 参数 → 越界 → 消费,失败路径不浪费单次授权。
17
+ V1.3 起授权只有三态:`unauthorized` / `pending-persistent` / `persistent`。铁律:**授权只由人的动作进入和撤销,不由时钟改变**——没有到期时间,没有剩余毫秒,持续授权等再久都不失效(回归测试用 35 秒等待证明这一点)。
18
+
19
+ - **进入授权**:按 Shift+Space 直接进入持续授权;或模型调 `vp_request_auth(mode='persistent')`,你在问答卡片上确认一次。
20
+ - **急停**:任何时候再按一次 Shift+Space,立即回到 `unauthorized`。这是物理热键,不经过模型、不依赖宿主 UI。
21
+ - **未授权即拦截**:`vp_click` / `vp_click_relative` / `vp_type` / `vp_key` 在未授权时直接抛错并提示人类开启持续授权,不会自己弹卡片。
22
+ - **审计**:JSON Lines 记录每次授权迁移,以及每次注入的放行/拒绝(`actionAllowed` / `actionDenied`),只记动作与工具名,不记任何按键内容。
23
+
24
+ 为什么取消单次授权:V1.2.2 实测发现,点卡片上的「允许」会把焦点抢回 DSH 窗口,紧接着的注入就落进错误窗口。V1.3 让键盘与鼠标共用持续授权,注入前不再有任何焦点跳转。
18
25
 
19
26
  <!-- 配图位 1:授权问答卡片截图(宿主内 vp_request_auth 弹出的人类确认卡片) -->
20
27
 
21
28
  ### 位置编码注入
22
29
 
23
- 大模型在原尺寸小图上数错行、估偏上百像素是常态。Vision-Pilot 把坐标计算交还给代码:每个差分区域附一张 `gridPath` 放大图——4x 最近邻放大,每 20 物理像素一条半透明红线,网格原点就是区域左上角(pngjs + 手写 Bresenham,零新增依赖)。模型只负责数格子、报百分比,`vp_click_relative` 完成精确换算。实测:模型按网格估点落进文字区,误差 ≤ 2.5px;同一场景靠肉眼估坐标偏过 111px。
30
+ 大模型在原尺寸小图上数错行、估偏上百像素是常态。Vision-Pilot 把坐标计算交还给代码:每个差分区域附一张 `gridPath` 放大图——按区域面积与单边长度自适应倍率(4x / 2x / 1x,放大后单边 ≤2,000 像素)的最近邻放大,每 20 物理像素一条半透明红线,网格原点就是区域左上角(pngjs + 手写 Bresenham,零新增依赖)。模型只负责数格子、报百分比,`vp_click_relative` 完成精确换算。实测:模型按网格估点落进文字区,误差 ≤ 2.5px;同一场景靠肉眼估坐标偏过 111px。
24
31
 
25
- <!-- 配图位 2:vp-grid-*.png 网格放大图示例(4x,红线每格 = 20 物理像素) -->
32
+ <!-- 配图位 2:vp-grid-*.png 网格放大图示例(自适应倍率 4x/2x/1x 且单边 ≤2,000 像素,红线每格 = 20 物理像素) -->
26
33
 
27
34
  ## 工具集
28
35
 
@@ -31,14 +38,50 @@
31
38
  | `vp_screenshot` | 截主屏全屏,返回 `shotId`——后续一切操作的句柄。 |
32
39
  | `vp_screenshot_diff` | 与历史截图逐像素差分,输出差异区域 + 网格放大图。 |
33
40
  | `vp_wait_change` | 高频轮询等待屏幕变化,变化即返回;超时不消耗 shotId。 |
34
- | `vp_click` | 按绝对物理坐标点击(内部换算逻辑坐标,需已授权)。 |
35
- | `vp_click_relative` | 在差分区域的网格图内按相对百分比点击(需已授权)。 |
36
- | `vp_request_auth` | 请求人类授权(once / persistent),经宿主问答卡片等待确认。 |
41
+ | `vp_click` | 按绝对物理坐标点击(内部换算逻辑坐标,需持续授权)。 |
42
+ | `vp_click_relative` | 在差分区域的网格图内按相对百分比点击(需持续授权)。 |
43
+ | `vp_type` | 在当前焦点输入一段文本(Unicode 直注,中英文通吃;**绝不自动回车**;需持续授权)。 |
44
+ | `vp_key` | 发送一次按键或组合键(enter / tab / esc / ctrl+c);**回车只能由它触发**;需持续授权。 |
45
+ | `vp_request_auth` | 请求人类开启持续授权(仅 `mode='persistent'`),经宿主问答卡片等待确认。 |
37
46
  | `vp_auth_status` | 只读查询授权状态、热键可用性与审计日志路径。 |
47
+ | `vp_summarize` | **收工必调**:归档关键截图、写入总结,清空其余全部临时碎片。 |
48
+
49
+ ## 语义级记忆压缩(vp_summarize)
50
+
51
+ 临时图会跨会话堆积。`vp_summarize` 让模型在**一个任务周期结束、准备收工或切换任务时**主动做一次收尾:
52
+
53
+ 1. `keepShotIds` 里的关键截图(例如「初始页」和「结果页」)被移进 `archives/` 子目录,索引路径同步更新——归档后仍然可以做差分回看;
54
+ 2. `summary` 连同本次保留清单追加写入 `archive-summary.txt`;
55
+ 3. 当前目录下其余所有 `vp-shot-*`、`vp-diff-*`、`vp-grid-*` 立即删除,返回各类碎片数量。
56
+
57
+ **保护区**:`audit.log`(证据链)、`shot-index.json`(shotId 播种与相对点击依赖)、`archives/`(冷存储,一次归档永久保留)永远不会被删除。被清理掉的 shotId 在索引里保留条目,避免 `nextShotId` 回退导致旧 shotId 静默指向新截图;此时对它们做差分/等待会得到「文件已被 vp_summarize 清理,请重新截图」的明确错误。
58
+
59
+ 与 `paths.js` 里的 `cleanupVisualFragments`(启动时删 3 天前的 diff/grid)**不冲突**:那个是按时间的兜底,只碰 diff/grid;本工具是按语义的主清理,目标集合是它的超集。
60
+
61
+ ## 键盘注入:与鼠标共用持续授权
62
+
63
+ 键盘能力分两个工具:`vp_type` 负责输入文本,`vp_key` 负责发送按键与组合键。V1.3 起键盘不再有独立的逐次确认通道,规则只有三条:
64
+
65
+ 1. **键盘与鼠标共用 `checkCanClick()`**——`persistent` 下两者一律直接放行,`unauthorized` 下一律直接拦截。注入前不再弹卡片,因此不会出现「点允许 → 焦点跳回 DSH → 注入落错窗口」。
66
+ 2. **`vp_type` 绝不自动发送 Enter**——回车只能由 `vp_key` 单独触发,且必须在 `vp_key` 的 `thought` 里说明意图。
67
+ 3. **每个动作工具都要写旁白**——`vp_click` / `vp_click_relative` / `vp_type` / `vp_key` 都带必填的 `thought` 参数,用一句自然语言说明当前动作,原样回显在返回 JSON 里,作为给人看的进度旁白。
68
+
69
+ ### 输入法(IME)实测结论
70
+
71
+ robot-ts 有两条注入通道,本插件按键的性质分流:
72
+
73
+ | 通道 | 底层 | 适用 |
74
+ |---|---|---|
75
+ | Unicode 直注 | `KEYEVENTF_UNICODE` | 文本字符,绕开键盘布局与输入法 |
76
+ | 真实扫描码 | `KEYEVENTF_SCANCODE` | 控制键与组合键,保留真实按键语义 |
77
+
78
+ 本机中文输入法(WeType)实测会**吞掉扫描码注入的字母键**:按下不落字,要到下一次 Enter 才吐出幽灵字符;还会把引号改写成中文弯引号。因此 `vp_key` 对「无修饰键的字符键(字母/数字/符号)」改走 Unicode 直注——`vp_key "a"` 会立刻落下 `a`,不会留下幽灵文本。控制键(enter / esc / tab / 方向键 / backspace / delete / f1-f12)、空格、以及任何带 ctrl / alt / meta 的组合键走真实扫描码:实测 Ctrl 修饰键确实送达(Ctrl+V 能粘上内容),Shift+End 能选出整段。返回值里的 `method` 字段会告诉你这次走的是哪条通道。
79
+
80
+ 注意:如果用户正在用输入法打字,组合串还挂在输入法里,此时 `vp_key enter` 会把它一起提交出去。
38
81
 
39
82
  ## 长程任务工作流(The OSA V Loop)
40
83
 
41
- 这段协议内嵌在全部 7 个工具的 description 里,模型每次调用都会被提醒:
84
+ 这段协议内嵌在全部 10 个工具的 description 里,模型每次调用都会被提醒:
42
85
 
43
86
  1. **观察 Observe**——每步开始前,用截图或差分确认当前屏幕状态。
44
87
  2. **行动 Act**——点击目标(绝对坐标或相对百分比)。
@@ -57,33 +100,36 @@ DSH_HOME=<DSH_HOME> dsh plugin --profile web add dsh-copilot-vision
57
100
 
58
101
  # 或从本地 tarball 安装(开发调试)
59
102
  npm pack
60
- DSH_HOME=<DSH_HOME> dsh plugin --profile web add file:<绝对路径>/dsh-copilot-vision-1.0.0.tgz
103
+ DSH_HOME=<DSH_HOME> dsh plugin --profile web add file:<绝对路径>/dsh-copilot-vision-1.3.1.tgz
61
104
  ```
62
105
 
63
106
  - **宿主**:web(问答卡片授权)/ headless(无 UI 答者,只能 Shift+Space,需真人在场)/ desktop(待 DSH 市场审核后内置验证)。
64
- - **授权**:模型调 `vp_request_auth`,你在卡片上确认;或随时 Shift+Space 进入持续授权、再按撤销。
107
+ - **授权**:按 Shift+Space 开启或撤销持续授权;模型也可调 `vp_request_auth(mode='persistent')` 请求,你在卡片上确认一次。单次授权已取消。
65
108
  - **平台**:仅 Windows、仅本地主屏。pnpm 11 拦截构建脚本时,在 profile 的 `pnpm-workspace.yaml` 中允许 `robot-ts` 构建;native 缺失时插件仍可加载,调用时返回可读错误。
66
109
  - **落盘**:截图、索引、审计日志默认在 `os.tmpdir()/vision-pilot-shots/`,`VP_SHOT_DIR` 可整体重定向。
67
110
  - **迭代**:tarball 是快照,同版本重新 `add` 会被判 "Already up to date"——改源码后标准流程是 `npm pack` → `remove` → `add`。
68
111
 
69
112
  ## 限制与边界
70
113
 
71
- - **输入能力边界**:不支持键盘注入、拖拽、滚轮、双击。模型无法自己「在地址栏输入」,该步需人代做。
114
+ - **输入能力边界**:支持键盘输入(`vp_type` 文本 / `vp_key` 按键与组合键);不支持拖拽、滚轮、双击。键盘只作用于**当前焦点控件**,模型无法自己「在地址栏输入」前必须先用鼠标点中输入框。
115
+ - **输入法干扰**:中文输入法会吞掉扫描码注入的字母键(按下不落字,要到下一次 Enter 才吐幽灵字符)。插件已对无修饰键的字符键改走 Unicode 直注绕开它,但用户在输入法里挂着的组合串仍会被 `vp_key enter` 一起提交。
72
116
  - **聊天窗口自干扰**:宿主聊天窗口的流式重绘会被当成屏幕变化(实测覆盖率约为变化门限的 14 倍)。等页面加载时,先把聊天窗口移出主屏或最小化。
73
117
  - **鼠标移动不可检测**:Windows 屏幕捕获不含硬件光标,挪鼠标不产生任何像素变化。
74
118
  - **热键无法区分真人与合成按键**:按键事件流不携带注入标记,审计日志只作事后追溯、不作防伪。真正的护栏在插件之外——宿主安全分类器与操作纪律。
75
119
  - **索引并发**:截图索引整份覆盖写、不做跨进程并发保护,多进程共用 `VP_SHOT_DIR` 会互相覆盖;回归测试一律用独立目录。
76
- - **临时目录卫生**:超过 3 天的差分/网格切片(`vp-diff-*` / `vp-grid-*`)会在插件启动时被自动清理,基准截图(`vp-shot-*`)和审计日志永久保留。
120
+ - **临时目录卫生**:超过 3 天的差分/网格切片(`vp-diff-*` / `vp-grid-*`)会在插件启动时被自动清理;模型在收工时调用 `vp_summarize` 会把关键截图归档到 `archives/` 并清空其余全部碎片。基准截图(`vp-shot-*`)、审计日志、索引与 `archives/` 永久保留。
77
121
 
78
122
  ## 本地验证
79
123
 
80
124
  > 以下脚本为开发期本机验证用,含本机绝对路径,**不随仓库与 npm 包分发**(`.gitignore` 已排除 `test-*.mjs`)。克隆仓库后需自备对应脚本。
81
125
 
82
126
  ```bash
83
- node test-mvp.mjs # 五态授权 + 热键/卡片竞态 + 无时钟衰减(约 45s,真实点击屏幕中心)
127
+ node test-mvp.mjs # 三态授权 + 热键急停/接管 + 无时钟衰减(约 45s,真实点击屏幕中心)
84
128
  node test-diff.mjs # 差分与索引(单元层不碰屏幕;--integration 需配合动屏)
85
129
  node test-relative.mjs # 网格渲染 + 相对坐标换算 + 未授权闸门(不截图、不注入)
86
130
  node test-wait.mjs # 轮询语义(--integration 配合静屏/动屏/假基线)
131
+ node test-keyboard.mjs # 键名解析 + 注入通道分流 + 持续授权闸门(--live 会真的抢一次键盘焦点)
132
+ node test-summarize.mjs # 记忆压缩:5 张截图保留 2 张,校验清理、保护区与幂等(不碰屏幕)
87
133
  ```
88
134
 
89
135
  ---
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-copilot-vision",
3
- "version": "1.0.0",
4
- "description": "Computer Use enhancement plugin for DeepSeek Harness (DSH). 纯 JS 视觉差分、网格位置编码、长程任务闭环与人机共驾授权。",
3
+ "version": "1.3.1",
4
+ "description": "Computer Use enhancement plugin for DeepSeek Harness (DSH). 纯 JS 视觉差分、网格位置编码、键盘注入、语义级记忆压缩、长程任务闭环与人机共驾授权。",
5
5
  "main": "./src/index.js",
6
6
  "scripts": {
7
7
  "test": "echo \"Error: no test specified\" && exit 1"
package/src/actions.js CHANGED
@@ -5,8 +5,9 @@
5
5
  import { mkdir } from 'node:fs/promises'
6
6
  import { join } from 'node:path'
7
7
  import { physicalToLogical, relativeToPhysical } from './coords.js'
8
+ import { parseKeySpec, resolveUnicodeChar } from './keys.js'
8
9
  import { loadRobotTs } from './robot-ts.js'
9
- import { checkCanClick, consumeOnce, getStatus } from './auth.js'
10
+ import { checkCanClick, getStatus } from './auth.js'
10
11
  import { logState } from './audit.js'
11
12
  import { resolveShotsDir } from './paths.js'
12
13
  import { get, nextShotId, record } from './shot-index.js'
@@ -54,25 +55,45 @@ export async function screenshotAction() {
54
55
  }
55
56
  }
56
57
 
57
- /** 按物理像素坐标点击一次。x/y 来自 vp_screenshot 的图像坐标系;授权闸门先于坐标校验。 */
58
+ /**
59
+ * 旁白归一化:模型给的 thought 原样回显;没给(或只给空白)时用一句默认描述兜底。
60
+ * 旁白是给人看的进度说明,不参与任何判定,所以缺失不该让操作失败。
61
+ */
62
+ export function normalizeThought(thought, fallback) {
63
+ if (typeof thought === 'string' && thought.trim() !== '') {
64
+ return thought.trim()
65
+ }
66
+ return fallback
67
+ }
68
+
69
+ const AUTH_HINT =
70
+ "请提示用户按 Shift+Space 开启持续授权,或调用 vp_request_auth(mode='persistent') 申请授权。"
71
+
72
+ /** 四种注入共用的拒绝路径:先落审计,再抛一句模型能照着做的错误。 */
73
+ function denyInjection(tool, gate, extra = {}) {
74
+ const state = getStatus().state
75
+ logState('actionDenied', 'tool', state, state, { tool, reason: gate.reason, ...extra })
76
+ throw new Error(gate.reason === '未授权' ? `未获授权。${AUTH_HINT}` : `${gate.reason}。${AUTH_HINT}`)
77
+ }
78
+
79
+ /** 放行注入后记一笔;不改变授权状态,只留「这一次真的注入了」的证据。 */
80
+ function allowInjection(tool, extra = {}) {
81
+ const state = getStatus().state
82
+ logState('actionAllowed', 'tool', state, state, { tool, ...extra })
83
+ }
84
+
85
+ /**
86
+ * 按物理像素坐标点击一次。x/y 来自 vp_screenshot 的图像坐标系;授权闸门先于坐标校验。
87
+ * 授权只认 persistent:unauthorized 直接抛错,不弹卡片、不消费任何东西。
88
+ */
58
89
  export async function clickAction(args = {}) {
59
90
  const { x, y } = args
60
91
  const gate = checkCanClick()
61
92
  if (!gate.allowed) {
62
93
  // 闸门在坐标校验之前,所以这里的 x/y 还没验过;只在确认是数值时才写进审计,
63
- // 避免把任意入参当成坐标记录。logical 此刻拿不到 scaleFactor(不为此新建会话),故省略。
64
- const state = getStatus().state
65
- logState('clickDenied', 'tool', state, state, {
66
- reason: gate.reason,
67
- ...(Number.isFinite(x) && Number.isFinite(y) ? { physical: { x, y } } : {}),
68
- })
69
- throw new Error(`vp_click 未获授权:${gate.reason}。可调用 vp_request_auth 请求,或按 Shift+Space 进入持续授权。`)
70
- }
71
- const { Session, MouseButton } = loadRobotTs()
72
- const buttons = {
73
- left: MouseButton.Left,
74
- right: MouseButton.Right,
75
- middle: MouseButton.Middle,
94
+ // 避免把任意入参当成坐标记录。
95
+ denyInjection('vp_click', gate,
96
+ Number.isFinite(x) && Number.isFinite(y) ? { physical: { x, y } } : {})
76
97
  }
77
98
  const { button = 'left' } = args
78
99
 
@@ -82,6 +103,18 @@ export async function clickAction(args = {}) {
82
103
  if (x < 0 || y < 0) {
83
104
  throw new Error(`vp_click: 坐标不能为负数,收到 x=${x}, y=${y}`)
84
105
  }
106
+
107
+ return await injectClick({ x, y, button, thought: args.thought, tool: 'vp_click' })
108
+ }
109
+
110
+ /** 真实注入的共用实现:参数校验、越界检查、换算逻辑坐标、移动并点击。 */
111
+ async function injectClick({ x, y, button = 'left', thought, tool }) {
112
+ const { Session, MouseButton } = loadRobotTs()
113
+ const buttons = {
114
+ left: MouseButton.Left,
115
+ right: MouseButton.Right,
116
+ middle: MouseButton.Middle,
117
+ }
85
118
  if (!Object.hasOwn(buttons, button)) {
86
119
  throw new Error(`vp_click: button 必须是 left|right|middle,收到 ${button}`)
87
120
  }
@@ -102,15 +135,12 @@ export async function clickAction(args = {}) {
102
135
  }
103
136
 
104
137
  const logical = physicalToLogical({ x, y }, primary.scaleFactor)
105
- // 参数与越界都通过后才消费放行,避免一次失败的调用浪费单次授权。
106
- const consumed = consumeOnce()
107
- if (!consumed.allowed) {
108
- throw new Error(`vp_click 未获授权:${consumed.reason}。可调用 vp_request_auth 请求,或按 Shift+Space 进入持续授权。`)
109
- }
110
138
  await session.mouse.move(logical)
111
139
  await session.mouse.click(buttons[button])
140
+ allowInjection(tool, { physical: { x, y }, button })
112
141
 
113
142
  return {
143
+ thought: normalizeThought(thought, `点击屏幕物理坐标 (${x}, ${y})`),
114
144
  clicked: true,
115
145
  physical: { x, y },
116
146
  logical,
@@ -121,6 +151,108 @@ export async function clickAction(args = {}) {
121
151
  }
122
152
  }
123
153
 
154
+ /**
155
+ * 在当前焦点位置输入文本。绝对不包含回车:回车只能由 vp_key 单独触发。
156
+ *
157
+ * 授权与鼠标完全一致:persistent 直接放行,unauthorized 直接拦截。
158
+ * V1.3 取消了键盘独立通道——单次确认卡片会把焦点抢回 DSH,注入反而落错窗口。
159
+ */
160
+ export async function typeAction(args = {}) {
161
+ const gate = checkCanClick()
162
+ if (!gate.allowed) {
163
+ denyInjection('vp_type', gate)
164
+ }
165
+
166
+ const { text } = args
167
+ if (typeof text !== 'string' || text.length === 0) {
168
+ throw new Error(`vp_type: text 必须是非空字符串,收到 ${typeof text === 'string' ? '空串' : typeof text}`)
169
+ }
170
+
171
+ const { Session } = loadRobotTs()
172
+ const session = await Session.create()
173
+ try {
174
+ if (!session.capabilities.canInjectKeyboard) {
175
+ throw new Error('当前宿主环境不支持键盘注入(canInjectKeyboard=false)')
176
+ }
177
+ if (!session.capabilities.canTypeUnicode) {
178
+ throw new Error('当前宿主环境不支持 Unicode 文本注入(canTypeUnicode=false)')
179
+ }
180
+ // robot-ts 的 typeText 走 KEYEVENTF_UNICODE 直接注入码点,与键盘布局无关,
181
+ // 中文/emoji 都能原样打出来,不需要剪贴板中转。
182
+ await session.keyboard.typeText(text)
183
+ allowInjection('vp_type', { chars: text.length })
184
+ return {
185
+ typed: text,
186
+ method: 'unicode',
187
+ chars: text.length,
188
+ }
189
+ } finally {
190
+ await session.dispose()
191
+ }
192
+ }
193
+
194
+ /**
195
+ * 只解析按键写法,不碰屏幕、不碰授权。
196
+ * 供宿主层在真正注入前提前报错,键名写错不该走到会话创建。
197
+ */
198
+ export function describeKeySpec(key) {
199
+ const { Key, Modifier } = loadRobotTs()
200
+ const spec = parseKeySpec(key, Key, Modifier)
201
+ if (!Object.values(Key).includes(spec.code)) {
202
+ throw new Error(`vp_key: 键码 ${spec.code} 不在 robot-ts 的键表里(上游键表可能已变更)`)
203
+ }
204
+ return spec
205
+ }
206
+
207
+ /**
208
+ * 发送一次按键或组合键(如 enter / tab / esc / ctrl+c)。
209
+ * 授权闸门与 vp_type 完全相同:persistent 直接放行,unauthorized 直接拦截。
210
+ *
211
+ * 注入通道按键的性质分流(本机实测):
212
+ * - 无修饰键的字符键(字母/数字/符号)→ Unicode 直注。扫描码通道会经过系统输入法,
213
+ * 中文输入法实测会吞掉字母键(按下不落字,下一次 Enter 才吐幽灵字符)、把引号改成中文弯引号。
214
+ * - 其余(控制键、space、带 ctrl/alt/meta 的组合键)→ 真实扫描码,
215
+ * 这样组合键语义是真的(Ctrl+V 实测能粘上),space 也能激活聚焦的按钮。
216
+ */
217
+ export async function keyAction(args = {}) {
218
+ const gate = checkCanClick()
219
+ if (!gate.allowed) {
220
+ denyInjection('vp_key', gate)
221
+ }
222
+
223
+ const { key } = args
224
+ const { Session, Key, Modifier } = loadRobotTs()
225
+ const spec = parseKeySpec(key, Key, Modifier)
226
+ const unicodeChar = resolveUnicodeChar(spec)
227
+
228
+ const session = await Session.create()
229
+ try {
230
+ if (!session.capabilities.canInjectKeyboard) {
231
+ throw new Error('当前宿主环境不支持键盘注入(canInjectKeyboard=false)')
232
+ }
233
+ if (!Object.values(Key).includes(spec.code)) {
234
+ throw new Error(`vp_key: 键码 ${spec.code} 不在 robot-ts 的键表里(上游键表可能已变更)`)
235
+ }
236
+ if (unicodeChar !== null && !session.capabilities.canTypeUnicode) {
237
+ throw new Error('当前宿主环境不支持 Unicode 文本注入(canTypeUnicode=false)')
238
+ }
239
+ if (unicodeChar !== null) {
240
+ await session.keyboard.typeChar(unicodeChar.codePointAt(0))
241
+ } else {
242
+ await session.keyboard.tap(spec.code, spec.modifiers)
243
+ }
244
+ allowInjection('vp_key', { key: spec.spec })
245
+ return {
246
+ key: spec.spec,
247
+ keyName: spec.keyName,
248
+ modifiers: spec.modifiers,
249
+ method: unicodeChar !== null ? 'unicode' : 'scancode',
250
+ }
251
+ } finally {
252
+ await session.dispose()
253
+ }
254
+ }
255
+
124
256
  /**
125
257
  * 把「shotId + 1-based region 序号 + 相对百分比」解析成全局物理坐标。
126
258
  * 只查索引与做算数,不碰屏幕、不碰授权,所以可以被测试直接调用。
@@ -152,23 +284,26 @@ export function resolveRelativeTarget({ shotId, index, xPercent, yPercent }) {
152
284
  }
153
285
 
154
286
  /**
155
- * 在差分区域内部按相对位置点击。授权闸门与 vp_click 保持同一顺序:
156
- * 授权 → 参数合法性 → 越界 → 消费,所以未授权时不会读索引、不会新建 robot-ts 会话。
287
+ * 在差分区域内部按相对位置点击。授权闸门与 vp_click 同一顺序:
288
+ * 授权 → 参数合法性 → 越界 → 注入,所以未授权时不会读索引、不会新建 robot-ts 会话。
157
289
  */
158
290
  export async function clickRelativeAction(args = {}) {
159
291
  const gate = checkCanClick()
160
292
  if (!gate.allowed) {
161
- const state = getStatus().state
162
- logState('clickDenied', 'tool', state, state, { reason: gate.reason, tool: 'vp_click_relative' })
163
- throw new Error(
164
- `vp_click_relative 未获授权:${gate.reason}。可调用 vp_request_auth 请求,或按 Shift+Space 进入持续授权。`,
165
- )
293
+ denyInjection('vp_click_relative', gate)
166
294
  }
167
295
 
168
296
  const { shotId, index, xPercent, yPercent, button = 'left' } = args
169
297
  const { bbox, physical } = resolveRelativeTarget({ shotId, index, xPercent, yPercent })
170
- const clicked = await clickAction({ x: physical.x, y: physical.y, button })
298
+ const clicked = await injectClick({
299
+ x: physical.x,
300
+ y: physical.y,
301
+ button,
302
+ thought: args.thought,
303
+ tool: 'vp_click_relative',
304
+ })
171
305
  return {
306
+ thought: clicked.thought,
172
307
  clicked: true,
173
308
  shotId,
174
309
  index,
package/src/audit.js CHANGED
@@ -8,13 +8,14 @@ import { join } from 'node:path'
8
8
  import { resolveShotsDir } from './paths.js'
9
9
 
10
10
  // 状态机会记录的动作集合(回归测试据此校验,不要在这里放实现分支)。
11
+ // actionAllowed / actionDenied 不改变授权状态,只记录某次注入被放行或被闸门拦截。
11
12
  export const AUDIT_ACTIONS = [
12
13
  'requestAuth',
13
14
  'confirmPending',
14
15
  'cancelPending',
15
16
  'togglePersistent',
16
- 'consumeOnce',
17
- 'clickDenied',
17
+ 'actionAllowed',
18
+ 'actionDenied',
18
19
  ]
19
20
 
20
21
  export function auditLogPath() {
package/src/auth.js CHANGED
@@ -1,23 +1,23 @@
1
1
  // Vision-Pilot 授权状态机(宿主无关,纯内存单例)。
2
2
  //
3
- // 原则:授权状态只由人的明确动作改变,不由时钟改变。
4
- // 因此本文件没有任何到期判断,也不返回 remainingMs —— persistent 一经进入就一直有效,
5
- // 直到用户再次按 Shift+Space 撤销。
3
+ // 授权模型(V1.3):只有两种权限结论 + 一种等待态。
4
+ // unauthorized 未授权,一切鼠标与键盘操作直接拦截
5
+ // pending-persistent 已请求持续授权、等待人类在卡片上确认
6
+ // persistent 持续授权中,鼠标与键盘一律直接放行
6
7
  //
7
- // 五种 state:
8
- // unauthorized 未授权
9
- // pending-once 已请求单次授权、等待人类确认(带 pendingRequest)
10
- // authorized-once 已确认、等待下一次点击消费(无 pendingRequest)
11
- // pending-persistent 已请求持续授权、等待人类确认
12
- // persistent 持续授权中,无到期
8
+ // 铁律:授权只由人的明确动作改变——按 Shift+Space,或在卡片上确认 persistent 请求。
9
+ // 不由时钟改变:没有任何到期判断,也不返回 remainingMs。
13
10
  //
14
- // 分工:checkCanClick() 只看是否放行、不改变状态;consumeOnce() 才真正消费
15
- // authorized-once。因此坐标越界等失败路径不会浪费一次单次授权。
11
+ // V1.3 取消了两样东西,别再引回来:
12
+ // - 单次授权(pending-once / authorized-once / consumeOnce):点「允许」会把焦点抢回
13
+ // DSH 窗口,紧接着的注入落进错误窗口,是实测确认的故障来源。
14
+ // - 键盘独立通道(keyboardGranted / checkCanKeyboard / consumeKeyboard):键盘与鼠标
15
+ // 共用 checkCanClick(),persistent 下一律放行,unauthorized 下一律拦截。
16
16
  //
17
- // 每次动作(含被拒绝的点击)都会追加一条审计记录,见 src/audit.js。
17
+ // 每次授权动作与每次被拒绝/放行的注入都追加审计记录,见 src/audit.js。
18
18
  //
19
- // 竞态约定(P0 修复):pendingRequest 的生命周期绑一个 AbortController。任何清空 pending 的
20
- // 动作(热键接管/撤销、cancelPending)都必须 abort 它,让调用方那侧「还在等人点卡片」的
19
+ // 竞态约定:pendingRequest 的生命周期绑一个 AbortController。任何清空 pending 的动作
20
+ // (热键接管/撤销、cancelPending)都必须 abort 它,让调用方那侧「还在等人点卡片」的
21
21
  // promise 立刻收到 AUTH_CANCELLED 并正常返回,而不是等那个答案迟到后撞进 confirmPending()。
22
22
  // confirmPending 只认自己那一次的 requestId:对不上就是迟到的答案,安全丢弃。
23
23
 
@@ -25,26 +25,29 @@ import { logState } from './audit.js'
25
25
 
26
26
  const state = {
27
27
  persistent: false,
28
- onceGranted: false,
29
- /** @type {{requestId: string, mode: 'once'|'persistent', requestedAt: number, controller: AbortController} | null} */
28
+ /** @type {{requestId: string, mode: 'persistent', requestedAt: number, controller: AbortController} | null} */
30
29
  pendingRequest: null,
31
30
  requestSeq: 0,
32
31
  }
33
32
 
34
33
  /** 当前授权状态(只读快照,不含内部的 AbortController)。 */
35
34
  export function getStatus() {
35
+ // 待确认请求独立于 state 展示:persistent 在位时也可能挂着一张卡片,
36
+ // 藏起来的话人/model 都看不到「还有一张卡在等」。
37
+ const pending = state.pendingRequest === null
38
+ ? {}
39
+ : {
40
+ pendingRequest: {
41
+ requestId: state.pendingRequest.requestId,
42
+ mode: state.pendingRequest.mode,
43
+ requestedAt: state.pendingRequest.requestedAt,
44
+ },
45
+ }
36
46
  if (state.persistent) {
37
- return { state: 'persistent' }
47
+ return { state: 'persistent', ...pending }
38
48
  }
39
49
  if (state.pendingRequest !== null) {
40
- const { requestId, mode, requestedAt } = state.pendingRequest
41
- return {
42
- state: mode === 'once' ? 'pending-once' : 'pending-persistent',
43
- pendingRequest: { requestId, mode, requestedAt },
44
- }
45
- }
46
- if (state.onceGranted) {
47
- return { state: 'authorized-once' }
50
+ return { state: 'pending-persistent', ...pending }
48
51
  }
49
52
  return { state: 'unauthorized' }
50
53
  }
@@ -65,14 +68,11 @@ function clearPending(reason) {
65
68
  }
66
69
 
67
70
  /**
68
- * 模型侧发起授权请求。只登记 pendingRequest,不改变放行状态。
71
+ * 登记一个持续授权请求,不改变放行状态。
69
72
  * 返回的 signal 会在该请求被热键接管或被取消时 abort,调用方用它把「等人回答」的
70
73
  * promise 换成一个可取消的等待。
71
74
  */
72
- export function requestAuth(mode) {
73
- if (mode !== 'once' && mode !== 'persistent') {
74
- throw new Error(`requestAuth: mode 必须是 once 或 persistent,收到 ${mode}`)
75
- }
75
+ function startRequest() {
76
76
  const previousState = getStatus().state
77
77
  // 连着发两次请求时,前一张卡片不能变成悬空的:它一被答就撞上「已丢弃」,
78
78
  // 所以这里同样要 abort 掉,让前一次等待立刻正常返回。
@@ -80,13 +80,24 @@ export function requestAuth(mode) {
80
80
  state.requestSeq += 1
81
81
  const requestId = `auth-${state.requestSeq}`
82
82
  const controller = new AbortController()
83
- state.pendingRequest = { requestId, mode, requestedAt: Date.now(), controller }
84
- audit('requestAuth', 'tool', previousState, { requestId, mode })
83
+ state.pendingRequest = { requestId, mode: 'persistent', requestedAt: Date.now(), controller }
84
+ audit('requestAuth', 'tool', previousState, { requestId, mode: 'persistent' })
85
85
  return { requestId, signal: controller.signal, status: getStatus() }
86
86
  }
87
87
 
88
88
  /**
89
- * 人侧确认:pending-once → authorized-once;pending-persistent → persistent。
89
+ * 模型侧发起持续授权请求。只登记 pendingRequest,不改变放行状态。
90
+ * mode 只为接口稳定保留:V1.3 起只接受 'persistent',other 值直接报错。
91
+ */
92
+ export function requestAuth(mode = 'persistent') {
93
+ if (mode !== 'persistent') {
94
+ throw new Error(`requestAuth: mode 只支持 persistent(V1.3 已取消单次授权),收到 ${mode}`)
95
+ }
96
+ return startRequest()
97
+ }
98
+
99
+ /**
100
+ * 人侧确认:pending-persistent → persistent。
90
101
  * 只认 requestId 对应的那一次请求。对不上(已被热键接管或已取消)时不改状态、
91
102
  * 不写审计、不抛错,返回 confirmed:false —— 迟到的答案本来就该被丢弃。
92
103
  */
@@ -97,43 +108,31 @@ export function confirmPending(requestId) {
97
108
  }
98
109
  const previousState = getStatus().state
99
110
  state.pendingRequest = null
100
- if (request.mode === 'once') {
101
- state.onceGranted = true
102
- } else {
103
- state.persistent = true
104
- state.onceGranted = false
105
- }
111
+ state.persistent = true
106
112
  request.controller.abort({ code: 'AUTH_CANCELLED', reason: '已由人确认' })
107
113
  audit('confirmPending', 'user-question', previousState, { mode: request.mode, requestId })
108
114
  return { ...getStatus(), confirmed: true }
109
115
  }
110
116
 
111
- /** 取消待确认请求与单次授权。persistent 不受影响(它只能由热键撤销)。 */
117
+ /** 取消待确认请求。persistent 不受影响(它只能由热键撤销)。 */
112
118
  export function cancelPending() {
113
119
  const previousState = getStatus().state
114
- state.onceGranted = false
115
120
  clearPending({ code: 'AUTH_CANCELLED', reason: '已取消' })
116
121
  audit('cancelPending', 'tool', previousState)
117
122
  return getStatus()
118
123
  }
119
124
 
120
125
  /**
121
- * 热键调用。热键优先级最高,且是 persistent 唯一的入口与出口:
122
- * persistent 中 → 撤销回 unauthorized;其余状态 → 进入 persistent(清 pending/authorized-once)。
123
- *
124
- * 撤销分支必须全清:persistent 期间人确认的「允许一次」会置 onceGranted,而 persistent 下的点击
125
- * 走 persistent 放行分支、不消费它。若撤销时不清 onceGranted,撤销后状态就成了 authorized-once,
126
- * 下一次点击仍会静默放行 —— 人以为已经收干净了。
126
+ * 热键调用,也是 persistent 唯一的入口与出口:
127
+ * persistent 中 → 撤销回 unauthorized;其余状态 → 进入 persistent(清 pending)。
127
128
  */
128
129
  export function togglePersistent(source = 'hotkey') {
129
130
  const previousState = getStatus().state
130
131
  if (state.persistent) {
131
132
  state.persistent = false
132
- state.onceGranted = false
133
133
  clearPending({ code: 'AUTH_CANCELLED', reason: '热键撤销' })
134
134
  } else {
135
135
  state.persistent = true
136
- state.onceGranted = false
137
136
  clearPending({ code: 'AUTH_CANCELLED', reason: '热键接管' })
138
137
  }
139
138
  audit('togglePersistent', source, previousState)
@@ -141,38 +140,13 @@ export function togglePersistent(source = 'hotkey') {
141
140
  }
142
141
 
143
142
  /**
144
- * 消费一次放行。持续授权下放行且不改变状态;单次授权放行即清空。
145
- * 返回 { allowed, mode?, reason? }。
143
+ * 鼠标与键盘共用的放行判断(不消费、不改状态)。
144
+ * 键盘不再有独立通道:persistent 下鼠标与键盘一律放行。
146
145
  */
147
- export function consumeOnce() {
148
- const previousState = getStatus().state
149
- if (state.persistent) {
150
- audit('consumeOnce', 'tool', previousState, { allowed: true, mode: 'persistent' })
151
- return { allowed: true, mode: 'persistent' }
152
- }
153
- if (state.onceGranted) {
154
- state.onceGranted = false
155
- audit('consumeOnce', 'tool', previousState, { allowed: true, mode: 'once' })
156
- return { allowed: true, mode: 'once' }
157
- }
158
- audit('consumeOnce', 'tool', previousState, {
159
- allowed: false,
160
- reason: state.pendingRequest !== null ? '授权请求待确认' : '未授权',
161
- })
162
- return {
163
- allowed: false,
164
- reason: state.pendingRequest !== null ? '授权请求待确认' : '未授权',
165
- }
166
- }
167
-
168
- /** vp_click 的放行判断(不消费)。 */
169
146
  export function checkCanClick() {
170
147
  if (state.persistent) {
171
148
  return { allowed: true, reason: '持续授权有效' }
172
149
  }
173
- if (state.onceGranted) {
174
- return { allowed: true, reason: '单次授权待使用' }
175
- }
176
150
  if (state.pendingRequest !== null) {
177
151
  return { allowed: false, reason: '授权请求待确认' }
178
152
  }
@@ -182,19 +156,14 @@ export function checkCanClick() {
182
156
  /** 测试专用:重置为未授权。 */
183
157
  export function _resetForTests() {
184
158
  state.persistent = false
185
- state.onceGranted = false
186
159
  clearPending({ code: 'AUTH_CANCELLED', reason: '测试重置' })
187
160
  state.requestSeq = 0
188
161
  }
189
162
 
190
- /**
191
- * 测试专用:读内部标志位。persistent 期间 onceGranted 是否还挂着,
192
- * getStatus() 看不出来(persistent 优先返回 'persistent'),只能从这里取证。
193
- */
163
+ /** 测试专用:读内部标志位(getStatus 看不到 persistent 期间挂着的 pending 详情之外的东西)。 */
194
164
  export function _peekForTests() {
195
165
  return {
196
166
  persistent: state.persistent,
197
- onceGranted: state.onceGranted,
198
167
  hasPending: state.pendingRequest !== null,
199
168
  }
200
169
  }