dsh-inner-browser-use 0.43.0 → 0.44.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.
@@ -168,8 +168,12 @@ window) costs one round trip on an animation-heavy page instead of one per frame
168
168
  ## Reading for an answer
169
169
 
170
170
  `browser_read` with a `query` does not return the page; it returns the sentences that answer the
171
- question. The page-side block reader already returns paragraphs with their heading and link
172
- density, so the work stays in `read.js` and costs no extra round trip.
171
+ question. The page-side block reader already returns paragraphs with their heading trail
172
+ (`Plans \u203a Trial`), their link density, a 0-1 quality score and table rows as one
173
+ `column: value | column: value` line, so the work stays in `read.js` and costs no extra round trip.
174
+ When the page's own menu would eat the budget, the blocks past the ceiling are chosen by quality and
175
+ then put back in page order, so prose survives where document order would have spent everything
176
+ above it.
173
177
 
174
178
  Paragraphs are ranked first (BM25 over the question's terms, link-heavy menus down-weighted), then
175
179
  split into sentences. Each sentence is scored on its own with the same saturation and length
@@ -177,7 +181,9 @@ normalisation, and that score is multiplied by three priors: the words of its he
177
181
  the paragraph it sits in, and inverse link density. Words the page says everywhere are dropped
178
182
  before scoring — a term in most sentences cannot point at anything — and a sentence that matches
179
183
  only part of the question is scaled by the share of the question's idf weight it actually covers,
180
- which is what stops a paragraph containing "to" from competing with one containing "refund".
184
+ which is what stops a paragraph containing "to" from competing with one containing "refund". The
185
+ block's quality score and its being a table row are priors as well, so a menu that repeats the
186
+ question's words cannot outrank the sentence that explains them.
181
187
 
182
188
  Selection is not "the top N scores": each pick subtracts its Jaccard overlap with what is already
183
189
  chosen (MMR), selection stops when the best remaining sentence is worth nothing, and picked
package/docs/BENCH.md CHANGED
@@ -1,3 +1,17 @@
1
+ ## 读页先读正文(0.44.0,本地页面,`scripts/bench.mjs`)
2
+
3
+ 同一台机器、同一档预算。旧法是按页面顺序把预算灌满的整页读。
4
+
5
+ | 页面 | 旧法字符 | 新法字符 | 省下 | 答案 |
6
+ | --- | --- | --- | --- | --- |
7
+ | 九成是菜单、答案在正文(900 字预算) | 1500 | 88 | 94% | 旧法读不到,新法读到 |
8
+ | 定价表(900 字预算) | 679 | 109 | 84% | 价格与列名同一行 |
9
+
10
+ - `read-noise` 2 ms、`read-table` 2 ms,两项预算 300 ms 全过(26 项全过)
11
+ - 表格行形如 `Plan: Pro | Price per month: $84`,列名来自表头,一行就是一条记录
12
+ - 标题路径 `Plans \u203a Trial` 印在证据上方;与最近标题相同、或超过 90 字时退回最近标题
13
+ - 既有数字没有回退:`read-answer` 90 字 / 92%、`read-miss` 438 字 / 79%、`read-tabs` 90 字 / 90%(与 0.43.0 相同)
14
+
1
15
  ## 一次读穿过登录墙(0.43.0,本地页面,`scripts/bench.mjs`)
2
16
 
3
17
  同一页:带 cookie 时是账户页,不带时是一个只有密码框的登录页。留一个登录(vault),清掉页面的 cookie 与 localStorage 后读它。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-inner-browser-use",
3
- "version": "0.43.0",
3
+ "version": "0.44.0",
4
4
  "description": "An agent-owned Chromium sidecar mirrored into the DeepSeek Harness right column: the agent drives it, the person watches and can take the mouse, and logins survive restarts.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -51,7 +51,7 @@
51
51
  "dsh": ">=0.2.0"
52
52
  },
