dsh-copilot-vision 1.1.0 → 1.3.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.
@@ -0,0 +1,131 @@
1
+ // 语义级记忆压缩:把关键截图归档,其余临时碎片一次清空。
2
+ //
3
+ // 与 paths.js 的 cleanupVisualFragments 的分工(两者不冲突):
4
+ // - cleanupVisualFragments 是「按时间的兜底」:只在插件启动时删 3 天前的 diff/grid,
5
+ // 永远不碰 vp-shot-*,永远不递归子目录。
6
+ // - 本函数是「按语义的主清理」:由模型在收工时显式调用,删所有 diff/grid 和没被保留的 vp-shot-*。
7
+ // 主清理的目标集合是兜底清理的超集,先跑谁都不会破坏另一方。
8
+ //
9
+ // 绝对不动的三样:audit.log(证据链)、shot-index.json(shotId 播种与相对点击依赖它)、archives/(归档本体)。
10
+ import { existsSync } from 'node:fs'
11
+ import { appendFile, mkdir, readdir, rename, stat, unlink } from 'node:fs/promises'
12
+ import { basename, join } from 'node:path'
13
+ import { resolveShotsDir } from './paths.js'
14
+ import { get, relocate } from './shot-index.js'
15
+
16
+ export const ARCHIVE_DIR_NAME = 'archives'
17
+ export const SUMMARY_FILE_NAME = 'archive-summary.txt'
18
+
19
+ export function archiveDir() {
20
+ return join(resolveShotsDir(), ARCHIVE_DIR_NAME)
21
+ }
22
+
23
+ export function summaryPath() {
24
+ return join(resolveShotsDir(), SUMMARY_FILE_NAME)
25
+ }
26
+
27
+ const DIFF_RE = /^vp-diff-\d+-\d+\.png$/
28
+ const GRID_RE = /^vp-grid-\d+-\d+\.png$/
29
+ const SHOT_RE = /^vp-shot-.*\.png$/
30
+
31
+ /**
32
+ * 收工清理。
33
+ * @param {{keepShotIds: number[], summary: string}} args
34
+ * @returns 归档/缺失清单与各类删除计数(供工具层回显)。
35
+ */
36
+ export async function summarizeAction({ keepShotIds = [], summary } = {}) {
37
+ if (!Array.isArray(keepShotIds) || keepShotIds.some((id) => !Number.isInteger(id))) {
38
+ throw new Error(`vp_summarize: keepShotIds 必须是整数数组,收到 ${JSON.stringify(keepShotIds)}`)
39
+ }
40
+ if (typeof summary !== 'string' || summary.trim() === '') {
41
+ throw new Error('vp_summarize: summary 必须是非空字符串')
42
+ }
43
+
44
+ const dir = resolveShotsDir()
45
+ const archives = archiveDir()
46
+ await mkdir(archives, { recursive: true })
47
+
48
+ const archivedShotIds = []
49
+ const missingShotIds = []
50
+ const failedShotIds = []
51
+ const keepNames = new Set()
52
+
53
+ // 先归档再删除:顺序反了会把要保留的图当成「没被选中」删掉。
54
+ for (const shotId of new Set(keepShotIds)) {
55
+ const meta = get(shotId)
56
+ if (meta === undefined) {
57
+ missingShotIds.push(shotId)
58
+ continue
59
+ }
60
+ const name = basename(meta.path)
61
+ const sourcePath = join(dir, name)
62
+ const archivedPath = join(archives, name)
63
+
64
+ try {
65
+ if (existsSync(sourcePath)) {
66
+ await rename(sourcePath, archivedPath)
67
+ }
68
+ } catch {
69
+ // 归档失败(文件被别的程序占用等)时绝不能继续往下走:
70
+ // 删循环会把仍在原位的源文件当成「没被保留」删掉。先把它记进 keepNames 保命。
71
+ keepNames.add(name)
72
+ failedShotIds.push(shotId)
73
+ continue
74
+ }
75
+ // 源和归档都不在,说明这张图已经丢过一次了,如实报缺失。
76
+ if (!existsSync(archivedPath)) {
77
+ missingShotIds.push(shotId)
78
+ continue
79
+ }
80
+
81
+ relocate(shotId, archivedPath)
82
+ keepNames.add(name)
83
+ archivedShotIds.push(shotId)
84
+ }
85
+
86
+ const block = [
87
+ `[${new Date().toISOString()}] keep=${archivedShotIds.join(',') || '(none)'}`,
88
+ summary.trim(),
89
+ '',
90
+ ].join('\n')
91
+ await appendFile(summaryPath(), block, 'utf8')
92
+
93
+ let removedShots = 0
94
+ let removedDiff = 0
95
+ let removedGrid = 0
96
+ let failedDeletes = 0
97
+
98
+ // 只扫顶层:archives/ 是目录,正则匹配不到;即便将来名字撞上,isFile 检查也会挡住。
99
+ for (const name of await readdir(dir)) {
100
+ const isDiff = DIFF_RE.test(name)
101
+ const isGrid = GRID_RE.test(name)
102
+ const isShot = SHOT_RE.test(name)
103
+ if (!isDiff && !isGrid && !isShot) continue
104
+ if (isShot && keepNames.has(name)) continue
105
+ try {
106
+ const info = await stat(join(dir, name))
107
+ if (!info.isFile()) continue
108
+ await unlink(join(dir, name))
109
+ if (isDiff) removedDiff++
110
+ else if (isGrid) removedGrid++
111
+ else removedShots++
112
+ } catch {
113
+ failedDeletes++
114
+ }
115
+ }
116
+
117
+ return {
118
+ summary: summary.trim(),
119
+ archivedShotIds,
120
+ missingShotIds,
121
+ failedShotIds,
122
+ failedArchives: failedShotIds.length,
123
+ removedShots,
124
+ removedDiff,
125
+ removedGrid,
126
+ removedTotal: removedShots + removedDiff + removedGrid,
127
+ failedDeletes,
128
+ archivesDir: archives,
129
+ summaryPath: summaryPath(),
130
+ }
131
+ }
package/src/wait.js CHANGED
@@ -13,7 +13,8 @@ import { readFileSync } from 'node:fs'
13
13
  import { PNG } from 'pngjs'
