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 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
- return [{ type: 'text', text: lines.join('\n') }]
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.3.0",
4
+ "version": "0.4.0",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.22.0",
7
7
  "engines": {