53
53
  "scripts": {
54
- "test": "node --test test/artifacts.test.mjs test/client.test.mjs test/cookies.test.mjs test/diff.test.mjs test/fresh.test.mjs test/host.test.mjs test/input.test.mjs test/isolation.test.mjs test/keyboard.test.mjs test/leak.test.mjs test/login.test.mjs test/media.test.mjs test/no-duplicate.test.mjs test/package.test.mjs test/readfault.test.mjs test/refs.test.mjs test/review.test.mjs test/session.test.mjs test/settle.test.mjs test/sidecar.test.mjs test/swf.test.mjs test/tabs.test.mjs test/trouble.test.mjs test/vault.test.mjs",
54
+ "test": "node --test test/artifacts.test.mjs test/client.test.mjs test/cookies.test.mjs test/diff.test.mjs test/fresh.test.mjs test/host.test.mjs test/input.test.mjs test/isolation.test.mjs test/keyboard.test.mjs test/leak.test.mjs test/login.test.mjs test/media.test.mjs test/no-duplicate.test.mjs test/package.test.mjs test/blocks.test.mjs test/readfault.test.mjs test/refs.test.mjs test/review.test.mjs test/session.test.mjs test/settle.test.mjs test/sidecar.test.mjs test/swf.test.mjs test/tabs.test.mjs test/trouble.test.mjs test/vault.test.mjs",
55
55
  "doctor": "node scripts/doctor.mjs",
56
56
  "breadth": "node scripts/breadth.mjs",
57
57
  "live": "node scripts/live.mjs",
package/read.js CHANGED
@@ -1124,10 +1124,12 @@ export async function findOnPage(page, query, options = {}) {
1124
1124
  }
1125
1125
 
1126
1126
  /**
1127
- * Collect the page as blocks of prose, each with the heading it sits under and
1128
- * how much of it is links. Navigation, sidebars and footers are mostly links;
1129
- * Readability's oldest signal is that such a block is not the content, and it
1130
- * costs one number to carry instead of a guess.
1127
+ * Collect the page as blocks of prose. Each block carries the trail of headings
1128
+ * it sits under, how much of it is links, and one number for how much of it
1129
+ * reads like written content: navigation, sidebars and footers are mostly links
1130
+ * and mostly short, which is Readability's oldest signal. A table row is one
1131
+ * block, with its column names written into it, so a fact that lives in a table
1132
+ * costs a line instead of a page.
1131
1133
  * @param {number} budget - characters of block text to collect.
1132
1134
  * @returns {string} an expression to evaluate.
1133
1135
  */
@@ -1136,32 +1138,66 @@ export function blocks(budget) {
1136
1138
  const root = document.querySelector('article, main, [role="main"]') || document.body
1137
1139
  if (!root) return JSON.stringify({ blocks: [] })
1138
1140
  const limit = ${budget}
1139
- const BLOCK = new Set(['P', 'LI', 'TD', 'TH', 'BLOCKQUOTE', 'PRE', 'FIGCAPTION', 'DD', 'DT', 'SUMMARY'])
1141
+ const guard = limit * 6
1142
+ const BLOCK = new Set(['P', 'LI', 'BLOCKQUOTE', 'PRE', 'FIGCAPTION', 'DD', 'DT', 'SUMMARY'])
1140
1143
  const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'SVG', 'IFRAME', 'CANVAS', 'VIDEO', 'HEADER', 'FOOTER', 'NAV', 'ASIDE', 'FORM', 'BUTTON', 'SELECT', 'TEXTAREA'])
1141
1144
  const out = []
1142
1145
  const seen = new Set()
1146
+ const stack = []
1143
1147
  let chars = 0
1144
- let heading = ''
1145
1148
  const flatten = (text) => String(text ?? '').replace(/\\s+/g, ' ').trim()
1149
+ const trail = () => stack.filter((one) => one !== '').join(' \\u203a ')
1150
+ const said = () => stack.filter((one) => one !== '').pop() ?? ''
1151
+ /* How much of a block reads like prose rather than a list of links: ends of
1152
+ sentences per word, plus a length floor. One number, no dictionary. */
1153
+ const written = (text) => {
1154
+ const words = text.toLowerCase().match(/[a-z0-9]+|[\\u3400-\\u9fff]/g) ?? []
1155
+ if (words.length === 0) return 0
1156
+ const ends = text.match(/[.!?\\u3002\\uff01\\uff1f;\\uff1b]/g) ?? []
1157
+ return Math.min(1, (ends.length / words.length) * 12)
1158
+ }
1159
+ const quality = (text, density) => Math.round((0.45 * (1 - density) + 0.35 * written(text) + 0.2 * Math.min(1, text.length / 180)) * 100) / 100
1146
1160
  const push = (el) => {
1147
- if (chars >= limit) return
1161
+ if (chars >= guard) return
1148
1162
  const text = flatten(el.innerText ?? el.textContent ?? '')
1149
1163
  if (text === '' || text.length > 4000) return
1150
1164
  let linkChars = 0
1151
1165
  for (const link of el.querySelectorAll('a')) linkChars += flatten(link.innerText ?? '').length
1152
1166
  if (el.tagName === 'A') linkChars += text.length
1153
- const density = text.length === 0 ? 0 : Math.min(1, linkChars / text.length)
1154
- out.push({ h: heading, t: text, d: Math.round(density * 100) / 100 })
1167
+ const density = Math.min(1, linkChars / text.length)
1168
+ /* A block that is almost all link and short is a menu item, not a sentence;
1169
+ carrying it only spends the budget. */
1170
+ if (density >= 0.8 && text.length < 80) return
1171
+ const path = trail()
1172
+ out.push({ h: said(), p: path, t: text, d: Math.round(density * 100) / 100, q: quality(text, density) })
1155
1173
  chars += text.length
1156
1174
  }