14
14
  import pixelmatch from 'pixelmatch'
15
15
  import { loadRobotTs } from './robot-ts.js'
16
- import { get } from './shot-index.js'
16
+ import { get, assertShotFileExists } from './shot-index.js'
17
+ import { checkCanClick, getAuthEpoch } from './auth.js'
17
18
  import { screenshotAction } from './actions.js'
18
19
  import { diffPair, connectedComponents, DEFAULT_MIN_REGION_AREA } from './diff.js'
19
20
 
@@ -72,8 +73,22 @@ export async function waitChangeAction({
72
73
  `vp_wait_change: baseline shotId ${baseline} 不存在,可先用 vp_screenshot 截一张`,
73
74
  )
74
75
  }
76
+ assertShotFileExists(baseline, baselineMeta, 'vp_wait_change')
75
77
  const baselineImg = readPng(baselineMeta.path)
76
78
 
79
+ // 等待本身不要求授权;只有「带着持久授权开始等」的调用会被撤销打断。
80
+ // 记在会话创建之前,这样建会话期间发生的撤销在第一次轮询时就能发现。
81
+ const startedAllowed = checkCanClick().allowed
82
+ const startedEpoch = getAuthEpoch()
83
+ const assertWaitGrant = async () => {
84
+ if (!startedAllowed) return
85
+ // 让排队的撤销回调先落地,再同步复核;epoch 变化(含撤销后立刻重授权)一律中断。
86
+ await Promise.resolve()
87
+ if (!checkCanClick().allowed || getAuthEpoch() !== startedEpoch) {
88
+ throw new Error('授权已被撤销,等待中断')
89
+ }
90
+ }
91
+
77
92
  const { Session } = loadRobotTs()
78
93
  const session = await Session.create()
79
94
  const startedAt = Date.now()
@@ -105,6 +120,7 @@ export async function waitChangeAction({
105
120
  const mask = new Uint8Array(pixels)
106
121
 
107
122
  for (;;) {
123
+ await assertWaitGrant()
108
124
  const tickStartedAt = Date.now()
109
125
  const frame = await session.screen.captureMonitor(primary.id)
110
126
  const current = frame.toBuffer()
@@ -122,6 +138,8 @@ export async function waitChangeAction({
122
138
  }
123
139
  // 已经开始的这一帧一定跑完,所以超时路径上的 waitedMs 必然 >= timeoutMs。
124
140
  if (waitedMs >= timeoutMs) break
141
+ // 睡之前再查一次:把「撤销 → 醒来才发现」的延迟压到一帧以内。
142
+ await assertWaitGrant()
125
143
  await sleep(Math.max(0, pollIntervalMs - (Date.now() - tickStartedAt)))
126
144
  }
127
145
  } finally {