dsh-mobilecode 0.3.0 → 0.4.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 +11 -0
- package/lib/index.js +12 -4
- package/lib/vision.js +130 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -115,6 +115,17 @@ The Devices pane shows a real-time mirror of the attached device. It is produced
|
|
|
115
115
|
key (`~/.dsh/mobilecode/stream-access.key`, `0600`), expiring within 10 minutes
|
|
116
116
|
and re-minted automatically. Coordinates are normalized 0..1 of the streamed
|
|
117
117
|
frame, so one mapping serves every rotation.
|
|
118
|
+
|
|
119
|
+
**Multimodal screenshots**
|
|
120
|
+
|
|
121
|
+
When the routed model declares image input, `device_screen` delivers the
|
|
122
|
+
screenshot **as an image block** — the model literally sees the screen instead of
|
|
123
|
+
reading a file path. This mirrors the in-tree `read_image` tool: the PNG is
|
|
124
|
+
committed to DSH's durable attachment store (`ctx.get('attachments').saveImage`)
|
|
125
|
+
and returned as a `{type:'image', attachment}` content block, gated on
|
|
126
|
+
`llm.resolveModelInfo(...).inputModalities`. It **degrades, never refuses**: a
|
|
127
|
+
text-only route, a headless profile, or a host without the attachment store keeps
|
|
128
|
+
the plain JSON summary (path + UI tree + OCR) with no new error.
|
|
118
129
|
- `device_log` — device logs: logcat `main`/`crash`/`events`/`kernel` buffers
|
|
119
130
|
(kernel = dmesg, needs adb root — works on emulators) with an optional
|
|
120
131
|
case-insensitive substring filter, capped line count.
|
package/lib/index.js
CHANGED
|
@@ -21,6 +21,7 @@ import * as UiTree from './uitree.js'
|
|
|
21
21
|
import * as FrameSource from './frame-source.js'
|
|
22
22
|
import * as StreamAccess from './stream-access.js'
|
|
23
23
|
import { AndroidStreamHost, ROTATION_CYCLE } from './android-stream.js'
|
|
24
|
+
import * as Vision from './vision.js'
|
|
24
25
|
import { DevicePreviewEngine } from './device-preview.js'
|
|
25
26
|
import * as Setup from './setup.js'
|
|
26
27
|
import { registerMobileSkill } from './skill.js'
|
|
@@ -1120,7 +1121,7 @@ function deviceStreamTool(host, access) {
|
|
|
1120
1121
|
})
|
|
1121
1122
|
}
|
|
1122
1123
|
|
|
1123
|
-
function deviceScreenTool(engine) {
|
|
1124
|
+
function deviceScreenTool(engine, vision) {
|
|
1124
1125
|
return defineTool({
|
|
1125
1126
|
name: 'device_screen',
|
|
1126
1127
|
description: 'See what is on an attached Android device right now: captures the screen as a PNG file, dumps the UI ' +
|
|
@@ -1177,6 +1178,7 @@ function deviceScreenTool(engine) {
|
|
|
1177
1178
|
},
|
|
1178
1179
|
},
|
|
1179
1180
|
ocrError: { type: 'string' },
|
|
1181
|
+
image: Vision.IMAGE_REF_SCHEMA,
|
|
1180
1182
|
},
|
|
1181
1183
|
},
|
|
1182
1184
|
render: (_args, value) => {
|
|
@@ -1200,10 +1202,13 @@ function deviceScreenTool(engine) {
|
|
|
1200
1202
|
if (v.ocr.length > 40) lines.push(` … and ${v.ocr.length - 40} more`)
|
|
1201
1203
|
}
|
|
1202
1204
|
if (!v.ui?.length && !v.ocr?.length) lines.push('No text found on screen.')
|
|
1203
|
-
|
|
1205
|
+
const blocks = [{ type: 'text', text: lines.join('\n') }]
|
|
1206
|
+
// When the routed model accepts images, the screenshot rides along as a
|
|
1207
|
+
// real image block so the model SEES the screen (see lib/vision.js).
|
|
1208
|
+
return Vision.appendImageBlock(blocks, v)
|
|
1204
1209
|
},
|
|
1205
1210
|
},
|
|
1206
|
-
async execute(args) {
|
|
1211
|
+
async execute(args, exec) {
|
|
1207
1212
|
const serial = await requireAndroidDevice(args.serial)
|
|
1208
1213
|
const png = await DeviceBuild.screenCapture(serial, args.directory)
|
|
1209
1214
|
const [ui, foreground, size] = await Promise.all([
|
|
@@ -1227,6 +1232,8 @@ function deviceScreenTool(engine) {
|
|
|
1227
1232
|
else out.ocrError = 'PaddleOCR returned no text (or failed silently)'
|
|
1228
1233
|
}
|
|
1229
1234
|
}
|
|
1235
|
+
const image = await Vision.maybeAttachScreenshot(vision, png, exec)
|
|
1236
|
+
if (image !== undefined) out.image = image
|
|
1230
1237
|
return out
|
|
1231
1238
|
},
|
|
1232
1239
|
})
|
|
@@ -1604,6 +1611,7 @@ export function apply(ctx, config) {
|
|
|
1604
1611
|
const engine = new DevicePreviewEngine()
|
|
1605
1612
|
const streamHost = new AndroidStreamHost()
|
|
1606
1613
|
const streamAccess = new StreamAccess.StreamAccessController()
|
|
1614
|
+
const vision = Vision.resolveVisionServices(ctx)
|
|
1607
1615
|
const handle = {
|
|
1608
1616
|
engine,
|
|
1609
1617
|
stream: streamHost,
|
|
@@ -1644,7 +1652,7 @@ export function apply(ctx, config) {
|
|
|
1644
1652
|
const disposers = [
|
|
1645
1653
|
deviceRunTool(engine, config),
|
|
1646
1654
|
deviceDetectTool(engine, config),
|
|
1647
|
-
deviceScreenTool(engine),
|
|
1655
|
+
deviceScreenTool(engine, vision),
|
|
1648
1656
|
deviceUiTreeTool(),
|
|
1649
1657
|
deviceTapElementTool(),
|
|
1650
1658
|
deviceWaitForTool(),
|
package/lib/vision.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-mobilecode — native multimodal delivery.
|
|
3
|
+
*
|
|
4
|
+
* When the routed model declares image input, the capture tools hand the model
|
|
5
|
+
* the screenshot ITSELF (a `{type:'image', attachment}` block) instead of only a
|
|
6
|
+
* file path it would have to open. DSH 0.1.1 carries images end to end: tool
|
|
7
|
+
* results may contain image blocks, bytes live in the durable attachment store
|
|
8
|
+
* (`ctx.get('attachments')`), and `llm.resolveModelInfo(...).inputModalities`
|
|
9
|
+
* says whether the routed model accepts images. This mirrors the in-tree
|
|
10
|
+
* `read_image` tool in dsh-tool-fs.
|
|
11
|
+
*
|
|
12
|
+
* The deliberate difference from `read_image`: where that tool REFUSES on a
|
|
13
|
+
* text-only route (the image is its whole point), the capture tools here
|
|
14
|
+
* DEGRADE. The primary output is always the JSON summary; the image block is an
|
|
15
|
+
* enhancement added only when (a) the attachment store is mounted, (b) the
|
|
16
|
+
* calling route's resolved model declares `image` input, and (c) admission
|
|
17
|
+
* succeeds. Any failure in that chain silently keeps the text-only behavior, so
|
|
18
|
+
* text-only routes, headless profiles, and older hosts never see a new error.
|
|
19
|
+
*
|
|
20
|
+
* Everything is typed structurally — the plugin is plain JS and must not depend
|
|
21
|
+
* on the host's attachment type exports.
|
|
22
|
+
* @module vision
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { readFile } from 'node:fs/promises'
|
|
26
|
+
import path from 'node:path'
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the optional vision services from the plugin context. Both come back
|
|
30
|
+
* absent on hosts that do not mount them; every consumer treats that as
|
|
31
|
+
* "stay text-only".
|
|
32
|
+
*/
|
|
33
|
+
export function resolveVisionServices(ctx) {
|
|
34
|
+
const get = typeof ctx?.get === 'function' ? ctx.get.bind(ctx) : undefined
|
|
35
|
+
if (get === undefined) return {}
|
|
36
|
+
const attachments = get('attachments')
|
|
37
|
+
const llm = get('llm')
|
|
38
|
+
return {
|
|
39
|
+
...(attachments !== undefined && typeof attachments.saveImage === 'function' ? { attachments } : {}),
|
|
40
|
+
...(llm !== undefined && typeof llm.resolveModelInfo === 'function' ? { llm } : {}),
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* True when the calling route's resolved model declares `image` input. Mirrors
|
|
46
|
+
* `read_image`'s gate (request-header config first, then agent options) but
|
|
47
|
+
* answers false instead of throwing: a tool result that enters durable history
|
|
48
|
+
* must not carry an image its route cannot replay.
|
|
49
|
+
*/
|
|
50
|
+
export async function imageInputActive(services, exec) {
|
|
51
|
+
if (services.llm === undefined || services.attachments === undefined) return false
|
|
52
|
+
try {
|
|
53
|
+
const routed = exec?.agent?.session?.requestHeader?.()?.config
|
|
54
|
+
const provider = routed?.provider ?? exec?.agent?.options?.provider
|
|
55
|
+
const model = routed?.model ?? exec?.agent?.options?.model
|
|
56
|
+
if (provider === undefined || model === undefined) return false
|
|
57
|
+
const info = await services.llm.resolveModelInfo(provider, model, exec?.signal)
|
|
58
|
+
return info?.inputModalities?.includes('image') === true
|
|
59
|
+
} catch {
|
|
60
|
+
return false
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Durably commit one screenshot PNG and return the plain reference for the
|
|
66
|
+
* result value, or undefined when the store is absent or admission fails
|
|
67
|
+
* (oversized, malformed) — never an error, per the degrade-not-refuse rule.
|
|
68
|
+
*/
|
|
69
|
+
export async function saveScreenshotAttachment(services, png, name) {
|
|
70
|
+
const attachments = services.attachments
|
|
71
|
+
if (attachments === undefined) return undefined
|
|
72
|
+
try {
|
|
73
|
+
const ref = await attachments.saveImage({ data: png, mediaType: 'image/png', name })
|
|
74
|
+
if (typeof ref?.attachmentId !== 'string' || ref.attachmentId === '') return undefined
|
|
75
|
+
return {
|
|
76
|
+
attachmentId: ref.attachmentId,
|
|
77
|
+
mediaType: ref.mediaType,
|
|
78
|
+
bytes: ref.bytes,
|
|
79
|
+
width: ref.width,
|
|
80
|
+
height: ref.height,
|
|
81
|
+
...(ref.name === undefined ? {} : { name: ref.name }),
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
return undefined
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Convenience for the capture tools: gate on the route, read the file, and save
|
|
90
|
+
* the attachment — returning undefined (degrade) on any miss. Never throws.
|
|
91
|
+
*/
|
|
92
|
+
export async function maybeAttachScreenshot(services, filePath, exec) {
|
|
93
|
+
if (services.attachments === undefined || typeof filePath !== 'string' || filePath === '') return undefined
|
|
94
|
+
if (!(await imageInputActive(services, exec))) return undefined
|
|
95
|
+
try {
|
|
96
|
+
const data = await readFile(filePath)
|
|
97
|
+
return await saveScreenshotAttachment(services, data, path.basename(filePath))
|
|
98
|
+
} catch {
|
|
99
|
+
return undefined
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Output-schema fragment for the optional `image` result field. */
|
|
104
|
+
export const IMAGE_REF_SCHEMA = {
|
|
105
|
+
type: 'object',
|
|
106
|
+
additionalProperties: false,
|
|
107
|
+
description: 'Durable attachment reference for the screenshot delivered to the model as an image block '
|
|
108
|
+
+ '(present only when the routed model declares image input).',
|
|
109
|
+
properties: {
|
|
110
|
+
attachmentId: { type: 'string', required: true },
|
|
111
|
+
mediaType: { type: 'string', required: true },
|
|
112
|
+
bytes: { type: 'number', required: true },
|
|
113
|
+
width: { type: 'number', required: true },
|
|
114
|
+
height: { type: 'number', required: true },
|
|
115
|
+
name: { type: 'string' },
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Append the image block to a render's content blocks when the value carries an
|
|
121
|
+
* `image` ref — so an image-capable model SEES the screen. Returns the same
|
|
122
|
+
* array for chaining.
|
|
123
|
+
*/
|
|
124
|
+
export function appendImageBlock(blocks, value) {
|
|
125
|
+
const image = value?.image
|
|
126
|
+
if (image !== undefined && typeof image.attachmentId === 'string') {
|
|
127
|
+
blocks.push({ type: 'image', attachment: image })
|
|
128
|
+
}
|
|
129
|
+
return blocks
|
|
130
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobilecode",
|
|
3
3
|
"description": "MobileCode for the dsh web GUI: detect iOS/Android projects, run serve-sim / serve-avd preview servers, and build-install-launch the app on the simulator or emulator from the session — plus agent tools (device_run, device_detect). Hot-pluggable — mounted via the profile bundle list + cordis.patch.yml, no dsh source changes.",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.4.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@11.22.0",
|
|
7
7
|
"engines": {
|