1175
+ const row = (tr) => {
1176
+ if (chars >= guard) return
1177
+ const cells = Array.from(tr.children).filter((cell) => cell.tagName === 'TD' || cell.tagName === 'TH')
1178
+ const words = cells.map((cell) => flatten(cell.innerText ?? ''))
1179
+ if (words.filter((one) => one !== '').length < 2) return
1180
+ const table = tr.closest('table')
1181
+ const first = table === null ? null : table.querySelector('tr')
1182
+ const head = first === null || first === tr ? [] : Array.from(first.children).map((cell) => flatten(cell.innerText ?? ''))
1183
+ const line = words.map((one, at) => (head[at] === undefined || head[at] === '' || head[at] === one ? one : head[at] + ': ' + one)).join(' | ')
1184
+ out.push({ h: said(), p: trail(), t: line, d: 0, q: 0.95, r: 1 })
1185
+ chars += line.length
1186
+ }
1157
1187
  const walk = (el) => {
1158
- if (chars >= limit || el === null) return
1188
+ if (el === null || chars >= guard) return
1159
1189
  const tag = el.tagName
1160
1190
  if (SKIP.has(tag) || el.hidden === true || el.getAttribute?.('aria-hidden') === 'true') return
1161
1191
  const style = getComputedStyle(el)
1162
1192
  if (style.display === 'none' || style.visibility === 'hidden') return
1163
1193
  if (/^H[1-6]$/.test(tag)) {
1164
- heading = flatten(el.innerText ?? '')
1194
+ const level = Number(tag.slice(1))
1195
+ stack.length = level
1196
+ stack[level - 1] = flatten(el.innerText ?? '')
1197
+ return
1198
+ }
1199
+ if (tag === 'TR') {
1200
+ if (!seen.has(el)) { seen.add(el); row(el) }
1165
1201
  return
1166
1202
  }
1167
1203
  if (BLOCK.has(tag)) {
@@ -1172,7 +1208,23 @@ export function blocks(budget) {
1172
1208
  }
1173
1209
  walk(root)
1174
1210
  const title = document.title
1175
- return JSON.stringify({ title, blocks: out })
1211
+ /* The page arrives in document order, so a menu at the top would spend the
1212
+ whole budget before the article starts. Keep the blocks that read like
1213
+ content, still in page order, until the budget is spent. */
1214
+ let kept = out
1215
+ const total = out.reduce((sum, block) => sum + block.t.length, 0)
1216
+ if (total > limit) {
1217
+ const byQuality = out.map((block, at) => ({ block, at })).sort((a, b) => b.block.q - a.block.q || a.at - b.at)
1218
+ const keep = new Set()
1219
+ let used = 0
1220
+ for (const one of byQuality) {
1221
+ if (used + one.block.t.length > limit) continue
1222
+ keep.add(one.at)
1223
+ used += one.block.t.length
1224
+ }
1225
+ kept = out.filter((block, at) => keep.has(at))
1226
+ }
1227
+ return JSON.stringify({ title, blocks: kept })
1176
1228
  })()`
1177
1229
  }
1178
1230
 
@@ -1217,6 +1269,20 @@ function brief(value) {
1217
1269
  return body.length > 260 ? `${body.slice(0, 260).replace(/\s+\S*$/, '')}…` : body
1218
1270
  }
1219
1271
 
1272
+ /**
1273
+ * The trail of headings a block sits under, when it says more than the nearest
1274
+ * one: "Pricing › Refunds" tells a caller where a fact lives, which a bare
1275
+ * sentence cannot. Kept short so location never costs more than it explains.
1276
+ * @param {{h?: string, p?: string}} block - the block.
1277
+ * @returns {string} what to print above the passage.
1278
+ */
1279
+ export function trailOf(block) {
1280
+ const path = String(block?.p ?? '')
1281
+ const near = String(block?.h ?? '')
1282
+ if (path === '' || path === near || path.length > 90) return near
1283
+ return path
1284
+ }
1285
+
1220
1286
  /**
1221
1287
  * Rank blocks against a query the way a search engine ranks a passage: BM25,
1222
1288
  * with the heading counted as a place a term can match. Then read only the
@@ -1228,7 +1294,7 @@ function brief(value) {
1228
1294
  export function rank(blocks, query) {
1229
1295
  const asked = terms(query)
1230
1296
  if (asked.length === 0) return []
1231
- const docs = blocks.map((block) => ({ block, terms: terms(`${block.h ?? ''} ${block.t}`) }))
1297
+ const docs = blocks.map((block) => ({ block, terms: terms(`${block.p ?? ''} ${block.h ?? ''} ${block.t}`) }))
1232
1298
  const average = docs.reduce((sum, one) => sum + one.terms.length, 0) / Math.max(1, docs.length)
1233
1299
  const seen = new Map()
1234
1300
  for (const one of docs) for (const term of new Set(one.terms)) seen.set(term, (seen.get(term) ?? 0) + 1)
@@ -1250,6 +1316,9 @@ export function rank(blocks, query) {
1250
1316
  /* A block that is nearly all link is a menu: it matches words without
1251
1317
  saying anything about them. */
1252
1318
  score *= 1 - 0.8 * Number(one.block.d ?? 0)
1319
+ /* Prose that answers a question outranks a fragment of navigation that
1320
+ happens to contain the same words; the quality number says which. */
1321
+ score *= 0.85 + 0.15 * Number(one.block.q ?? 0.5)
1253
1322
  if (score > 0) scored.push({ block: one.block, score })
1254
1323
  }
1255
1324
  scored.sort((a, b) => b.score - a.score)
@@ -1297,7 +1366,7 @@ export function evidence(blocks, query, options = {}) {
1297
1366
  const near = rank(blocks, query).slice(0, Math.max(1, Math.min(60, Number(options.blocks ?? 40))))
1298
1367
  const units = []
1299
1368
  for (const row of near) {
1300
- const heading = String(row.block.h ?? '')
1369
+ const heading = trailOf(row.block)
1301
1370
  const body = String(row.block.t ?? '')
1302
1371
  const parts = sentences(body)
1303
1372
  for (let step = 0; step < parts.length; step += 1) {
@@ -1339,8 +1408,9 @@ export function evidence(blocks, query, options = {}) {
1339
1408
  both say "this is the right place", so they lift their sentences. */
1340
1409
  const headingHit = asked.filter((term) => terms(unit.heading).includes(term)).length
1341
1410
  /* A menu sentence can match the words without saying anything about them;
1342
- the paragraph's link density is what tells the two apart. */
1343
- const linkish = 1 - 0.8 * Number(unit.block?.d ?? 0)
1411
+ the paragraph's link density and how much it reads like prose tell the
1412
+ two apart, and a table row is data rather than a sentence to distrust. */
1413
+ const linkish = (1 - 0.8 * Number(unit.block?.d ?? 0)) * (unit.block?.r === 1 ? 1 : 0.9 + 0.1 * Number(unit.block?.q ?? 0.5))
1344
1414
  unit.score = score * linkish * (1 + 0.25 * headingHit) * (1 + 0.5 * (unit.prior > 0 ? 1 : 0))
1345
1415
  }
1346
1416
  /* Below a fifth of the best sentence, a match is coincidence rather than
@@ -1424,7 +1494,7 @@ export async function readRelevant(page, query, options = {}) {
1424
1494
  for (const row of pieces.slice(0, limit)) {
1425
1495
  const body = String(row.block.t ?? '')
1426
1496
  if (used + body.length > budget) continue
1427
- const heading = String(row.block.h ?? '')
1497
+ const heading = trailOf(row.block)
1428
1498
  if (heading !== '' && heading !== lastHeading) { lines.push(`## ${heading}`); lastHeading = heading }
1429
1499
  lines.push(body)
1430
1500
  used += body.length