dsh-inner-browser-use 0.27.0 → 0.34.28

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/read.js CHANGED
@@ -257,13 +257,20 @@ function snapshot(mode, budget, max) {
257
257
  const missing = []
258
258
  for (const el of document.querySelectorAll('img,canvas')) {
259
259
  const b = box(el)
260
- if (b.width < 64 || b.height < 64 || b.width * b.height < 8000) continue
260
+ /* A picture that failed to arrive shrinks to an error icon, so its own
261
+ declared size is what the page meant to show. */
262
+ const askW = Number(el.getAttribute?.('width')) || 0
263
+ const askH = Number(el.getAttribute?.('height')) || 0
264
+ const wide = Math.max(b.width, askW)
265
+ const high = Math.max(b.height, askH)
266
+ if (wide < 64 || high < 64 || wide * high < 8000) continue
261
267
  const s = getComputedStyle(el)
262
268
  if (s.visibility === 'hidden' || s.display === 'none') continue
263
269
  /* A picture slot that never got its picture is the commonest way a
264
270
  generated image goes missing, and it is silent by default. */
265
271
  if (el.tagName === 'IMG' && el.complete && el.naturalWidth === 0) {
266
- missing.push({ width: Math.round(b.width), height: Math.round(b.height), name: String(el.getAttribute('alt') ?? '').replace(/\s+/g, ' ').trim().slice(0, 60) })
272
+ missing.push({ width: Math.round(wide), height: Math.round(high) })
273
+ missing[missing.length - 1].name = String(el.getAttribute('alt') ?? '').replace(/\s+/g, ' ').trim().slice(0, 60)
267
274
  continue
268
275
  }
269
276
  const w = el.tagName === 'CANVAS' ? el.width : el.naturalWidth || Math.round(b.width)
@@ -300,6 +307,38 @@ function snapshot(mode, budget, max) {
300
307
  }
301
308
  return said
302
309
  })()
310
+ /* What no amount of text will tell the reader: the screen shows something
311
+ that has no words in the tree. Counted here so every read can say it. */
312
+ const blind = (() => {
313
+ const out = { pictures: 0, canvases: 0, videos: 0, frames: 0 }
314
+ const speaks = (el) => {
315
+ const said = String(el.getAttribute?.('alt') ?? '').trim() || String(el.getAttribute?.('aria-label') ?? '').trim() || String(el.getAttribute?.('title') ?? '').trim()
316
+ if (said.length > 1) return true
317
+ let near = el.closest?.('figure, picture')
318
+ if (near) {
319
+ const words = String(near.querySelector?.('figcaption')?.textContent ?? '').trim()
320
+ if (words.length > 1) return true
321
+ }
322
+ return false
323
+ }
324
+ const shows = (el) => {
325
+ const b = box(el)
326
+ if (b.width < 48 || b.height < 48) return false
327
+ if (!inView(b)) return false
328
+ const st = getComputedStyle(el)
329
+ return st.visibility !== 'hidden' && st.display !== 'none' && Number(st.opacity) > 0.05
330
+ }
331
+ for (const el of document.querySelectorAll('img')) {
332
+ if (!shows(el) || speaks(el)) continue
333
+ out.pictures += 1
334
+ }
335
+ for (const el of document.querySelectorAll('canvas')) if (shows(el)) out.canvases += 1
336
+ for (const el of document.querySelectorAll('video')) if (shows(el)) out.videos += 1
337
+ /* A tracking pixel of a frame is not something the reader is missing: only
338
+ frames that occupy the screen are worth a sentence. */
339
+ for (const el of document.querySelectorAll('iframe')) if (shows(el)) out.frames += 1
340
+ return out
341
+ })()
303
342
  return JSON.stringify({
304
343
  url: location.href, // ui-copy-ignore
305
344
  title: document.title, // ui-copy-ignore
@@ -313,6 +352,7 @@ function snapshot(mode, budget, max) {
313
352
  })(),
314
353
  controls: picked,
315
354
  picture,
355
+ blind,
316
356
  })
