dsh-codex-style-computer-use 1.3.1 → 2.0.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 +32 -10
- package/README.zh.md +26 -9
- package/lib/audit.js +7 -0
- package/lib/batch.js +14 -19
- package/lib/bin/attention-worker.exe +0 -0
- package/lib/bin/attention-worker.exe.json +1 -1
- package/lib/compact-feedback.js +175 -0
- package/lib/continuation.js +1 -1
- package/lib/core/bin/dsh-computer-use-worker.exe +0 -0
- package/lib/core/bin/dsh-computer-use-worker.exe.json +2 -2
- package/lib/core/index.js +372 -136
- package/lib/core/native-runtime.js +17 -0
- package/lib/core/worker.cs +1779 -218
- package/lib/feedback-routes.json +11 -0
- package/lib/feedback.js +49 -16
- package/lib/image-output.js +59 -11
- package/lib/index.js +25 -7
- package/lib/key-command.js +37 -0
- package/lib/media-contract.js +43 -0
- package/lib/observations.js +267 -13
- package/lib/policy.js +7 -7
- package/lib/tool-contract.js +56 -0
- package/lib/tools.js +134 -49
- package/lib/verification-region.js +17 -0
- package/mcp/package.json +2 -1
- package/mcp/src/execution.js +8 -5
- package/mcp/src/index.js +16 -267
- package/mcp/src/tools.js +271 -0
- package/package.json +2 -1
- package/scripts/check-behavior.mjs +83 -0
- package/scripts/measure-tool-tokens.mjs +21 -0
- package/skills/computer-use/SKILL.md +20 -8
- package/skills/computer-use/references/lifecycle.md +11 -7
- package/skills/computer-use/references/maintenance.md +9 -5
- package/skills/computer-use/references/precision.md +39 -14
- package/skills/computer-use/references/progressive-control.md +10 -4
package/lib/observations.js
CHANGED
|
@@ -1,15 +1,180 @@
|
|
|
1
|
-
import
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import v8 from 'node:v8'
|
|
5
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
2
6
|
|
|
3
7
|
const clone = value => JSON.parse(JSON.stringify(value))
|
|
4
8
|
const encode = value => JSON.stringify(value)
|
|
5
9
|
const elementKey = e => e.target || (e.runtimeId ? `uia:${e.pid ?? ''}:${e.runtimeId}` : null)
|
|
6
10
|
const windowKey = w => w.hwnd ? `window:${w.pid ?? ''}:${w.hwnd}` : null
|
|
11
|
+
const pagedFields = ['elements', 'uiaFlat', 'windows', 'ancestors']
|
|
12
|
+
const TTL = 24 * 60 * 60 * 1000, MEMORY_BYTES = 4 * 1024 * 1024, DISK_BYTES = 32 * 1024 * 1024
|
|
13
|
+
const FORMAT = 'dsh-computer-use-observation-cache-v1', ID = /^[a-f0-9]{32}$/
|
|
14
|
+
const digest = bytes => createHash('sha256').update(bytes).digest('hex')
|
|
15
|
+
const failure = code => Object.assign(new Error('Observation cache unavailable'), { code })
|
|
16
|
+
const unavailable = () => new Error('OBSERVATION_UNAVAILABLE: snapshot expired or belongs to another Agent/tool; request a fresh full observation')
|
|
17
|
+
const pressure = () => { const heap = v8.getHeapStatistics(); return os.freemem() < 256 * 1024 * 1024 || heap.used_heap_size / heap.heap_size_limit > 0.85 }
|
|
18
|
+
|
|
19
|
+
// Only this dedicated directory is maintained. A manifest reserves ownership
|
|
20
|
+
// before data is written, so a crash/partial write remains identifiable on restart.
|
|
21
|
+
class DiskCache {
|
|
22
|
+
constructor (directory, io, now) { this.directory = directory; this.io = io; this.now = now; this.lastSweep = -Infinity }
|
|
23
|
+
file (name) { return path.join(this.directory, name) }
|
|
24
|
+
regular (file) { const stat = this.io.lstatSync(file); if (!stat.isFile() || stat.isSymbolicLink()) throw failure('CACHE_UNSAFE'); return stat }
|
|
25
|
+
remove (file) {
|
|
26
|
+
try { const stat = this.regular(file); this.io.unlinkSync(file); return stat.size }
|
|
27
|
+
catch (error) { if (error.code === 'ENOENT') return 0; throw error }
|
|
28
|
+
}
|
|
29
|
+
lock (body, create = true) {
|
|
30
|
+
if (!this.directory || path.basename(this.directory).toLowerCase() !== 'observation-cache') throw failure('CACHE_DIRECTORY')
|
|
31
|
+
if (create) this.io.mkdirSync(this.directory, { recursive: true })
|
|
32
|
+
const directory = this.io.lstatSync(this.directory)
|
|
33
|
+
if (!directory.isDirectory() || directory.isSymbolicLink()) throw failure('CACHE_UNSAFE')
|
|
34
|
+
const lockFile = this.file('.lock'), nonce = randomBytes(16).toString('hex')
|
|
35
|
+
let handle
|
|
36
|
+
try { handle = this.io.openSync(lockFile, 'wx', 0o600) }
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (error.code !== 'EEXIST') throw error
|
|
39
|
+
const stat = this.regular(lockFile); if (stat.size > 4096) throw failure('CACHE_LOCKED')
|
|
40
|
+
let stale; try { stale = JSON.parse(this.io.readFileSync(lockFile, 'utf8')) } catch { throw failure('CACHE_LOCKED') }
|
|
41
|
+
if (stale.format !== FORMAT || !ID.test(stale.nonce) || !Number.isSafeInteger(stale.pid) || stale.pid < 1) throw failure('CACHE_LOCKED')
|
|
42
|
+
if (stale.pid !== process.pid || this.recoverableLock !== stale.nonce)
|
|
43
|
+
try { process.kill(stale.pid, 0); throw failure('CACHE_LOCKED') } catch (alive) { if (alive.code !== 'ESRCH') throw failure('CACHE_LOCKED') }
|
|
44
|
+
// This exact temporary filename is owned by the persisted dead lock.
|
|
45
|
+
this.remove(this.file(`.manifest-${stale.nonce}.tmp`)); this.remove(lockFile)
|
|
46
|
+
this.recoverableLock = null
|
|
47
|
+
handle = this.io.openSync(lockFile, 'wx', 0o600)
|
|
48
|
+
}
|
|
49
|
+
const lease = { nonce, keep: false }
|
|
50
|
+
try {
|
|
51
|
+
this.io.writeFileSync(handle, encode({ format: FORMAT, nonce, pid: process.pid }) + '\n', 'utf8')
|
|
52
|
+
return body(lease)
|
|
53
|
+
} finally {
|
|
54
|
+
this.io.closeSync(handle)
|
|
55
|
+
if (lease.keep) this.recoverableLock = nonce
|
|
56
|
+
else this.remove(lockFile)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
manifest () {
|
|
60
|
+
const file = this.file('.manifest.json')
|
|
61
|
+
let value
|
|
62
|
+
try { const stat = this.regular(file); if (stat.size > 128 * 1024) throw failure('CACHE_MANIFEST'); value = JSON.parse(this.io.readFileSync(file, 'utf8')) }
|
|
63
|
+
catch (error) { if (error.code === 'ENOENT') return []; throw error }
|
|
64
|
+
if (value.format !== FORMAT || !Array.isArray(value.entries) || value.entries.length > 64) throw failure('CACHE_MANIFEST')
|
|
65
|
+
const ids = new Set()
|
|
66
|
+
for (const e of value.entries) {
|
|
67
|
+
if (!ID.test(e.id) || !ID.test(e.owner) || ids.has(e.id) || !Number.isFinite(e.createdAt) ||
|
|
68
|
+
!Number.isSafeInteger(e.bytes) || e.bytes < 0 || !/^[a-f0-9]{64}$/.test(e.sha256)) throw failure('CACHE_MANIFEST')
|
|
69
|
+
ids.add(e.id)
|
|
70
|
+
}
|
|
71
|
+
return value.entries
|
|
72
|
+
}
|
|
73
|
+
commit (entries, lease) {
|
|
74
|
+
const temp = this.file(`.manifest-${lease.nonce}.tmp`)
|
|
75
|
+
let opened = false, handle
|
|
76
|
+
try {
|
|
77
|
+
handle = this.io.openSync(temp, 'wx', 0o600); opened = true
|
|
78
|
+
this.io.writeFileSync(handle, encode({ format: FORMAT, entries }) + '\n', 'utf8')
|
|
79
|
+
this.io.closeSync(handle); handle = undefined
|
|
80
|
+
this.io.renameSync(temp, this.file('.manifest.json')); opened = false
|
|
81
|
+
} finally {
|
|
82
|
+
if (handle !== undefined) this.io.closeSync(handle)
|
|
83
|
+
if (opened) try { this.remove(temp) } catch { lease.keep = true }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
trim (entries, incomingBytes = 0, incomingFiles = 0) {
|
|
87
|
+
let changed = false, retained = 0
|
|
88
|
+
const discard = entry => {
|
|
89
|
+
try { this.remove(this.file(entry.id + '.json')); entries.splice(entries.indexOf(entry), 1); changed = true; return true }
|
|
90
|
+
catch { retained++; return false }
|
|
91
|
+
}
|
|
92
|
+
for (const entry of [...entries]) {
|
|
93
|
+
let missing = false
|
|
94
|
+
try { this.regular(this.file(entry.id + '.json')) } catch (error) { missing = error.code === 'ENOENT' }
|
|
95
|
+
if (missing || this.now() - entry.createdAt >= TTL) discard(entry)
|
|
96
|
+
}
|
|
97
|
+
for (const entry of [...entries].sort((a, b) => a.createdAt - b.createdAt)) {
|
|
98
|
+
if (entries.length + incomingFiles <= 64 && entries.reduce((n, e) => n + e.bytes, 0) + incomingBytes <= DISK_BYTES) break
|
|
99
|
+
discard(entry)
|
|
100
|
+
}
|
|
101
|
+
return { changed, retained, fits: entries.length + incomingFiles <= 64 && entries.reduce((n, e) => n + e.bytes, 0) + incomingBytes <= DISK_BYTES }
|
|
102
|
+
}
|
|
103
|
+
sweep (force = false) {
|
|
104
|
+
if (!this.directory || (!force && performance.now() - this.lastSweep < 60000)) return
|
|
105
|
+
this.lastSweep = performance.now()
|
|
106
|
+
try { this.lock(lease => { const entries = this.manifest(); if (this.trim(entries).changed) this.commit(entries, lease) }, false) } catch { /* Later save reports bounded fallback. */ }
|
|
107
|
+
}
|
|
108
|
+
save (owner, full) {
|
|
109
|
+
try {
|
|
110
|
+
const bytes = Buffer.from(encode(full) + '\n', 'utf8')
|
|
111
|
+
if (bytes.length > DISK_BYTES) throw failure('CACHE_CAPACITY')
|
|
112
|
+
return this.lock(lease => {
|
|
113
|
+
const entries = this.manifest(), cleanup = this.trim(entries, bytes.length, 1)
|
|
114
|
+
if (cleanup.changed) this.commit(entries, lease)
|
|
115
|
+
if (!cleanup.fits) throw failure('CACHE_CAPACITY')
|
|
116
|
+
const entry = { id: randomBytes(16).toString('hex'), owner, createdAt: this.now(), bytes: bytes.length, sha256: digest(bytes) }
|
|
117
|
+
entries.push(entry); this.commit(entries, lease)
|
|
118
|
+
const file = this.file(entry.id + '.json'); let handle, created = false
|
|
119
|
+
try { handle = this.io.openSync(file, 'wx', 0o600); created = true; this.io.writeFileSync(handle, bytes) }
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (handle !== undefined) { this.io.closeSync(handle); handle = undefined }
|
|
122
|
+
let removed = !created
|
|
123
|
+
if (created) try { this.remove(file); removed = true } catch { }
|
|
124
|
+
if (removed) { entries.pop(); try { this.commit(entries, lease) } catch { } }
|
|
125
|
+
throw error
|
|
126
|
+
} finally { if (handle !== undefined) this.io.closeSync(handle) }
|
|
127
|
+
this.lastSweep = performance.now()
|
|
128
|
+
return { path: file, bytes: entry.bytes, sha256: entry.sha256, status: 'saved', cacheId: entry.id }
|
|
129
|
+
})
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return { status: 'memory-only', reason: ['ENOSPC', 'EDQUOT'].includes(error.code) ? 'disk-full' : error.code === 'CACHE_CAPACITY' ? 'capacity' : 'disk-unavailable', fallback: 'inline' }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
read (owner, archive) {
|
|
135
|
+
return this.lock(() => {
|
|
136
|
+
const entry = this.manifest().find(e => e.id === archive.cacheId && e.owner === owner)
|
|
137
|
+
if (!entry || this.now() - entry.createdAt >= TTL) throw unavailable()
|
|
138
|
+
const file = this.file(entry.id + '.json'), stat = this.regular(file)
|
|
139
|
+
if (stat.size !== entry.bytes || stat.size > DISK_BYTES) throw unavailable()
|
|
140
|
+
const bytes = this.io.readFileSync(file)
|
|
141
|
+
if (digest(bytes) !== entry.sha256) throw unavailable()
|
|
142
|
+
return JSON.parse(bytes)
|
|
143
|
+
}, false)
|
|
144
|
+
}
|
|
145
|
+
clear (owner) {
|
|
146
|
+
if (!this.directory) return { filesRemoved: 0, bytesFreed: 0, retainedOnFailure: 0 }
|
|
147
|
+
try { this.io.lstatSync(this.directory) } catch (error) { if (error.code === 'ENOENT') return { filesRemoved: 0, bytesFreed: 0, retainedOnFailure: 0 }; throw error }
|
|
148
|
+
return this.lock(lease => {
|
|
149
|
+
const entries = this.manifest(); let filesRemoved = 0, bytesFreed = 0, retainedOnFailure = 0
|
|
150
|
+
for (const entry of [...entries]) if (entry.owner === owner) {
|
|
151
|
+
try { const bytes = this.remove(this.file(entry.id + '.json')); filesRemoved += bytes > 0 ? 1 : 0; bytesFreed += bytes; entries.splice(entries.indexOf(entry), 1) }
|
|
152
|
+
catch { retainedOnFailure++ }
|
|
153
|
+
}
|
|
154
|
+
this.commit(entries, lease)
|
|
155
|
+
return { filesRemoved, bytesFreed, retainedOnFailure }
|
|
156
|
+
}, false)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
7
159
|
|
|
8
160
|
// An explicit base prevents implicit deltas after compaction or in an unrelated Agent.
|
|
9
161
|
// Full immutable observations are retained, including every provider field; only the wire view differs.
|
|
10
162
|
export class ObservationStore {
|
|
11
|
-
constructor ({ capacity = 64 } = {}) {
|
|
12
|
-
|
|
163
|
+
constructor ({ capacity = 64, archiveDir, memoryPressure = pressure, fileSystem = fs, now = Date.now } = {}) {
|
|
164
|
+
if (!Number.isSafeInteger(capacity) || capacity < 1) throw new RangeError('Observation capacity must be a positive integer')
|
|
165
|
+
if (archiveDir !== undefined && (typeof archiveDir !== 'string' || !archiveDir.trim())) throw new TypeError('archiveDir must be a nonempty directory path')
|
|
166
|
+
this.capacity = Math.min(64, capacity)
|
|
167
|
+
this.archiveDir = archiveDir === undefined ? null : path.resolve(archiveDir)
|
|
168
|
+
this.now = now
|
|
169
|
+
this.disk = new DiskCache(this.archiveDir, fileSystem, now)
|
|
170
|
+
this.memoryPressure = memoryPressure
|
|
171
|
+
this.lastPressureCheck = -Infinity
|
|
172
|
+
this.pressured = false
|
|
173
|
+
this.entries = new Map()
|
|
174
|
+
this.memoryBytes = 0
|
|
175
|
+
this.epoch = 0
|
|
176
|
+
this.hasRead = false
|
|
177
|
+
this.owners = new WeakMap()
|
|
13
178
|
this.scopes = new WeakMap()
|
|
14
179
|
this.unscoped = {}
|
|
15
180
|
this.sequence = 0
|
|
@@ -21,21 +186,93 @@ export class ObservationStore {
|
|
|
21
186
|
if (!cache) { cache = new Map(); this.scopes.set(owner, cache) }
|
|
22
187
|
return cache
|
|
23
188
|
}
|
|
24
|
-
|
|
189
|
+
owner (exec) {
|
|
190
|
+
const key = exec?.agent || this.unscoped
|
|
191
|
+
if (!this.owners.has(key)) this.owners.set(key, randomBytes(16).toString('hex'))
|
|
192
|
+
return this.owners.get(key)
|
|
193
|
+
}
|
|
194
|
+
evict (id) {
|
|
195
|
+
const entry = this.entries.get(id)
|
|
196
|
+
if (entry) { this.memoryBytes -= entry.full ? entry.bytes : 0; entry.cache.delete(id); this.entries.delete(id) }
|
|
197
|
+
}
|
|
198
|
+
checkPressure () {
|
|
199
|
+
if (performance.now() - this.lastPressureCheck >= 5000) {
|
|
200
|
+
this.lastPressureCheck = performance.now()
|
|
201
|
+
try { this.pressured = !!this.memoryPressure() } catch { this.pressured = true }
|
|
202
|
+
if (this.pressured) for (const [id, entry] of this.entries) {
|
|
203
|
+
if (entry.archive?.status === 'saved') { this.memoryBytes -= entry.full ? entry.bytes : 0; entry.full = null }
|
|
204
|
+
else this.evict(id)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return this.pressured
|
|
208
|
+
}
|
|
209
|
+
fullOf (entry) {
|
|
210
|
+
if (!entry || this.now() - entry.createdAt >= TTL) { if (entry) this.evict(entry.id); return null }
|
|
211
|
+
if (entry.full) return entry.full
|
|
212
|
+
try { return this.disk.read(entry.owner, entry.archive) } catch { this.evict(entry.id); return null }
|
|
213
|
+
}
|
|
214
|
+
invalidate (reason = 'evidence-invalidated') {
|
|
215
|
+
this.epoch++
|
|
216
|
+
return { invalidated: true, reason, historicalSnapshotsRetained: true }
|
|
217
|
+
}
|
|
218
|
+
clear (exec) {
|
|
219
|
+
const cache = this.scope(exec), cleared = cache.size
|
|
220
|
+
const knownFiles = [...cache.values()].filter(e => e.archive?.status === 'saved').length
|
|
221
|
+
for (const id of [...cache.keys()]) this.evict(id)
|
|
222
|
+
let disk = { filesRemoved: 0, bytesFreed: 0, retainedOnFailure: 0 }
|
|
223
|
+
if (this.archiveDir) try { disk = this.disk.clear(this.owner(exec)) }
|
|
224
|
+
catch { disk.retainedOnFailure = knownFiles || null }
|
|
225
|
+
return { cleared, ...disk }
|
|
226
|
+
}
|
|
227
|
+
get (exec, id, kind, view = {}) {
|
|
228
|
+
this.checkPressure(); this.disk.sweep(!this.hasRead); this.hasRead = true
|
|
25
229
|
const entry = this.scope(exec).get(id)
|
|
26
|
-
if (!entry || entry.kind !== kind) throw
|
|
27
|
-
|
|
230
|
+
if (!entry || entry.kind !== kind) throw unavailable()
|
|
231
|
+
const full = this.fullOf(entry)
|
|
232
|
+
if (!full) throw unavailable()
|
|
233
|
+
const snapshot = clone(full)
|
|
234
|
+
snapshot.snapshotHistorical = true
|
|
235
|
+
snapshot.targetsValid = entry.epoch === this.epoch
|
|
236
|
+
if (entry.archive) snapshot.archive = { ...entry.archive }
|
|
237
|
+
if (view.offset !== undefined || view.limit !== undefined) {
|
|
238
|
+
const offset = view.offset ?? 0, limit = view.limit ?? 80
|
|
239
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || !Number.isInteger(limit) || limit < 1 || limit > 80)
|
|
240
|
+
throw new RangeError('Observation pagination requires offset >= 0 and limit between 1 and 80')
|
|
241
|
+
const page = { offset, limit, fields: {} }
|
|
242
|
+
for (const field of pagedFields) if (Array.isArray(snapshot[field])) {
|
|
243
|
+
const total = snapshot[field].length
|
|
244
|
+
snapshot[field] = snapshot[field].slice(offset, offset + limit)
|
|
245
|
+
const returned = snapshot[field].length, hasMore = offset < total && returned < total - offset
|
|
246
|
+
page.fields[field] = { total, returned, hasMore, nextOffset: hasMore ? offset + returned : null }
|
|
247
|
+
}
|
|
248
|
+
// A second complete tree would defeat an explicitly requested page. The
|
|
249
|
+
// default full read and archive still preserve every provider field.
|
|
250
|
+
if (Object.hasOwn(snapshot, 'uiaRoot')) { delete snapshot.uiaRoot; page.omittedFields = ['uiaRoot'] }
|
|
251
|
+
snapshot.page = page
|
|
252
|
+
}
|
|
253
|
+
return snapshot
|
|
28
254
|
}
|
|
29
255
|
ownsTarget (exec, target) {
|
|
30
|
-
|
|
256
|
+
this.checkPressure()
|
|
257
|
+
return [...this.scope(exec).values()].some(entry => entry.epoch === this.epoch && (this.fullOf(entry)?.elements || []).some(e => e.target === target))
|
|
31
258
|
}
|
|
32
|
-
publish (exec, kind, query, value, since) {
|
|
259
|
+
publish (exec, kind, query, value, since, options = {}) {
|
|
260
|
+
// The action already allocated its result. Under pressure do not create a
|
|
261
|
+
// second deep copy, diff, encoded buffer, or new disk record for that result.
|
|
262
|
+
if (this.checkPressure()) {
|
|
263
|
+
const inline = { ...value, capturedAt: value.capturedAt ?? value.ts ?? new Date().toISOString(), mode: 'full',
|
|
264
|
+
archive: { status: 'uncached', reason: 'memory-pressure', fallback: 'inline' } }
|
|
265
|
+
delete inline.observation
|
|
266
|
+
return inline
|
|
267
|
+
}
|
|
33
268
|
const cache = this.scope(exec)
|
|
34
269
|
const fields = kind === 'uia' ? { elements: elementKey } : { windows: windowKey, uiaFlat: elementKey }
|
|
35
|
-
const full = clone({ ...value,
|
|
270
|
+
const full = clone({ ...value, capturedAt: value.capturedAt ?? value.ts ?? new Date().toISOString(),
|
|
271
|
+
observation: `O${this.prefix}-${++this.sequence}`, mode: 'full' })
|
|
36
272
|
// The scope includes the exact query and observed window/process. A change forces a full view.
|
|
37
273
|
const scope = encode([query, value.hwnd ?? value.active?.hwnd ?? null, value.pid ?? value.active?.pid ?? null])
|
|
38
|
-
const
|
|
274
|
+
const previousEntry = since && cache.get(since), previousFull = this.fullOf(previousEntry)
|
|
275
|
+
const previous = previousFull && { ...previousEntry, full: previousFull }
|
|
39
276
|
let wire = full
|
|
40
277
|
if (since) {
|
|
41
278
|
let reason = !previous ? 'base-unavailable' : previous.kind !== kind || previous.scope !== scope ? 'scope-changed'
|
|
@@ -71,8 +308,25 @@ export class ObservationStore {
|
|
|
71
308
|
}
|
|
72
309
|
if (reason) wire = { ...full, resetReason: reason }
|
|
73
310
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
311
|
+
let archive = options.archive === true ? this.disk.save(this.owner(exec), full) : undefined
|
|
312
|
+
const bytes = Buffer.byteLength(encode(full)), keepMemory = !this.checkPressure() && bytes <= MEMORY_BYTES
|
|
313
|
+
if (!keepMemory && archive?.status !== 'saved') {
|
|
314
|
+
const inline = clone(full); delete inline.observation
|
|
315
|
+
return { ...inline, archive: { status: 'uncached', reason: 'memory-pressure', fallback: 'inline' } }
|
|
316
|
+
}
|
|
317
|
+
if (keepMemory) for (const [id, entry] of this.entries) {
|
|
318
|
+
if (this.memoryBytes + bytes <= MEMORY_BYTES) break
|
|
319
|
+
if (!entry.full) continue
|
|
320
|
+
if (entry.archive?.status === 'saved') { this.memoryBytes -= entry.bytes; entry.full = null }
|
|
321
|
+
else this.evict(id)
|
|
322
|
+
}
|
|
323
|
+
const entry = { id: full.observation, full: keepMemory ? full : null, bytes, kind, scope, archive,
|
|
324
|
+
owner: this.owner(exec), createdAt: this.now(), epoch: this.epoch, cache }
|
|
325
|
+
cache.set(full.observation, entry); this.entries.set(full.observation, entry)
|
|
326
|
+
if (keepMemory) this.memoryBytes += bytes
|
|
327
|
+
while (cache.size > this.capacity) this.evict(cache.keys().next().value)
|
|
328
|
+
while (this.entries.size > 64) this.evict(this.entries.keys().next().value)
|
|
329
|
+
// Neither the full wire view nor delta rows may alias the retained snapshot.
|
|
330
|
+
return { ...clone(wire), ...(archive ? { archive: { ...archive } } : {}) }
|
|
77
331
|
}
|
|
78
332
|
}
|
package/lib/policy.js
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
// plugin's session/event turn/start opens a cycle, and only the human's Ctrl+Alt+R releases a pause.)
|
|
19
19
|
// computer_ask is argument-independent and BRAKE-LIKE — see its branch below.
|
|
20
20
|
|
|
21
|
+
import { keyCommandRisk } from './key-command.js'
|
|
22
|
+
|
|
21
23
|
export const OBSERVATION_TOOLS = new Set([
|
|
22
24
|
'computer_shot', 'computer_state', 'computer_marks', 'computer_uia', 'computer_wait', 'computer_ocr',
|
|
23
25
|
])
|
|
@@ -27,10 +29,6 @@ export const ACTUATION_TOOLS = new Set([
|
|
|
27
29
|
'computer_key', 'computer_type', 'computer_uia_act', 'computer_window', 'computer_batch',
|
|
28
30
|
])
|
|
29
31
|
|
|
30
|
-
// combos that escalate: run dialog, window close, signout/lock/taskmgr, delete-key
|
|
31
|
-
const HIGH_RISK_KEY = /(^|\+)(win\+r|alt\+f4|ctrl\+shift\+esc|win\+l|win\+d)(\+|$)/i
|
|
32
|
-
const DESTRUCTIVE_KEY = /(^|\+)(del|delete|bksp|backspace)(\+|$)/i
|
|
33
|
-
|
|
34
32
|
export const HIGH_RISK_NOTE = 'high-risk action (destructive key / closing a window / password field) — always requires human approval'
|
|
35
33
|
|
|
36
34
|
/** @returns {{kind:'allow'|'deny'|'ask', reason?: string}} */
|
|
@@ -43,6 +41,8 @@ export function policyDecision (name, args, mode) {
|
|
|
43
41
|
// The brake is available in EVERY mode, including read-only, and it drives nothing.
|
|
44
42
|
if (act === 'stop') return { kind: 'allow' }
|
|
45
43
|
if (act === 'release') return { kind: 'allow' }
|
|
44
|
+
// An advisory visual intent never injects input or releases a human brake.
|
|
45
|
+
if (act === 'hold' || act === 'free') return { kind: 'allow' }
|
|
46
46
|
if (act === 'recover') return mode === 'read-only'
|
|
47
47
|
? { kind: 'deny', reason: 'read-only mode cannot recover control' } : { kind: 'allow' }
|
|
48
48
|
if (act === 'selftest') return { kind: 'allow' } // input-path health, never moves the pointer
|
|
@@ -74,7 +74,8 @@ export function policyDecision (name, args, mode) {
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
function decideActuation (name, a, mode) {
|
|
77
|
-
|
|
77
|
+
let hr
|
|
78
|
+
try { hr = highRisk(name, a) } catch (error) { return { kind: 'deny', reason: error.message } }
|
|
78
79
|
switch (mode) {
|
|
79
80
|
case 'read-only':
|
|
80
81
|
return { kind: 'deny', reason: `automationMode=read-only: ${name} drives the machine` }
|
|
@@ -113,8 +114,7 @@ function riskSuffix (name, a) {
|
|
|
113
114
|
export function highRisk (name, args) {
|
|
114
115
|
const a = args || {}
|
|
115
116
|
if (name === 'computer_key') {
|
|
116
|
-
|
|
117
|
-
if (HIGH_RISK_KEY.test(combo) || DESTRUCTIVE_KEY.test(combo)) return true
|
|
117
|
+
return keyCommandRisk(a.combo)
|
|
118
118
|
}
|
|
119
119
|
if (name === 'computer_window' && String(a.op || 'activate') === 'close') return true
|
|
120
120
|
if (name === 'computer_type' && a.allowPassword) return true
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Host names are aliases of one operation/result contract. Adapters execute
|
|
2
|
+
// their native argument shapes; batching and refusal semantics use this view.
|
|
3
|
+
export function canonicalStep (step = {}) {
|
|
4
|
+
step ||= {}
|
|
5
|
+
const args = step.args || {}
|
|
6
|
+
const names = {
|
|
7
|
+
cu_screenshot: 'computer_shot', cu_screen_state: 'computer_state', cu_windows: 'computer_state',
|
|
8
|
+
cu_cursor: 'computer_state', cu_marks: 'computer_marks', cu_uia_query: 'computer_uia',
|
|
9
|
+
cu_uia_at_point: 'computer_uia', cu_wait: 'computer_wait', cu_move: 'computer_move',
|
|
10
|
+
cu_click: 'computer_click', cu_drag: 'computer_drag', cu_scroll: 'computer_scroll',
|
|
11
|
+
cu_key: 'computer_key', cu_type: 'computer_type', cu_batch: 'computer_batch',
|
|
12
|
+
}
|
|
13
|
+
if (step.tool === 'cu_activate_window') return { tool: 'computer_window', args: { ...args, op: 'activate' } }
|
|
14
|
+
if (step.tool === 'cu_window_op') return { tool: 'computer_window', args }
|
|
15
|
+
if (['cu_hold', 'cu_free', 'cu_release'].includes(step.tool)) return { tool: 'computer_ctrl', args: { action: step.tool.slice(3) } }
|
|
16
|
+
if (step.tool === 'cu_clipboard_read') return { tool: 'computer_clip', args: {} }
|
|
17
|
+
if (step.tool === 'cu_clipboard_write') return { tool: 'computer_clip', args }
|
|
18
|
+
return { tool: names[step.tool] || step.tool, args }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const reads = new Set(['computer_state', 'computer_uia', 'computer_marks', 'computer_shot', 'computer_wait', 'computer_ocr'])
|
|
22
|
+
export const isReadStep = step => {
|
|
23
|
+
const s = canonicalStep(step)
|
|
24
|
+
return reads.has(s.tool) || (s.tool === 'computer_clip' && s.args.text === undefined)
|
|
25
|
+
}
|
|
26
|
+
export const isReadbackStep = step => {
|
|
27
|
+
const s = canonicalStep(step)
|
|
28
|
+
return ['computer_state', 'computer_uia', 'computer_marks', 'computer_shot', 'computer_ocr'].includes(s.tool) ||
|
|
29
|
+
(s.tool === 'computer_clip' && s.args.text === undefined)
|
|
30
|
+
}
|
|
31
|
+
export const endsBatch = step => {
|
|
32
|
+
const s = canonicalStep(step)
|
|
33
|
+
return s.tool === 'computer_ctrl' && ['stop', 'exit', 'release'].includes(s.args.action)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function actionRefusal (step, result) {
|
|
37
|
+
const s = canonicalStep(step)
|
|
38
|
+
if (result?.ok === false || result?.success === false) return result.error || result.reason || 'action reported failure'
|
|
39
|
+
if (s.tool === 'computer_window' && (s.args.op || 'activate') === 'activate' && result?.activated !== true) {
|
|
40
|
+
return 'target window activation was not confirmed; subsequent actions were not dispatched'
|
|
41
|
+
}
|
|
42
|
+
if (s.tool === 'computer_ctrl' && s.args.action === 'recover' && result?.recovered !== true) return result.reason || 'temporary pause recovery refused'
|
|
43
|
+
if (s.tool === 'computer_ctrl' && s.args.action === 'release' && !result?.released && !result?.alreadyReleased) return result.reason || 'cycle release refused'
|
|
44
|
+
if (s.tool === 'computer_batch' && result?.actions?.some(action => action.ok === false)) return 'nested batch did not complete; subsequent actions were not dispatched'
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function actionError (error) {
|
|
49
|
+
return { ok: false, error: String(error?.message || error),
|
|
50
|
+
code: error?.code || 'action-error', outcome: error?.outcome || 'unknown',
|
|
51
|
+
...(error?.dragStarted !== undefined ? { dragStarted: error.dragStarted } : {}),
|
|
52
|
+
...(error?.expectedCursor !== undefined ? { expectedCursor: error.expectedCursor, actualCursor: error.actualCursor } : {}),
|
|
53
|
+
...(error?.cursorCheck !== undefined ? { cursorCheck: error.cursorCheck } : {}),
|
|
54
|
+
...(error?.cancelSource ? { cancelSource: error.cancelSource } : {}),
|
|
55
|
+
...(error?.handoffWait ? { handoffWait: error.handoffWait } : {}) }
|
|
56
|
+
}
|