dsh-codex-style-computer-use 1.3.0 → 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 +53 -22
- package/README.zh.md +46 -21
- package/icon.svg +29 -0
- 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/compile-worker.ps1 +5 -1
- package/lib/core/index.js +452 -129
- package/lib/core/native-runtime.js +23 -1
- package/lib/core/winmd/Windows.winmd +0 -0
- package/lib/core/worker.cs +2415 -356
- package/lib/feedback-routes.json +11 -0
- package/lib/feedback.js +49 -16
- package/lib/image-output.js +59 -11
- package/lib/index.js +42 -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 +30 -10
- package/lib/tool-contract.js +56 -0
- package/lib/tools.js +174 -51
- 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 +5 -2
- package/scripts/check-behavior.mjs +83 -0
- package/scripts/measure-tool-tokens.mjs +21 -0
- package/scripts/render-promo.py +81 -0
- package/skills/computer-use/SKILL.md +35 -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/skills/computer-use/references/zero-uia-apps.md +54 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"provider": "bigmodel-anthropic",
|
|
4
|
+
"model": "glm-5.3",
|
|
5
|
+
"adapterVersion": "unversioned",
|
|
6
|
+
"grounding": "failed",
|
|
7
|
+
"testedAt": "2026-10-02T00:00:00Z",
|
|
8
|
+
"expiresAt": "2026-12-01T00:00:00Z",
|
|
9
|
+
"reason": "Historical Desktop grounding test failed; adapter version was not recorded. Conservative text fallback expires after 60 days and must be revalidated."
|
|
10
|
+
}
|
|
11
|
+
]
|
package/lib/feedback.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
// Transport capability and grounding are separate evidence. Route exceptions
|
|
4
|
+
// are dated data, can be replaced in settings, and expire.
|
|
5
|
+
export const DEFAULT_FEEDBACK_RULES = Object.freeze(JSON.parse(readFileSync(new URL('./feedback-routes.json', import.meta.url), 'utf8')))
|
|
3
6
|
export function feedbackRoute (exec) {
|
|
4
7
|
const routed = exec?.agent?.session?.requestHeader?.()?.config
|
|
5
8
|
return {
|
|
@@ -8,23 +11,53 @@ export function feedbackRoute (exec) {
|
|
|
8
11
|
}
|
|
9
12
|
}
|
|
10
13
|
|
|
11
|
-
export function
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
+
export function visionEvidence (exec, { rules = DEFAULT_FEEDBACK_RULES, adapterVersion = 'unversioned', now = Date.now() } = {}) {
|
|
15
|
+
const route = feedbackRoute(exec)
|
|
16
|
+
for (const rule of [...(Array.isArray(rules) ? rules : [])].reverse()) {
|
|
17
|
+
const tested = Date.parse(rule.testedAt), expires = Date.parse(rule.expiresAt)
|
|
18
|
+
if (rule.provider !== route.provider || rule.model !== route.model ||
|
|
19
|
+
rule.adapterVersion !== adapterVersion || !Number.isFinite(tested) || !Number.isFinite(expires) ||
|
|
20
|
+
tested > now || expires <= now || expires <= tested || !['failed', 'verified'].includes(rule.grounding)) continue
|
|
21
|
+
// Unversioned historical tests may select a conservative text fallback,
|
|
22
|
+
// but cannot certify the current adapter's visual grounding.
|
|
23
|
+
if (rule.grounding === 'verified' && adapterVersion === 'unversioned') continue
|
|
24
|
+
return { grounding: rule.grounding, adapterVersion, testedAt: rule.testedAt, expiresAt: rule.expiresAt,
|
|
25
|
+
reason: rule.reason || 'configured route evidence' }
|
|
26
|
+
}
|
|
27
|
+
return { grounding: 'unverified', adapterVersion, reason: 'no current matching grounding record' }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function unverifiedVisionRoute (exec, options) {
|
|
31
|
+
return visionEvidence(exec, options).grounding === 'failed'
|
|
14
32
|
}
|
|
15
33
|
|
|
16
|
-
export
|
|
17
|
-
|
|
18
|
-
if (
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
34
|
+
export function selectFeedbackDetail (requested, fallback = 'compact') {
|
|
35
|
+
const detail = requested ?? fallback
|
|
36
|
+
if (!['compact', 'full', 'none'].includes(detail)) throw Object.assign(new Error('feedback must be compact, full or none'),
|
|
37
|
+
{ code: 'INVALID_FEEDBACK', outcome: 'not-dispatched' })
|
|
38
|
+
return detail
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function feedbackPolicy (ctx, exec, enabled = true, options = {}) {
|
|
42
|
+
const route = feedbackRoute(exec)
|
|
43
|
+
const detail = selectFeedbackDetail(options.detail, 'full')
|
|
44
|
+
if (!enabled || detail === 'none' || exec?.signal?.aborted) return { kind: null, detail, reason: exec?.signal?.aborted ? 'cancelled' : !enabled ? 'disabled' : 'feedback-none' }
|
|
45
|
+
let active
|
|
23
46
|
try {
|
|
24
|
-
if (
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
47
|
+
if (route.provider && route.model) active = await ctx.get?.('llm')?.resolveModelInfo?.(route.provider, route.model, exec?.signal)
|
|
48
|
+
} catch { /* missing capability evidence selects text, not silence */ }
|
|
49
|
+
if (exec?.signal?.aborted) return { kind: null, reason: 'cancelled' }
|
|
50
|
+
const evidence = visionEvidence(exec, { ...options, adapterVersion: active?.adapterVersion || 'unversioned' })
|
|
51
|
+
const base = { route, detail, ...evidence }
|
|
52
|
+
if (evidence.grounding === 'failed') return { ...base, kind: 'state', reason: 'current grounding record requires UIA/text readback' }
|
|
53
|
+
if (!active) return { ...base, kind: 'state', reason: 'model-capabilities-unavailable' }
|
|
54
|
+
if (!active.inputModalities?.includes('image')) return { ...base, kind: 'state', reason: 'model-text-only' }
|
|
55
|
+
if (!ctx.get?.('attachments')?.saveImage) return { ...base, kind: 'state', reason: 'attachments-unavailable' }
|
|
56
|
+
return { ...base, kind: 'image', reason: 'image transport declared; inspect returned evidence to verify effect' }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function feedbackMode (ctx, exec, enabled = true, options) {
|
|
60
|
+
return (await feedbackPolicy(ctx, exec, enabled, options)).kind
|
|
28
61
|
}
|
|
29
62
|
|
|
30
63
|
export const feedbackActions = new Set([
|
package/lib/image-output.js
CHANGED
|
@@ -1,10 +1,44 @@
|
|
|
1
1
|
import { basename } from 'node:path'
|
|
2
|
-
import {
|
|
2
|
+
import { visionEvidence } from './feedback.js'
|
|
3
|
+
import { captureCoordinates } from './media-contract.js'
|
|
4
|
+
|
|
5
|
+
export function screenshotMetadata (shot) {
|
|
6
|
+
const value = {}
|
|
7
|
+
for (const key of ['capturedAt', 'ts', 'imageSize', 'settled', 'settleMs', 'settleDiffPct', 'settleNote',
|
|
8
|
+
'cursor', 'cursorRequested', 'cursorDrawn', 'cursorScreenPos', 'cursorInImage', 'cursorNote']) {
|
|
9
|
+
if (shot[key] !== undefined) value[key] = shot[key]
|
|
10
|
+
}
|
|
11
|
+
return value
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function setCoordinates (value, size) {
|
|
15
|
+
const coordinates = captureCoordinates(value.region, size)
|
|
16
|
+
if (!coordinates) { delete value.coordinates; delete value.coordinateRule; return }
|
|
17
|
+
value.coordinates = coordinates
|
|
18
|
+
value.coordinateRule = 'screen = screenOrigin + imagePixel * screenPerImagePixel (per axis). Do not apply DPI or browser zoom again. After resize/move/zoom, obtain fresh evidence.'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const validImageSize = size => Array.isArray(size) && size.length === 2 && size.every(n => Number.isFinite(n) && n > 0)
|
|
22
|
+
const validPoint = point => Number.isFinite(point?.x) && Number.isFinite(point?.y)
|
|
23
|
+
|
|
24
|
+
function restoreFileGeometry (value) {
|
|
25
|
+
if (!value.sourceImageSize) return
|
|
26
|
+
value.imageSize = value.sourceImageSize
|
|
27
|
+
if (value.sourceCursorInImage) value.cursorInImage = value.sourceCursorInImage
|
|
28
|
+
else delete value.cursorInImage
|
|
29
|
+
delete value.sourceImageSize
|
|
30
|
+
delete value.sourceCursorInImage
|
|
31
|
+
setCoordinates(value, value.imageSize)
|
|
32
|
+
}
|
|
3
33
|
|
|
4
34
|
// DSH image blocks reference the host's durable attachment store. Paths and raw
|
|
5
35
|
// base64 are not image blocks. Keep a usable file when a route cannot accept one.
|
|
6
|
-
export async function attachScreenshot (ctx, exec, value, data) {
|
|
36
|
+
export async function attachScreenshot (ctx, exec, value, data, options = {}) {
|
|
37
|
+
// The local file has geometry even when the model route cannot accept images.
|
|
38
|
+
setCoordinates(value, value.imageSize)
|
|
7
39
|
const fallback = (reason) => {
|
|
40
|
+
delete value.image
|
|
41
|
+
restoreFileGeometry(value)
|
|
8
42
|
value.imageStatus = 'path-only'
|
|
9
43
|
value.imageReason = reason
|
|
10
44
|
value.view = reason === 'model-text-only'
|
|
@@ -14,6 +48,8 @@ export async function attachScreenshot (ctx, exec, value, data) {
|
|
|
14
48
|
return value
|
|
15
49
|
}
|
|
16
50
|
try {
|
|
51
|
+
if (!validImageSize(value.imageSize)) return fallback('invalid-source-image-size')
|
|
52
|
+
const sourceSize = [...value.imageSize]
|
|
17
53
|
const attachments = ctx.get?.('attachments')
|
|
18
54
|
const llm = ctx.get?.('llm')
|
|
19
55
|
if (!attachments?.saveImage) return fallback('attachments-unavailable')
|
|
@@ -27,21 +63,32 @@ export async function attachScreenshot (ctx, exec, value, data) {
|
|
|
27
63
|
if (!attachments.imageLimits?.mediaTypes?.includes(value.mime)) return fallback('media-type-unavailable')
|
|
28
64
|
const image = await attachments.saveImage({ data, mediaType: value.mime, name: basename(value.path) })
|
|
29
65
|
if (exec?.signal?.aborted) return fallback('cancelled')
|
|
66
|
+
const attachedSize = [image?.width, image?.height]
|
|
67
|
+
if (!validImageSize(attachedSize)) return fallback('invalid-attachment-image-size')
|
|
68
|
+
const evidence = visionEvidence(exec, { ...options, adapterVersion: active.adapterVersion || 'unversioned' })
|
|
69
|
+
const resized = attachedSize.some((n, axis) => n !== sourceSize[axis])
|
|
70
|
+
if (resized) {
|
|
71
|
+
value.sourceImageSize = sourceSize
|
|
72
|
+
if (validPoint(value.cursorInImage)) {
|
|
73
|
+
value.sourceCursorInImage = { ...value.cursorInImage }
|
|
74
|
+
value.cursorInImage = { x: value.cursorInImage.x * attachedSize[0] / sourceSize[0],
|
|
75
|
+
y: value.cursorInImage.y * attachedSize[1] / sourceSize[1] }
|
|
76
|
+
} else delete value.cursorInImage
|
|
77
|
+
value.imageSize = attachedSize
|
|
78
|
+
}
|
|
30
79
|
value.image = image
|
|
31
80
|
value.imageStatus = 'attached'
|
|
32
|
-
|
|
33
|
-
if (
|
|
34
|
-
value.coordinates = {
|
|
35
|
-
screenOrigin: [r.x, r.y], imageSize: [image.width, image.height],
|
|
36
|
-
screenPerImagePixel: [r.width / image.width, r.height / image.height],
|
|
37
|
-
}
|
|
38
|
-
}
|
|
81
|
+
setCoordinates(value, attachedSize)
|
|
82
|
+
if (resized && value.coordinateRule) value.coordinateRule += ' imageSize, cursorInImage and coordinates describe the attached image. path retains the original capture: use sourceImageSize and sourceCursorInImage for that file; map its pixels using region.width/sourceImageSize[0] and region.height/sourceImageSize[1].'
|
|
39
83
|
value.view = value.settled === false
|
|
40
84
|
? 'NOT settled — may be a partial render; re-shoot.'
|
|
41
85
|
: 'Image attached. Inspect it directly; no read_image needed. Coordinates for clicks are screen pixels.'
|
|
42
|
-
|
|
86
|
+
value.visionEvidence = evidence
|
|
87
|
+
if (value.visionEvidence.grounding === 'failed') {
|
|
43
88
|
value.visionStatus = 'unverified-route'
|
|
44
|
-
value.view = 'Image transport succeeded, but
|
|
89
|
+
value.view = 'Image transport succeeded, but a current route record reports failed grounding. Use UIA/text readback; the image and path remain available. See visionEvidence for the scope and expiry.'
|
|
90
|
+
} else {
|
|
91
|
+
value.visionStatus = value.visionEvidence.grounding === 'verified' ? 'grounding-verified' : 'grounding-unverified'
|
|
45
92
|
}
|
|
46
93
|
return value
|
|
47
94
|
} catch (error) {
|
|
@@ -75,6 +122,7 @@ export function selectBatchScreenshot (result, automatic, kind = 'image') {
|
|
|
75
122
|
const shot = action.result
|
|
76
123
|
if (shot?.image && shot !== latest) {
|
|
77
124
|
delete shot.image
|
|
125
|
+
restoreFileGeometry(shot)
|
|
78
126
|
shot.imageStatus = 'path-only'
|
|
79
127
|
shot.imageReason = 'earlier-batch-image'
|
|
80
128
|
shot.view = kind === 'state'
|
package/lib/index.js
CHANGED
|
@@ -31,8 +31,8 @@ import Schema from '@deepseek-ai/schemastery'
|
|
|
31
31
|
import { ComputerUse } from './core/index.js'
|
|
32
32
|
import { policyDecision, HIGH_RISK_NOTE } from './policy.js'
|
|
33
33
|
import { acknowledge, isAcked, ACK_HINT, isDrivingCall } from './skill-gate.js'
|
|
34
|
-
import { mkDataDir, snapshotPath,
|
|
35
|
-
import { registerTools } from './tools.js'
|
|
34
|
+
import { mkDataDir, snapshotPath, installAudit } from './audit.js'
|
|
35
|
+
import { registerTools, isCycleStateOnly } from './tools.js'
|
|
36
36
|
import { installToolExposure, ACTIVATION_TOOL } from './exposure.js'
|
|
37
37
|
import { scopeOf } from '@deepseek-ai/dsh-scope'
|
|
38
38
|
import { createCycleGate, agentKeyOf } from './cycle.js'
|
|
@@ -382,7 +382,14 @@ export const inject = ['tools', 'userQuestions']
|
|
|
382
382
|
export const Config = Schema.object({
|
|
383
383
|
enabled: Schema.boolean().default(true).description('enable the computer-use toolset'),
|
|
384
384
|
progressiveTools: Schema.boolean().default(true).description('expose desktop execution tools only to Agents that load/activate computer-use; brakes remain available'),
|
|
385
|
-
actionFeedback: Schema.boolean().default(true).description('post-input
|
|
385
|
+
actionFeedback: Schema.boolean().default(true).description('automatic post-input observation; per-call feedback can explicitly select compact, full or none'),
|
|
386
|
+
actionFeedbackDetail: Schema.union([Schema.const('compact'), Schema.const('full'), Schema.const('none')]).default('compact').description('compact bounds local text readback to 6 KiB; vision keeps its image and crops only an explicitly requested verificationRegion. full retains complete route-specific feedback'),
|
|
387
|
+
feedbackRules: Schema.array(Schema.object({
|
|
388
|
+
provider: Schema.string().required(), model: Schema.string().required(),
|
|
389
|
+
adapterVersion: Schema.string().required().description('exact adapter version, or unversioned for a conservative failed-test fallback'),
|
|
390
|
+
grounding: Schema.union([Schema.const('failed'), Schema.const('verified')]).required(),
|
|
391
|
+
testedAt: Schema.string().required(), expiresAt: Schema.string().required(), reason: Schema.string(),
|
|
392
|
+
})).default(undefined).description('optional dated route grounding evidence; defaults to bundled expiring records; [] removes historical overrides'),
|
|
386
393
|
automationMode: Schema.union([
|
|
387
394
|
Schema.const('read-only').description('observation only'),
|
|
388
395
|
Schema.const('standard').description('actuations require approval'),
|
|
@@ -436,7 +443,7 @@ export function apply (ctx, config) {
|
|
|
436
443
|
rateLimit: { max: config.maxActionsPerMinute, windowMs: 60_000 },
|
|
437
444
|
log: (m) => log.info(m),
|
|
438
445
|
})
|
|
439
|
-
|
|
446
|
+
const detachAudit = installAudit(cu, dataDir)
|
|
440
447
|
|
|
441
448
|
// ---- the live agent of the session that drives this machine ------------------------------
|
|
442
449
|
// Captured from the tool execution itself (`exec.agent` is the live Agent object); a session
|
|
@@ -560,7 +567,14 @@ export function apply (ctx, config) {
|
|
|
560
567
|
const expect = exitRecordText()
|
|
561
568
|
// Rejections ride the SHARED qualification promise: every later call in this turn awaits the
|
|
562
569
|
// same outcome instead of retrying it into a resurrection.
|
|
563
|
-
|
|
570
|
+
const cycleId = gate.snapshot().id
|
|
571
|
+
const openingContext = { signal: exec?.signal, cycleId, sessionKey: cl.sessionKey, agentKey: cl.agentKey,
|
|
572
|
+
assertOwnership: () => gate.assertOwnership(cl, cycleId) }
|
|
573
|
+
const resume = async () => {
|
|
574
|
+
await cu.resume(expect)
|
|
575
|
+
}
|
|
576
|
+
if (cu.withInputContext) await cu.withInputContext(openingContext, resume)
|
|
577
|
+
else await resume()
|
|
564
578
|
if (!cu.exited && !exitedLatch()) {
|
|
565
579
|
log.info('opened a new computer-use cycle for this turn (cleared the Ctrl+Alt+Q record left by an earlier cycle)')
|
|
566
580
|
return { opened: true }
|
|
@@ -596,6 +610,10 @@ export function apply (ctx, config) {
|
|
|
596
610
|
paused: pausedOrAsking, report: error => log.warn(`computer-use terminal cleanup failed: ${error.message}`),
|
|
597
611
|
})
|
|
598
612
|
const hooks = {
|
|
613
|
+
contextOf: (exec) => {
|
|
614
|
+
const owner = exec && typeof exec === 'object' ? callOwners.get(exec) : null
|
|
615
|
+
return owner ? { cycleId: owner.cycleId, sessionKey: owner.claim.sessionKey, agentKey: owner.claim.agentKey } : {}
|
|
616
|
+
},
|
|
599
617
|
assertOwnership: (exec) => {
|
|
600
618
|
const owner = exec && typeof exec === 'object' ? callOwners.get(exec) : null
|
|
601
619
|
if (!owner) {
|
|
@@ -608,7 +626,7 @@ export function apply (ctx, config) {
|
|
|
608
626
|
releaseCycle: (exec, why) => lifecycle.release(exec, why),
|
|
609
627
|
onStart: (exec) => {
|
|
610
628
|
inFlight++
|
|
611
|
-
if (exec?.name
|
|
629
|
+
if (isCycleStateOnly(exec?.name, exec?.arguments)) {
|
|
612
630
|
callOwners.set(exec, { claim: claimOf(exec), cycleId: gate.snapshot().id })
|
|
613
631
|
}
|
|
614
632
|
},
|
|
@@ -783,6 +801,23 @@ export function apply (ctx, config) {
|
|
|
783
801
|
// The brake. WHO caused it is carried by `why` — do not name a key here: the same brake also
|
|
784
802
|
// is reserved for a durable manual/explicit pause. Ordinary physical activity
|
|
785
803
|
// travels through the core handoff state and never injects a wrap-up notice.
|
|
804
|
+
// Secure-desktop transitions (UAC / lock screen, human request 2026-11-04): the worker already
|
|
805
|
+
// refuses to dispatch while it stands; the card tells the human what only they can do, and the
|
|
806
|
+
// cleared card tells them work has resumed on its own. No cycle state is touched here — this is
|
|
807
|
+
// a WAIT, not a pause: ownership, lease and lights all stay exactly as they were.
|
|
808
|
+
cu.on('secure', (e) => {
|
|
809
|
+
if (e && e.why === 'active') {
|
|
810
|
+
notice(
|
|
811
|
+
'系统确认弹窗(UAC/锁屏)已出现 — AI 已暂停键鼠派发。需要你手动完成系统确认;确认后桌面恢复时,AI 会自动继续。' +
|
|
812
|
+
'Secure desktop detected — input dispatch paused until you answer the system prompt.',
|
|
813
|
+
'computer-use: secure desktop', { agent: bus.agent }).catch(() => {})
|
|
814
|
+
} else {
|
|
815
|
+
notice(
|
|
816
|
+
'系统确认已结束 — 桌面恢复,AI 自动继续键鼠操作。Secure desktop cleared — dispatch resumed automatically.',
|
|
817
|
+
'computer-use: secure desktop cleared', { agent: bus.agent }).catch(() => {})
|
|
818
|
+
}
|
|
819
|
+
})
|
|
820
|
+
|
|
786
821
|
cu.on('panic', (e) => {
|
|
787
822
|
// NO calm() HERE (F1, 2026-09-13). calm in the worker is `CycleLit=false; Disarm(); Glow.Kill()`:
|
|
788
823
|
// on a PAUSE it ended the cycle the pause is supposed to keep alive, dropped the monitors, and
|
|
@@ -1137,7 +1172,7 @@ export function apply (ctx, config) {
|
|
|
1137
1172
|
})
|
|
1138
1173
|
|
|
1139
1174
|
ctx.effect(() => {
|
|
1140
|
-
const disposer = () => { cu.kill().catch(() => {}) }
|
|
1175
|
+
const disposer = () => { return cu.kill().catch(() => {}).finally(detachAudit) }
|
|
1141
1176
|
return disposer
|
|
1142
1177
|
})
|
|
1143
1178
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// One command grammar for approval and dispatch. Native parsing implements the
|
|
2
|
+
// same contract for direct worker clients; cross-language fixtures guard parity.
|
|
3
|
+
export const KEY_ALIASES = Object.freeze({
|
|
4
|
+
control: 'ctrl', lwin: 'win', windows: 'win', meta: 'win',
|
|
5
|
+
return: 'enter', escape: 'esc', bksp: 'backspace', del: 'delete',
|
|
6
|
+
ins: 'insert', pageup: 'pgup', pagedown: 'pgdn', capslock: 'caps', printscreen: 'prtsc',
|
|
7
|
+
})
|
|
8
|
+
const modifierOrder = ['ctrl', 'alt', 'shift', 'win', 'rwin']
|
|
9
|
+
const named = new Set([...modifierOrder, 'enter', 'esc', 'tab', 'space', 'backspace', 'delete',
|
|
10
|
+
'insert', 'home', 'end', 'pgup', 'pgdn', 'up', 'down', 'left', 'right', 'caps', 'apps',
|
|
11
|
+
'prtsc', 'numlock', 'plus', 'minus', 'comma', 'period'])
|
|
12
|
+
|
|
13
|
+
export function parseKeyCommand (combo) {
|
|
14
|
+
if (typeof combo !== 'string' || !combo.trim()) throw new Error('KEY_COMMAND_INVALID: nonempty key combo required')
|
|
15
|
+
const unique = []
|
|
16
|
+
for (const part of combo.split('+')) {
|
|
17
|
+
const raw = part.trim().toLowerCase()
|
|
18
|
+
const key = KEY_ALIASES[raw] || raw
|
|
19
|
+
if (!key || !(named.has(key) || /^[a-z0-9]$/.test(key) || /^f(?:[1-9]|1\d|2[0-4])$/.test(key))) {
|
|
20
|
+
throw new Error(`KEY_COMMAND_INVALID: unknown or empty key ${JSON.stringify(raw)}`)
|
|
21
|
+
}
|
|
22
|
+
if (!unique.includes(key)) unique.push(key)
|
|
23
|
+
}
|
|
24
|
+
const modifiers = modifierOrder.filter(key => unique.includes(key))
|
|
25
|
+
const keys = unique.filter(key => !modifierOrder.includes(key))
|
|
26
|
+
const tokens = [...modifiers, ...keys]
|
|
27
|
+
return { combo: tokens.join('+'), tokens, modifiers, keys }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function keyCommandRisk (combo) {
|
|
31
|
+
const { tokens } = parseKeyCommand(combo)
|
|
32
|
+
const has = key => tokens.includes(key)
|
|
33
|
+
const win = has('win') || has('rwin')
|
|
34
|
+
return has('delete') || has('backspace') ||
|
|
35
|
+
(win && ['r', 'l', 'd'].some(has)) || (has('alt') && has('f4')) ||
|
|
36
|
+
(has('ctrl') && has('shift') && has('esc'))
|
|
37
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Coordinate metadata is transport-independent; attachments and MCP content
|
|
2
|
+
// must describe the same captured pixels without serializing binary bytes as text.
|
|
3
|
+
export function captureCoordinates (region, imageSize) {
|
|
4
|
+
if (!region || ![region.x, region.y, region.width, region.height].every(Number.isFinite) ||
|
|
5
|
+
region.width <= 0 || region.height <= 0 || !Array.isArray(imageSize) || imageSize.length !== 2 ||
|
|
6
|
+
!imageSize.every(n => Number.isFinite(n) && n > 0)) return null
|
|
7
|
+
return { screenOrigin: [region.x, region.y], imageSize,
|
|
8
|
+
screenPerImagePixel: [region.width / imageSize[0], region.height / imageSize[1]] }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function imageMime (bytes, declared) {
|
|
12
|
+
if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return 'image/png'
|
|
13
|
+
if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return 'image/jpeg'
|
|
14
|
+
if (declared === 'image/png' || declared === 'image/jpeg') return declared
|
|
15
|
+
throw new Error('IMAGE_FORMAT_UNKNOWN: missing supported image signature or MIME')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function splitMediaResult (value) {
|
|
19
|
+
const images = []
|
|
20
|
+
const clean = (input, location) => {
|
|
21
|
+
if (Buffer.isBuffer(input)) return undefined
|
|
22
|
+
if (Array.isArray(input)) return input.map((child, i) => clean(child, `${location}[${i}]`))
|
|
23
|
+
if (!input || typeof input !== 'object') return input
|
|
24
|
+
const output = {}
|
|
25
|
+
for (const [key, child] of Object.entries(input)) {
|
|
26
|
+
if (!Buffer.isBuffer(child)) output[key] = clean(child, `${location}.${key}`)
|
|
27
|
+
}
|
|
28
|
+
const bytes = Buffer.isBuffer(input.annotated) ? input.annotated
|
|
29
|
+
: Buffer.isBuffer(input.image) ? input.image : Buffer.isBuffer(input.raw) ? input.raw : null
|
|
30
|
+
if (bytes) {
|
|
31
|
+
const mime = imageMime(bytes, input.mime)
|
|
32
|
+
output.mime = mime
|
|
33
|
+
const coordinates = captureCoordinates(input.region, input.imageSize)
|
|
34
|
+
if (coordinates) output.coordinates = coordinates
|
|
35
|
+
images.push({ bytes, mime, location, metadata: output })
|
|
36
|
+
}
|
|
37
|
+
return output
|
|
38
|
+
}
|
|
39
|
+
const text = clean(value, 'result')
|
|
40
|
+
const latest = images.at(-1)
|
|
41
|
+
for (const entry of images) entry.metadata.imageStatus = entry === latest ? 'attached' : 'omitted-earlier-batch-image'
|
|
42
|
+
return { value: text, ...(latest ? { image: latest.bytes, mime: latest.mime, imageSource: latest.location } : {}) }
|
|
43
|
+
}
|