317
357
  })()`
318
358
  }
@@ -378,8 +418,8 @@ export function pictures(ref, count) {
378
418
  if (el === null || el === undefined || seen.has(el)) return
379
419
  seen.add(el)
380
420
  const b = el.getBoundingClientRect()
381
- const w = kind === 'canvas' ? el.width : el.naturalWidth || b.width
382
- const h = kind === 'canvas' ? el.height : el.naturalHeight || b.height
421
+ const w = kind === 'canvas' ? el.width : kind === 'video' ? el.videoWidth || b.width : el.naturalWidth || b.width
422
+ const h = kind === 'canvas' ? el.height : kind === 'video' ? el.videoHeight || b.height : el.naturalHeight || b.height
383
423
  if (w < 48 || h < 48) return
384
424
  if (b.width < 8 || b.height < 8) return
385
425
  const s = getComputedStyle(el)
@@ -392,9 +432,13 @@ export function pictures(ref, count) {
392
432
  const tag = node.tagName
393
433
  if (tag === 'IMG') add(node, 'img')
394
434
  else if (tag === 'CANVAS') add(node, 'canvas')
435
+ /* A paused video is the frame the person is looking at; it never arrives as
436
+ an image file, so it has to be drawn out of the element. */
437
+ else if (tag === 'VIDEO' && (node.videoWidth || 0) > 0) add(node, 'video')
395
438
  }
396
439
  if (where && where.tagName === 'IMG') add(where, 'img')
397
440
  if (where && where.tagName === 'CANVAS') add(where, 'canvas')
441
+ if (where && where.tagName === 'VIDEO' && (where.videoWidth || 0) > 0) add(where, 'video')
398
442
  picked.sort((a, b) => b.area - a.area)
399
443
  /* A picture nobody can look at still has to be describable: how much of it is
400
444
  one colour tells a blank chart from a chart. Sampled at 32x32, which is
@@ -428,8 +472,15 @@ export function pictures(ref, count) {
428
472
  let data = ''
429
473
  let type = 'image/png'
430
474
  try {
431
- if (item.kind === 'canvas') {
432
- data = item.el.toDataURL('image/png')
475
+ if (item.kind === 'canvas' || item.kind === 'video') {
476
+ /* Same path for both: a video frame is copied onto a board and read as
477
+ a picture. Nothing here needs the video to be playing. */
478
+ const board = document.createElement('canvas')
479
+ board.width = Math.max(1, Math.round(item.w))
480
+ board.height = Math.max(1, Math.round(item.h))
481
+ const pen = board.getContext('2d')
482
+ pen.drawImage(item.el, 0, 0, board.width, board.height)
483
+ data = board.toDataURL('image/png')
433
484
  } else {
434
485
  const url = item.el.currentSrc || item.el.src || ''
435
486
  const answer = await fetch(url, { credentials: 'include' })
@@ -612,15 +663,15 @@ function routeOf(page, frame, slot) {
612
663
  * @param {any} page - sidecar page.
613
664
  * @param {string} mode - read mode.
614
665
  * @param {number} budget - characters of text to keep.
615
- * @returns {Promise<{text: string, controls: any[]}>} what the frames add.
666
+ * @returns {Promise<{text: string, controls: any[], frames: number}>} what the frames add.
616
667
  */
617
668
  async function readFrames(page, mode, budget) {
618
- if (typeof page.frameList !== 'function') return { text: '', controls: [] }
669
+ if (typeof page.frameList !== 'function') return { text: '', controls: [], frames: 0 }
619
670
  let frames = []
620
671
  try {
621
672
  frames = await page.frameList()
622
673
  } catch (error) {
623
- return { text: '', controls: [] }
674
+ return { text: '', controls: [], frames: 0 }
624
675
  }
625
676
  const children = frames.slice(1)
626
677
  const controls = []
@@ -642,7 +693,7 @@ async function readFrames(page, mode, budget) {
642
693
  continue
643
694
  }
644
695
  }
645
- return { text, controls }
696
+ return { text, controls, frames: children.length }
646
697
  }
647
698
 
648
699
  /** Decode a ref into the frame it lives in and the number that frame knows. */
@@ -699,15 +750,51 @@ export async function readPage(page, options = {}) {
699
750
  scroll: read?.scroll,
700
751
  focused: read?.focused ?? null,
701
752
  controls: Array.isArray(read?.controls) ? read.controls : [],
753
+ blind: blindSpots(read?.blind, framed.frames),
702
754
  }
703
755
  }
704
756
 
757
+ /**
758
+ * Merge what the page counted with what the frame reader managed to see.
759
+ * @param {any} said - counts from the page.
760
+ * @param {number} [read] - frames that answered.
761
+ * @returns {{pictures: number, canvases: number, videos: number, frames: number}} what text cannot show.
762
+ */
763
+ export function blindSpots(said, read) {
764
+ const n = (value) => (Number.isFinite(Number(value)) ? Math.max(0, Math.round(Number(value))) : 0)
765
+ /* The page knows which frames take up the screen; the frame reader knows how
766
+ many exist, tracking pixels included. The screen is what the reader is
767
+ missing, so its count wins whenever the page could count at all. */
768
+ const frames = said !== undefined && said !== null && said.frames !== undefined ? n(said.frames) : n(read)
769
+ return { pictures: n(said?.pictures), canvases: n(said?.canvases), videos: n(said?.videos), frames }
770
+ }
771
+
705
772
  /**
706
773
  * Resolve a ref into a point, at the moment of the action.
707
774
  * @param {any} page - sidecar page.
708
775
  * @param {number} ref - ref from a read.
709
776
  * @returns {Promise<{gone: boolean, x?: number, y?: number, name?: string, kind?: string, off?: boolean, hidden?: boolean, disabled?: boolean}>} where it is now.
710
777
  */
778
+ /**
779
+ * Whether a number still means what it meant when it was read.
780
+ *
781
+ * A page can keep the same element and change its words: "Send" becomes
782
+ * "Stop", a row is reused for another order. Pressing the old number then
783
+ * does something the person never asked for.
784
+ * @param {any} known - the control the read handed out.
785
+ * @param {any} spot - what the number resolves to now.
786
+ * @returns {string} what changed, or '' when it is the same control.
787
+ */
788
+ export function differs(known, spot) {
789
+ if (known === undefined || known === null || spot === undefined || spot === null) return ''
790
+ const before = String(known.name ?? '').replace(/\s+/g, ' ').trim().slice(0, 40)
791
+ const now = String(spot.name ?? '').replace(/\s+/g, ' ').trim().slice(0, 40)
792
+ if (before === '' || now === '' || before === now) return ''
793
+ const head = Math.min(12, before.length, now.length)
794
+ if (head >= 4 && (before.slice(0, head) === now.slice(0, head) || before.includes(now.slice(0, head)) || now.includes(before.slice(0, head)))) return ''
795
+ return `that number now points at "${now}" instead of "${before}" — read the page again and use the new number.`
796
+ }
797
+
711
798
  export async function locate(page, ref) {
712
799
  const split = splitFrameRef(ref)
713
800
  let route = null
@@ -747,6 +834,10 @@ export function renderPage(read, options = {}) {
747
834
  const mode = String(options.mode ?? 'text')
748
835
  const maxChars = Number(options.maxChars ?? 6000)
749
836
  const lines = [read.url]
837
+ /* A browser error page is not a site with nothing on it: it is a site that
838
+ never arrived. Saying so is the only way the reader stops looking for the
839
+ content that would have been there. */
840
+ if (String(read.url).startsWith('chrome-error://')) lines.push('the page did not load — sidecar_nav to open the address again, or sidecar_open if the browser is gone')
750
841
  if (read.title !== '') lines.push(`# ${read.title}`)
