dsh-inner-browser-use 0.39.0 → 0.40.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 +1 -1
- package/README.zh.md +1 -1
- package/docs/ARCHITECTURE.md +21 -0
- package/docs/BENCH.md +17 -0
- package/package.json +1 -1
- package/read.js +153 -3
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ live picture of that browser, and your own cursor keeps working on the picture.
|
|
|
12
12
|
| --- | --- |
|
|
13
13
|
| `browser_open` | Starts the browser (or reuses the running one) and opens an address. `headless: false` shows a real window on the desktop instead. |
|
|
14
14
|
| `browser_view` | Returns the address, the title, every control as `1 … 120`, and the visible text. |
|
|
15
|
-
| `browser_read` | Reads the page the way a person does: one Markdown block of headings, paragraphs, lists and links, no controls. Far fewer tokens than a snapshot on long pages, and the links stay usable. Give it a `query` and only the
|
|
15
|
+
| `browser_read` | Reads the page the way a person does: one Markdown block of headings, paragraphs, lists and links, no controls. Far fewer tokens than a snapshot on long pages, and the links stay usable. Give it a `query` and only the sentences that answer it come back: BM25 over Readability-style blocks, then per-sentence scoring with heading, position and link-density priors, idf-weighted coverage, MMR de-duplication and a character budget — 92% fewer tokens on a long page in the bench. |
|
|
16
16
|
| `browser_find` | Finds words (or a regex) anywhere on the page: matching controls come back with a clickable number, matching text with its point, each saying whether it is on screen. |
|
|
17
17
|
| `browser_extract` | Returns the items a CSS selector matches, one compact line each with its link, instead of the whole page. |
|
|
18
18
|
| `browser_click` | Clicks a numbered control, or an `x`/`y` point. `double: true` double-clicks. A cookie banner or modal over the control is cleared first, then the click happens. |
|
package/README.zh.md
CHANGED
|
@@ -11,7 +11,7 @@ profile,不是产品自带的那个浏览页签:右栏放的是它的画面
|
|
|
11
11
|
| --- | --- |
|
|
12
12
|
| `browser_open` | 启动浏览器(已在跑就复用)并打开一个地址。传 `headless: false` 会改为在桌面上开真实窗口。 |
|
|
13
13
|
| `browser_view` | 返回地址、标题、编号成 `1 … 120` 的控件,以及可见文字。 |
|
|
14
|
-
| `browser_read` | 按人读文章的方式给 Markdown(标题、段落、列表、链接)。带 `query`
|
|
14
|
+
| `browser_read` | 按人读文章的方式给 Markdown(标题、段落、列表、链接)。带 `query` 只回能回答的那几句(段落排序后再按句子打分,标题、位置、链接密度做先验,重复句去掉,按字符预算装箱);问不到时自动改为整页,不再空手回来。页面和上次读的没变就只回一行,不重发正文(`force: true` 强制重发)。 |
|
|
15
15
|
| `browser_extract` | 按 CSS 选择器把列表项读成一行一条、带链接,比整页读省得多。 |
|
|
16
16
|
| `browser_view` 的 `map` | 大页面先看地标:各类控件各多少个、去重后的控件和编号、正文多少字,但不带回正文。 |
|
|
17
17
|
| `browser_click` | 点编号控件,或点 `x`/`y` 坐标。`double: true` 双击。控件被横幅或弹层挡着时,先关掉它再点。 |
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -165,6 +165,27 @@ picture comes back on the mutation that satisfies it. A page that changes 137 ms
|
|
|
165
165
|
starts answers at 138 ms, not at the next 100 ms tick — the same reason `idle` (a mutation-free
|
|
166
166
|
window) costs one round trip on an animation-heavy page instead of one per frame.
|
|
167
167
|
|
|
168
|
+
## Reading for an answer
|
|
169
|
+
|
|
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.
|
|
173
|
+
|
|
174
|
+
Paragraphs are ranked first (BM25 over the question's terms, link-heavy menus down-weighted), then
|
|
175
|
+
split into sentences. Each sentence is scored on its own with the same saturation and length
|
|
176
|
+
normalisation, and that score is multiplied by three priors: the words of its heading, the rank of
|
|
177
|
+
the paragraph it sits in, and inverse link density. Words the page says everywhere are dropped
|
|
178
|
+
before scoring — a term in most sentences cannot point at anything — and a sentence that matches
|
|
179
|
+
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".
|
|
181
|
+
|
|
182
|
+
Selection is not "the top N scores": each pick subtracts its Jaccard overlap with what is already
|
|
183
|
+
chosen (MMR), selection stops when the best remaining sentence is worth nothing, and picked
|
|
184
|
+
sentences are packed to a character budget and restored to document order. Terms are folded
|
|
185
|
+
(plurals, trailing `-ing`/`-ed`) so a question can use a different inflection than the page.
|
|
186
|
+
|
|
187
|
+
`sentences: false` keeps the paragraph path for callers who want the whole section.
|
|
188
|
+
|
|
168
189
|
## Sign-ins and reclaim
|
|
169
190
|
|
|
170
191
|
Sign-in state lives in the profile, and session cookies — the kind a sign-in usually sets,
|
package/docs/BENCH.md
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
## 只读答案那几句(0.40.0)
|
|
2
|
+
|
|
3
|
+
同一张本地故事页:24 段会议记录里夹着一段账单说明,问题是「退款多久到账」。一条路按段
|
|
4
|
+
落读(`sentences: false`),另一条走默认的句子级证据。
|
|
5
|
+
|
|
6
|
+
| 项 | 整页段落 | 句子证据 |
|
|
7
|
+
| --- | --- | --- |
|
|
8
|
+
| 回来的字符数 | 1172 | 90 |
|
|
9
|
+
| 折合 token | 345 | 26 |
|
|
10
|
+
| 答案还在 | 是 | 是(`30 days`) |
|
|
11
|
+
| 命中的块 / 总块 | 8 / 49 | 1 / 49 |
|
|
12
|
+
| 读这一趟 | — | 11 ms |
|
|
13
|
+
|
|
14
|
+
省下 92%,`read-answer` 预算 250 ms。句子级这一步没有新增进程、没有模型、没有依赖:分句、
|
|
15
|
+
打分、去重都在已有的阅读调用里完成。同一批里的 `read` / `read-repeat` / `read-changed` 行与
|
|
16
|
+
0.39.0 持平(`dense` 6 ms / 337 token,`full` 6 ms / 624 token)。
|
|
17
|
+
|
|
1
18
|
## 登录能留、能切、关得掉(0.39.0)
|
|
2
19
|
|
|
3
20
|
本地页签在加载时写一个会话 cookie 和一个 `localStorage` 令牌,然后用工具层实际走一遍:保存 → 清空浏览器里的一切 → 切回。磁盘上那一份同时用字符串搜一遍。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-inner-browser-use",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.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",
|
package/read.js
CHANGED
|
@@ -1185,7 +1185,22 @@ const tokenPattern = /[a-z0-9]+|[\u3400-\u9fff]/g
|
|
|
1185
1185
|
* @returns {string[]} lower-case terms.
|
|
1186
1186
|
*/
|
|
1187
1187
|
export function terms(text) {
|
|
1188
|
-
|
|
1188
|
+
const raw = String(text ?? '').toLowerCase().match(tokenPattern) ?? []
|
|
1189
|
+
return raw.map(fold)
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/** "refunds" and "refund" are the same idea; a light fold lets a question that
|
|
1193
|
+
* says one word find a page that says the other. Deliberately not a stemmer —
|
|
1194
|
+
* no dictionary, no suffix table beyond plurals and a trailing -ing/-ed. */
|
|
1195
|
+
function fold(word) {
|
|
1196
|
+
if (word.length <= 3 || /[0-9]/.test(word) || /[^a-z]/.test(word)) return word
|
|
1197
|
+
if (word.endsWith('ies') && word.length > 4) return `${word.slice(0, -3)}y`
|
|
1198
|
+
if (word.endsWith('sses')) return word.slice(0, -2)
|
|
1199
|
+
if (word.endsWith('ing') && word.length > 5) return word.slice(0, -3)
|
|
1200
|
+
if (word.endsWith('ed') && word.length > 4 && !word.endsWith('eed')) return word.slice(0, -2)
|
|
1201
|
+
if (word.endsWith('es') && word.length > 4) return word.slice(0, -2)
|
|
1202
|
+
if (word.endsWith('s') && !word.endsWith('ss') && !word.endsWith('us') && !word.endsWith('is')) return word.slice(0, -1)
|
|
1203
|
+
return word
|
|
1189
1204
|
}
|
|
1190
1205
|
|
|
1191
1206
|
/** How often a term may occur before it stops adding meaning. */
|
|
@@ -1230,6 +1245,134 @@ export function rank(blocks, query) {
|
|
|
1230
1245
|
return scored
|
|
1231
1246
|
}
|
|
1232
1247
|
|
|
1248
|
+
/** A sentence ends at a full stop, a question mark, an exclamation, or their
|
|
1249
|
+
* CJK equivalents; a very short fragment joins its neighbour rather than
|
|
1250
|
+
* travelling alone. */
|
|
1251
|
+
const SENTENCE_BREAK = /(?<=[。!?;])|(?<=[.!?;])\s+/u
|
|
1252
|
+
|
|
1253
|
+
/**
|
|
1254
|
+
* @param {string} text - a paragraph.
|
|
1255
|
+
* @returns {string[]} its sentences, trimmed.
|
|
1256
|
+
*/
|
|
1257
|
+
export function sentences(text) {
|
|
1258
|
+
const raw = String(text ?? '').replace(/\s+/g, ' ').trim()
|
|
1259
|
+
if (raw === '') return []
|
|
1260
|
+
const out = []
|
|
1261
|
+
for (const piece of raw.split(SENTENCE_BREAK)) {
|
|
1262
|
+
const one = piece.trim()
|
|
1263
|
+
if (one === '') continue
|
|
1264
|
+
/* A full stop inside "e.g." leaves a fragment whose continuation starts
|
|
1265
|
+
lowercase; that is the one case where the break was not real. */
|
|
1266
|
+
const continues = /^[a-z(]/.test(one) || out[out.length - 1]?.length < 4
|
|
1267
|
+
if (out.length > 0 && continues) out[out.length - 1] = `${out[out.length - 1]} ${one}`
|
|
1268
|
+
else out.push(one)
|
|
1269
|
+
}
|
|
1270
|
+
return out
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/**
|
|
1274
|
+
* The sentences that answer a query, chosen the way a retriever picks
|
|
1275
|
+
* evidence: BM25 over sentences, a prior for the paragraph and heading they
|
|
1276
|
+
* sit in, and maximal-marginal-relevance so the second sentence adds
|
|
1277
|
+
* something the first did not say. Purely local — no model, no network.
|
|
1278
|
+
* @param {{h?: string, t?: string, d?: number}[]} blocks - paragraphs.
|
|
1279
|
+
* @param {string} query - what the caller wants to know.
|
|
1280
|
+
* @param {{maxChars?: number, limit?: number, blocks?: number, lambda?: number}} [options] - ceilings.
|
|
1281
|
+
* @returns {{rows: {text: string, heading: string, at: number, step: number}[], total: number, matched: number}} picked sentences in page order.
|
|
1282
|
+
*/
|
|
1283
|
+
export function evidence(blocks, query, options = {}) {
|
|
1284
|
+
const typed = terms(query)
|
|
1285
|
+
if (typed.length === 0) return { rows: [], total: blocks.length, matched: 0 }
|
|
1286
|
+
const near = rank(blocks, query).slice(0, Math.max(1, Math.min(60, Number(options.blocks ?? 40))))
|
|
1287
|
+
const units = []
|
|
1288
|
+
for (const row of near) {
|
|
1289
|
+
const heading = String(row.block.h ?? '')
|
|
1290
|
+
const body = String(row.block.t ?? '')
|
|
1291
|
+
const parts = sentences(body)
|
|
1292
|
+
for (let step = 0; step < parts.length; step += 1) {
|
|
1293
|
+
const spoken = terms(parts[step])
|
|
1294
|
+
if (spoken.length === 0) continue
|
|
1295
|
+
units.push({ text: parts[step], terms: spoken, heading, at: blocks.indexOf(row.block), step, prior: row.score, block: row.block })
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
if (units.length === 0) return { rows: [], total: blocks.length, matched: 0 }
|
|
1299
|
+
const total = Math.max(1, units.length)
|
|
1300
|
+
const seen = new Map()
|
|
1301
|
+
for (const unit of units) for (const term of new Set(unit.terms)) seen.set(term, (seen.get(term) ?? 0) + 1)
|
|
1302
|
+
const average = units.reduce((sum, unit) => sum + unit.terms.length, 0) / total
|
|
1303
|
+
/* A word the page says everywhere cannot point at anything: "to" is in every
|
|
1304
|
+
paragraph of a long article. Keep the terms that actually narrow the page;
|
|
1305
|
+
if the question is all common words, keep them all. */
|
|
1306
|
+
const weight = (term) => {
|
|
1307
|
+
const inHowMany = seen.get(term) ?? 0
|
|
1308
|
+
return inHowMany === 0 ? 0 : Math.log(1 + (total - inHowMany + 0.5) / (inHowMany + 0.5))
|
|
1309
|
+
}
|
|
1310
|
+
const sharp = typed.filter((term) => weight(term) >= 0.5)
|
|
1311
|
+
const asked = sharp.length > 0 ? sharp : typed
|
|
1312
|
+
const askedWeight = asked.reduce((sum, term) => sum + weight(term), 0)
|
|
1313
|
+
for (const unit of units) {
|
|
1314
|
+
const counts = new Map()
|
|
1315
|
+
for (const term of unit.terms) counts.set(term, (counts.get(term) ?? 0) + 1)
|
|
1316
|
+
let score = 0
|
|
1317
|
+
let covered = 0
|
|
1318
|
+
for (const term of asked) {
|
|
1319
|
+
const tf = counts.get(term) ?? 0
|
|
1320
|
+
if (tf === 0) continue
|
|
1321
|
+
covered += weight(term)
|
|
1322
|
+
score += weight(term) * ((tf * (SATURATION + 1)) / (tf + SATURATION * (1 - 0.75 + (0.75 * unit.terms.length) / average)))
|
|
1323
|
+
}
|
|
1324
|
+
/* Matching one frequent word of a nine-word question is not evidence; the
|
|
1325
|
+
share of the question's weight the sentence actually answers is. */
|
|
1326
|
+
score *= covered / Math.max(1e-6, askedWeight)
|
|
1327
|
+
/* The paragraph's own relevance and the words of the heading it sits under
|
|
1328
|
+
both say "this is the right place", so they lift their sentences. */
|
|
1329
|
+
const headingHit = asked.filter((term) => terms(unit.heading).includes(term)).length
|
|
1330
|
+
/* A menu sentence can match the words without saying anything about them;
|
|
1331
|
+
the paragraph's link density is what tells the two apart. */
|
|
1332
|
+
const linkish = 1 - 0.8 * Number(unit.block?.d ?? 0)
|
|
1333
|
+
unit.score = score * linkish * (1 + 0.25 * headingHit) * (1 + 0.5 * (unit.prior > 0 ? 1 : 0))
|
|
1334
|
+
}
|
|
1335
|
+
/* Below a fifth of the best sentence, a match is coincidence rather than
|
|
1336
|
+
evidence; carrying it only costs tokens. */
|
|
1337
|
+
const best = units.reduce((top, unit) => Math.max(top, unit.score), 0)
|
|
1338
|
+
for (const unit of units) if (unit.score < best * 0.2) unit.score = 0
|
|
1339
|
+
const lambda = Math.max(0, Math.min(0.9, Number(options.lambda ?? 0.35)))
|
|
1340
|
+
const ceiling = Math.max(200, Math.min(24_000, Number(options.maxChars ?? 4000)))
|
|
1341
|
+
const limit = Math.max(1, Math.min(60, Number(options.limit ?? 24)))
|
|
1342
|
+
const chosen = []
|
|
1343
|
+
const left = units.filter((unit) => unit.score > 0)
|
|
1344
|
+
let used = 0
|
|
1345
|
+
let lastHeading = ''
|
|
1346
|
+
while (left.length > 0 && chosen.length < limit) {
|
|
1347
|
+
let best = -1
|
|
1348
|
+
let bestValue = -Infinity
|
|
1349
|
+
for (let i = 0; i < left.length; i += 1) {
|
|
1350
|
+
const candidate = left[i]
|
|
1351
|
+
const spoken = new Set(candidate.terms)
|
|
1352
|
+
let overlap = 0
|
|
1353
|
+
for (const picked of chosen) {
|
|
1354
|
+
const other = new Set(picked.terms)
|
|
1355
|
+
let shared = 0
|
|
1356
|
+
for (const term of spoken) if (other.has(term)) shared += 1
|
|
1357
|
+
overlap = Math.max(overlap, shared / Math.max(1, spoken.size + other.size - shared))
|
|
1358
|
+
}
|
|
1359
|
+
const value = candidate.score * (1 - lambda) - lambda * overlap
|
|
1360
|
+
if (value > bestValue) { bestValue = value; best = i }
|
|
1361
|
+
}
|
|
1362
|
+
/* Once the best sentence left is no better than nothing — or is simply a
|
|
1363
|
+
repeat of one already picked — the answer is complete. */
|
|
1364
|
+
if (best < 0 || bestValue <= 0) break
|
|
1365
|
+
const pick = left.splice(best, 1)[0]
|
|
1366
|
+
const heading = pick.heading !== lastHeading && pick.heading !== '' ? pick.heading.length + 8 : 0
|
|
1367
|
+
if (used + pick.text.length + heading > ceiling && chosen.length > 0) break
|
|
1368
|
+
lastHeading = pick.heading
|
|
1369
|
+
used += pick.text.length + heading
|
|
1370
|
+
chosen.push(pick)
|
|
1371
|
+
}
|
|
1372
|
+
chosen.sort((a, b) => a.at - b.at || a.step - b.step)
|
|
1373
|
+
return { rows: chosen.map((one) => ({ text: one.text, heading: one.heading, at: one.at, step: one.step })), total: blocks.length, matched: chosen.length }
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1233
1376
|
/**
|
|
1234
1377
|
* Read only the passages of a page that answer a query, so a long page costs
|
|
1235
1378
|
* what the answer costs instead of what the page costs.
|
|
@@ -1248,12 +1391,19 @@ export async function readRelevant(page, query, options = {}) {
|
|
|
1248
1391
|
if (ranked.length === 0) {
|
|
1249
1392
|
return { title: String(said?.title ?? page.title ?? ''), url: String(page.url ?? ''), text: '', chars: 0, matched: 0, total: all.length }
|
|
1250
1393
|
}
|
|
1251
|
-
|
|
1394
|
+
/* Passages cost whole paragraphs; sentences cost only the ones that say
|
|
1395
|
+
something about the query, which on a long page is most of the saving. */
|
|
1396
|
+
let pieces = ranked
|
|
1397
|
+
if (options.sentences !== false) {
|
|
1398
|
+
const picked = evidence(all, query, { maxChars: budget, limit: options.limit, blocks: options.blocks, lambda: options.lambda })
|
|
1399
|
+
pieces = picked.rows.map((row) => ({ block: { h: row.heading, t: row.text }, score: 0 }))
|
|
1400
|
+
}
|
|
1401
|
+
const limit = Math.max(1, Math.min(60, Number(options.limit ?? (options.sentences === false ? 8 : 24))))
|
|
1252
1402
|
const lines = []
|
|
1253
1403
|
let used = 0
|
|
1254
1404
|
let matched = 0
|
|
1255
1405
|
let lastHeading = ''
|
|
1256
|
-
for (const row of
|
|
1406
|
+
for (const row of pieces.slice(0, limit)) {
|
|
1257
1407
|
const body = String(row.block.t ?? '')
|
|
1258
1408
|
if (used + body.length > budget) continue
|
|
1259
1409
|
const heading = String(row.block.h ?? '')
|