751
842
  const shown = read.controls.filter((control) => control.view !== false || mode === 'full')
752
843
  const hidden = read.controls.length - shown.length
@@ -766,6 +857,16 @@ export function renderPage(read, options = {}) {
766
857
  lines.push(body)
767
858
  if (read.textChars > body.length) lines.push(`… ${read.textChars - body.length} more characters`)
768
859
  }
860
+ if (read.blind !== undefined && read.blind !== null) {
861
+ const bits = []
862
+ if (read.blind.pictures > 0) bits.push(`${read.blind.pictures} picture(s) with no words`)
863
+ if (read.blind.canvases > 0) bits.push(`${read.blind.canvases} drawing surface(s)`)
864
+ if (read.blind.videos > 0) bits.push(`${read.blind.videos} video(s)`)
865
+ if (read.blind.frames > 0) bits.push(`${read.blind.frames} embedded frame(s)`)
866
+ /* Only when something is actually out of reach: a line saying "nothing is
867
+ hidden" on every read is a line the reader learns to skip. */
868
+ if (bits.length > 0) lines.push(`text cannot show: ${bits.join(', ')} — sidecar_grab takes the picture, sidecar_shot shows the screen`)
869
+ }
769
870
  if (read.picture && Array.isArray(read.picture.missing)) {
770
871
  /* Something was meant to be here and is not: say that instead of nothing. */
771
872
  for (const gone of read.picture.missing.slice(0, 2)) {
package/review.js ADDED
@@ -0,0 +1,213 @@
1
+ /**
2
+ * A review round: hand a repository address to the chat in the column, take its
3
+ * review back, and remember which commit the review was about. The log is what
4
+ * makes the next round checkable — advice given about a commit that has since
5
+ * moved on is stale, and saying so costs one line instead of a reread.
6
+ */
7
+
8
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
9
+ import { dirname, join } from 'node:path'
10
+
11
+ /** Where rounds are kept when the caller does not say. */
12
+ export const ROUND_DIR = join(process.env.DSH_HOME ?? join(process.env.HOME ?? '.', '.dsh'), 'inner-browser-use', 'rounds')
13
+
14
+ /** How long an answer may be, so a chatty model cannot flood the log. */
15
+ const ANSWER_CAP = 6000
16
+
17
+ /**
18
+ * @param {string} text - one line of configuration.
19
+ * @returns {string} the value, or an empty string.
20
+ */
21
+ function clean(text) {
22
+ return String(text ?? '').replace(/\s+/g, ' ').trim()
23
+ }
24
+
25
+ /**
26
+ * The message to send: enough for a reviewer that has never seen this project,
27
+ * and no more. The address carries the code, so the message does not paste any.
28
+ * @param {{repo?: string, commit?: string, focus?: string}} [what] - what to review.
29
+ * @returns {string} the message.
30
+ */
31
+ export function brief(what = {}) {
32
+ const repo = clean(what.repo)
33
+ /* What the reviewer said last time and what the code did about it: a point
34
+ already answered is not raised again, so a round does not re-litigate. */
35
+ const handled = Array.isArray(what.already) ? what.already.filter((mark) => clean(mark?.status) !== 'open') : []
36
+ const commit = clean(what.commit)
37
+ const focus = clean(what.focus)
38
+ const lines = ['Please review the latest code and tell me what to change.']
39
+ if (repo !== '') lines.push(`Repository: ${repo}`)
40
+ if (commit !== '') lines.push(`Look at commit ${commit} (the newest one) and the files it touches.`)
41
+ else lines.push('Look at the newest commit and the files it touches.')
42
+ if (focus !== '') lines.push(`Focus on: ${focus}`)
43
+ /* The reviewer cannot open the repository, so the change travels with the
44
+ request. Capped: a whole diff would bury the question it is attached to. */
45
+ const diff = String(what.diff ?? '').trim()
46
+ if (diff !== '') {
47
+ const cap = 5000
48
+ lines.push('The change under review, inline (a diff):')
49
+ lines.push(diff.length <= cap ? diff : `${diff.slice(0, cap)}\n… (trimmed)`)
50
+ }
51
+ if (handled.length > 0) {
52
+ lines.push('Already handled from your previous review, do not raise these again:')
53
+ for (const mark of handled.slice(0, 12)) {
54
+ const note = clean(mark.evidence) === '' ? '' : ` (${clean(mark.evidence).slice(0, 120)})`
55
+ lines.push(`- ${clean(mark.text).slice(0, 200)} — ${clean(mark.status)}${note}`)
56
+ }
57
+ }
58
+ lines.push('Answer with a short list: what is wrong, and the smallest change that fixes it. Name the file for each point.')
59
+ return lines.join('\n')
60
+ }
61
+
62
+ /**
63
+ * The suggestions in an answer, one line each: reviewers are asked for a list,
64
+ * and the list is what the next round acts on.
65
+ * @param {string} answer - what the reviewer said.
66
+ * @returns {string[]} the points, in the order given.
67
+ */
68
+ export function points(answer) {
69
+ /* A page read flattens an answer into one line, so a list arrives as
70
+ "intro - first point - second point". Split on the markers a reviewer uses
71
+ for items, keep only pieces that carry a marker, and drop the echo of the
72
+ question when the page repeats it above the answer. */
73
+ const out = []
74
+ const seen = new Set()
75
+ for (const raw of String(answer ?? '').split(/\r?\n/)) {
76
+ const parts = raw.split(/\s+[-\u2022\u2013\u2014]\s+|\s+(?=\d+[.)]\s)/).map((part) => part.trim()).filter((part) => part !== '')
77
+ if (parts.length === 1 && !/^(?:[-*\u2022]|\d+[.)])\s+/.test(parts[0])) continue
78
+ for (let at = 0; at < parts.length; at += 1) {
79
+ let line = parts[at]
80
+ if (at === 0 && !/^(?:[-*\u2022]|\d+[.)])\s+/.test(line)) continue
81
+ line = line.replace(/^(?:Answer:\s*)/i, '').replace(/^(?:[-*\u2022]|\d+[.)])\s+/, '').trim()
82
+ if (/^(?:Please review|Repository:|Look at commit|Focus on:|Answer with)/i.test(line)) continue
83
+ if (line.length < 8 || line.length > 400) continue
84
+ const key = line.toLowerCase()
85
+ if (seen.has(key)) continue
86
+ seen.add(key)
87
+ out.push(line)
88
+ }
89
+ }
90
+ return out.slice(0, 40)
91
+ }
92
+
93
+ /**
94
+ * Write one round down. Append-only: a round is history, not state to rewrite.
95
+ * @param {{dir?: string, commit?: string, question?: string, answer?: string, tookMs?: number}} entry - the round.
96
+ * @returns {{file: string, round: any}} where it went and what was written.
97
+ */
98
+ export function recordRound(entry = {}) {
99
+ const dir = clean(entry.dir) === '' ? ROUND_DIR : clean(entry.dir)
100
+ mkdirSync(dir, { recursive: true })
101
+ const file = join(dir, 'rounds.ndjson')
102
+ const round = {
103
+ at: new Date().toISOString(),
104
+ commit: clean(entry.commit),
105
+ question: clean(entry.question).slice(0, 400),
106
+ answer: String(entry.answer ?? '').replace(/\s+/g, ' ').trim().slice(0, ANSWER_CAP),
107
+ points: points(entry.answer),
108
+ tookMs: Math.max(0, Math.trunc(Number(entry.tookMs ?? 0)) || 0),
109
+ }
110
+ appendFileSync(file, `${JSON.stringify(round)}\n`)
111
+ return { file, round }
112
+ }
113
+
114
+ /**
115
+ * The last round played, for a round that wants to know what it was told before.
116
+ * @param {{dir?: string}} [where] - which log.
117
+ * @returns {any|null} the last round, or null when none was played.
118
+ */
119
+ export function lastRound(where = {}) {
120
+ const dir = clean(where.dir) === '' ? ROUND_DIR : clean(where.dir)
121
+ const file = join(dir, 'rounds.ndjson')
122
+ if (!existsSync(file)) return null
123
+ const rows = readFileSync(file, 'utf8').split('\n').filter((row) => row.trim() !== '')
124
+ if (rows.length === 0) return null
125
+ /* Verdicts are separate rows, appended after the round they judge: find the
126
+ newest round, then fold in the marks written since. */
127
+ /** @type {any[]} */
128
+ const rows2 = []
129
+ for (const row of rows) {
130
+ try {
131
+ rows2.push(JSON.parse(row))
132
+ } catch {
133
+ /* a torn line costs its own row, not the log */
134
+ }
135
+ }
136
+ let at = -1
137
+ for (let i = rows2.length - 1; i >= 0; i -= 1) {
138
+ if (rows2[i]?.kind !== 'verdict') {
139
+ at = i
140
+ break
141
+ }
142
+ }
143
+ if (at < 0) return null
144
+ const round = rows2[at]
145
+ const latest = new Map()
146
+ for (const row of rows2.slice(at + 1)) {
147
+ if (row?.kind !== 'verdict' || clean(row.roundAt) !== clean(round.at)) continue
148
+ for (const mark of Array.isArray(row.marks) ? row.marks : []) if (Number.isInteger(mark?.point)) latest.set(mark.point, mark)
149
+ }
150
+ return { ...round, marks: [...latest.values()].sort((a, b) => a.point - b.point) }
151
+ }
152
+
153
+ /** What a point can be, once the code has looked at it. */
154
+ export const MARK_STATUS = ['fixed', 'verified', 'invalid', 'open']
155
+
156
+ /**
157
+ * Say what happened to the points of the last round. The verdict is history
158
+ * too: it is appended, and the next round reads it so a settled point is not
159
+ * raised twice.
160
+ * @param {{dir?: string, marks?: any[]}} entry - point numbers (1-based) and their outcome.
161
+ * @returns {{file: string, round: any, marks: any[]}|null} what was written, or null when there is no round.
162
+ */
163
+ export function markRound(entry = {}) {
164
+ const dir = clean(entry.dir) === '' ? ROUND_DIR : clean(entry.dir)
165
+ const round = lastRound({ dir })
166
+ if (round === null) return null
167
+ const said = Array.isArray(round.points) ? round.points : []
168
+ /** @type {any[]} */
169
+ const marks = []
170
+ for (const raw of Array.isArray(entry.marks) ? entry.marks : []) {
171
+ const point = Math.trunc(Number(raw?.point ?? 0))
172
+ if (point < 1 || point > said.length) continue
173
+ const status = MARK_STATUS.includes(clean(raw?.status)) ? clean(raw.status) : 'open'
174
+ marks.push({ point, text: said[point - 1], status, evidence: clean(raw?.evidence).slice(0, 200), at: new Date().toISOString() })
175
+ }
176
+ if (marks.length === 0) return null
177
+ const file = join(dir, 'rounds.ndjson')
178
+ mkdirSync(dir, { recursive: true })
179
+ appendFileSync(file, `${JSON.stringify({ at: new Date().toISOString(), kind: 'verdict', roundAt: round.at, commit: round.commit, marks })}\n`)
180
+ return { file, round, marks }
181
+ }
182
+
183
+ /**
184
+ * One line for the model: what the reviewer said last, and whether the code has
185
+ * moved since. A review of an old commit is worth reading and not worth obeying.
186
+ * @param {{dir?: string, commit?: string}} [now] - the commit the code is at now.
187
+ * @returns {string} the note, or an empty string when no round was played.
188
+ */
189
+ export function lastRoundNote(now = {}) {
190
+ const last = lastRound(now)
191
+ if (last === null) return ''
192
+ const commit = clean(now.commit)
193
+ const same = commit === '' || last.commit === '' || last.commit === commit
194
+ const points = Array.isArray(last.points) ? last.points : []
195
+ const head = points.length === 0 ? 'the reviewer gave no list' : `${points.length} point${points.length === 1 ? '' : 's'}`
196
+ const when = same ? 'about this commit' : `about ${String(last.commit).slice(0, 12)}, and the code has moved since`
197
+ return `Last review (${last.at.slice(0, 16).replace('T', ' ')}): ${head}, ${when}.`
198
+ }
199
+
200
+ /**
201
+ * The reviewer's last points as a list to act on.
202
+ * @param {{dir?: string}} [where] - which log.
203
+ * @returns {string[]} the points.
204
+ */
205
+ export function lastPoints(where = {}) {
206
+ const last = lastRound(where)
207
+ return last !== null && Array.isArray(last.points) ? last.points : []
208
+ }
209
+
210
+ /** @returns {string} the directory the log lives in. */
211
+ export function roundDir() {
212
+ return dirname(join(ROUND_DIR, 'rounds.ndjson'))
213
+ }