@thincoder/core 0.10.5 → 0.10.6

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.
@@ -0,0 +1,270 @@
1
+ /**
2
+ * browser/snapshot.mjs — 页面侧快照脚本 ∥ 几何 ∥ 点 / 聚焦表达式 ∥ 结果归一 ∥ 引用表 ∥ 紧凑渲染 ∥ 页摘
3
+ * (BROWSER-TOOL.md §2.3 ∥ §2.7)。
4
+ *
5
+ * 页面侧脚本一律经 `Runtime.evaluate` **一次性执行、不驻留**(§3.3);每条表达式带一个块注释标识
6
+ * 前缀(`thincoder-browser:<种类>`)——回执/调试可辨出处,单元测试假传输按此前缀派发。
7
+ * 引用表:`key = tag + "|" + selector`,跨快照复用原号(KD-1 / F-BT4「微变不全失效」)。
8
+ * 几何(F-BT14):每条记录携 `geo`(视口 CSS 像素)与 `inViewport`;`[outside]` 仅标视口外行(KD-15)。
9
+ */
10
+ export const DEFAULT_MAX = 100
11
+ export const MAX_ELEMENTS = 200
12
+ export const MAX_SNAPSHOT_CHARS = 20_000
13
+ export const COMPACT_ROWS = 30 // 失败回执的紧凑清单行数
14
+
15
+ const mark = (kind) => `/*thincoder-browser:${kind}*/`
16
+
17
+ /**
18
+ * 页面侧快照函数源串(`(opts) => 记录集`)。
19
+ * 走交互元素(§2.3):`a[href]` / `button` / `input`(非 hidden) / `select` / `textarea` /
20
+ * `[role=button]` / `[onclick]` / `[tabindex]`;`selector` = 稳定寻址链(`#id` → `[data-testid]`
21
+ * → `[name]` → `[href]` → 结构路径);password 输入**不取 value**(N-BT4)。
22
+ * 几何随记录(§2.7):`geo = {x,y,w,h}` 取整 + `inViewport`(非零面积 ∩ 视口)。
23
+ */
24
+ export const SNAPSHOT_EXPR = String.raw`function (opts) {
25
+ var SEL = 'a[href],button,input:not([type=hidden]),select,textarea,[role=button],[onclick],[tabindex]'
26
+ var norm = function (s) { return String(s == null ? '' : s).replace(/\s+/g, ' ').trim().slice(0, 60) }
27
+ var esc = function (s) {
28
+ try { return (globalThis.CSS && CSS.escape) ? CSS.escape(String(s)) : String(s).replace(/[^a-zA-Z0-9_-]/g, function (c) { return '\\' + c }) }
29
+ catch (e) { return String(s) }
30
+ }
31
+ var unique = function (sel) { try { return document.querySelectorAll(sel).length === 1 } catch (e) { return false } }
32
+ var pathOf = function (el) {
33
+ var parts = []
34
+ var cur = el
35
+ while (cur && cur.nodeType === 1 && cur !== document.documentElement) {
36
+ if (cur.id && unique('#' + esc(cur.id))) { parts.unshift('#' + esc(cur.id)); break }
37
+ var tag = cur.tagName.toLowerCase()
38
+ var parent = cur.parentElement
39
+ if (!parent) { parts.unshift(tag); break }
40
+ var same = Array.prototype.filter.call(parent.children, function (c) { return c.tagName === cur.tagName })
41
+ parts.unshift(same.length > 1 ? tag + ':nth-of-type(' + (same.indexOf(cur) + 1) + ')' : tag)
42
+ cur = parent
43
+ }
44
+ return parts.join(' > ')
45
+ }
46
+ var selectorOf = function (el) {
47
+ if (el.id && unique('#' + esc(el.id))) return '#' + esc(el.id)
48
+ var testid = el.getAttribute('data-testid')
49
+ if (testid && unique('[data-testid="' + testid + '"]')) return '[data-testid="' + testid + '"]'
50
+ var name = el.getAttribute('name')
51
+ if (name && unique('[name="' + name + '"]')) return '[name="' + name + '"]'
52
+ var href = el.getAttribute('href')
53
+ if (href && el.tagName === 'A' && unique('[href="' + href + '"]')) return '[href="' + href + '"]'
54
+ return pathOf(el)
55
+ }
56
+ var roleOf = function (el, tag) {
57
+ var t = String(el.getAttribute('type') || '').toLowerCase()
58
+ if (tag === 'a') return 'link'
59
+ if (tag === 'select') return 'select'
60
+ if (tag === 'textarea') return 'textbox'
61
+ if (tag === 'button') return 'button'
62
+ if (tag === 'input') {
63
+ if (t === 'password') return 'password'
64
+ if (t === 'checkbox') return 'checkbox'
65
+ if (t === 'radio') return 'radio'
66
+ if (t === 'submit' || t === 'button' || t === 'reset' || t === 'image') return 'button'
67
+ return 'textbox'
68
+ }
69
+ if (el.getAttribute('role') === 'button' || el.hasAttribute('onclick')) return 'button'
70
+ return 'other'
71
+ }
72
+ var nameOf = function (el) {
73
+ // 可及名取序照 §2.3:aria-label ∥ 文本 ∥ placeholder ∥ title
74
+ return norm(el.getAttribute('aria-label') || el.innerText || el.textContent || el.getAttribute('placeholder') || el.getAttribute('title') || '')
75
+ }
76
+ var rectOf = function (el) {
77
+ var r = el.getBoundingClientRect()
78
+ return { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) }
79
+ }
80
+ var inViewportOf = function (g) {
81
+ return g.w > 0 && g.h > 0 && g.x < innerWidth && g.y < innerHeight && g.x + g.w > 0 && g.y + g.h > 0
82
+ }
83
+ var root = (opts && opts.selector) ? document.querySelector(opts.selector) : document
84
+ if (!root) return { missing: true, url: location.href, title: document.title, elements: [], total: 0 }
85
+ var nodes = []
86
+ if (root.nodeType === 1 && root.matches && root.matches(SEL)) nodes.push(root)
87
+ var found = root.querySelectorAll ? root.querySelectorAll(SEL) : []
88
+ for (var i = 0; i < found.length; i++) nodes.push(found[i])
89
+ var max = Math.max(1, (opts && opts.max) ? opts.max : 100)
90
+ var elements = []
91
+ for (var j = 0; j < nodes.length && elements.length < max; j++) {
92
+ var el = nodes[j]
93
+ var tag = el.tagName.toLowerCase()
94
+ var role = roleOf(el, tag)
95
+ var geo = rectOf(el)
96
+ var rec = {
97
+ tag: tag,
98
+ role: role,
99
+ name: nameOf(el),
100
+ selector: selectorOf(el),
101
+ disabled: Boolean(el.disabled) || el.getAttribute('aria-disabled') === 'true',
102
+ geo: geo,
103
+ inViewport: inViewportOf(geo),
104
+ }
105
+ if (role === 'textbox' || role === 'select') rec.value = norm(el.value)
106
+ elements.push(rec)
107
+ }
108
+ return { url: location.href, title: document.title, total: nodes.length, elements: elements }
109
+ }`
110
+
111
+ /** 快照表达式(页面侧一次执行)。 */
112
+ export function snapshotExpression({ selector = null, max = DEFAULT_MAX } = {}) {
113
+ return `${mark("snapshot")}(${SNAPSHOT_EXPR})(${JSON.stringify({ selector, max })})`
114
+ }
115
+
116
+ /** 当前页信息表达式(URL ∥ title ∥ readyState)。 */
117
+ export function pageInfoExpression() {
118
+ return `${mark("pageinfo")}(function () { return { url: location.href, title: document.title, readyState: document.readyState } })()`
119
+ }
120
+
121
+ /** 点击表达式(寻址 + tag 一致校验 + disabled 判;返回 `{found, disabled, tag?}`)。 */
122
+ export function clickExpression(selector, tag) {
123
+ return `${mark("click")}(function () {
124
+ var el = document.querySelector(${JSON.stringify(selector)})
125
+ if (!el) return { found: false }
126
+ var tag = el.tagName.toLowerCase()
127
+ if (tag !== ${JSON.stringify(tag)}) return { found: false, tag: tag }
128
+ if (el.disabled) return { found: true, disabled: true }
129
+ el.scrollIntoView({ block: 'center' })
130
+ el.click()
131
+ return { found: true, disabled: false }
132
+ })()`
133
+ }
134
+
135
+ /** 输入表达式(原生 value setter + input/change 事件;回执判据 = `password` 标记)。 */
136
+ export function typeExpression(selector, tag, text, clear) {
137
+ return `${mark("type")}(function () {
138
+ var el = document.querySelector(${JSON.stringify(selector)})
139
+ if (!el) return { found: false }
140
+ var tag = el.tagName.toLowerCase()
141
+ if (tag !== ${JSON.stringify(tag)}) return { found: false, tag: tag }
142
+ if (tag !== 'input' && tag !== 'textarea') return { found: true, disabled: false, password: false, fillable: false }
143
+ var password = tag === 'input' && String(el.getAttribute('type') || '').toLowerCase() === 'password'
144
+ if (el.disabled) return { found: true, disabled: true, password: password }
145
+ var proto = tag === 'textarea' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype
146
+ var desc = Object.getOwnPropertyDescriptor(proto, 'value')
147
+ var next = ${clear ? "true" : "false"} ? ${JSON.stringify(text)} : String(el.value == null ? '' : el.value) + ${JSON.stringify(text)}
148
+ if (desc && desc.set) desc.set.call(el, next); else el.value = next
149
+ el.dispatchEvent(new Event('input', { bubbles: true }))
150
+ el.dispatchEvent(new Event('change', { bubbles: true }))
151
+ return { found: true, disabled: false, password: password }
152
+ })()`
153
+ }
154
+
155
+ /** 点表达式(ref → 真点——§2.7):`scrollIntoView({block:'center'})` → 重测 → 返回中心点与 `{found, tag?}`。 */
156
+ export function pointExpression(selector, tag) {
157
+ return `${mark("point")}(function () {
158
+ var el = document.querySelector(${JSON.stringify(selector)})
159
+ if (!el) return { found: false }
160
+ var tag = el.tagName.toLowerCase()
161
+ if (tag !== ${JSON.stringify(tag)}) return { found: false, tag: tag }
162
+ el.scrollIntoView({ block: 'center' })
163
+ var r = el.getBoundingClientRect()
164
+ return { found: true, x: r.left + r.width / 2, y: r.top + r.height / 2 }
165
+ })()`
166
+ }
167
+
168
+ /** 聚焦表达式(§2.7):`scrollIntoView` + `focus()` → `{found, tag?, focused}`;不可聚焦 ⇒ `focused:false`。 */
169
+ export function focusExpression(selector, tag) {
170
+ return `${mark("focus")}(function () {
171
+ var el = document.querySelector(${JSON.stringify(selector)})
172
+ if (!el) return { found: false }
173
+ var tag = el.tagName.toLowerCase()
174
+ if (tag !== ${JSON.stringify(tag)}) return { found: false, tag: tag }
175
+ el.scrollIntoView({ block: 'center' })
176
+ try { el.focus() } catch (e) { /* 不可聚焦元素(div 等)——focused 读回自证 */ }
177
+ return { found: true, focused: document.activeElement === el }
178
+ })()`
179
+ }
180
+
181
+ /** 等待谓词表达式(selector ∥ text ∥ url——networkIdle 走核侧事件计数)。 */
182
+ export function waitExpression(kind, value) {
183
+ const expr = kind === "selector" ? `!!document.querySelector(${JSON.stringify(value)})`
184
+ : kind === "text" ? `String((document.body && document.body.innerText) || '').includes(${JSON.stringify(value)})`
185
+ : `location.href.includes(${JSON.stringify(value)})`
186
+ return `${mark("wait")}(function () { return ${expr} })()`
187
+ }
188
+
189
+ /** 页面侧结果 → 形状归一的记录集(不可信输入:缺字段/异型一律归一,不抛)。 */
190
+ export function normalizeSnapshot(raw) {
191
+ const r = raw && typeof raw === "object" ? raw : {}
192
+ const num = (v) => (Number.isFinite(Number(v)) ? Math.round(Number(v)) : 0)
193
+ const elements = (Array.isArray(r.elements) ? r.elements : [])
194
+ .filter((e) => e && typeof e === "object" && e.selector)
195
+ .map((e) => {
196
+ const rec = {
197
+ tag: String(e.tag ?? "").toLowerCase(),
198
+ role: String(e.role ?? "other"),
199
+ name: String(e.name ?? ""),
200
+ selector: String(e.selector),
201
+ disabled: e.disabled === true,
202
+ }
203
+ if (e.value !== undefined && e.value !== null && String(e.value) !== "") rec.value = String(e.value)
204
+ if (e.geo && typeof e.geo === "object") {
205
+ rec.geo = { x: num(e.geo.x), y: num(e.geo.y), w: num(e.geo.w), h: num(e.geo.h) }
206
+ }
207
+ if (e.inViewport === false) rec.inViewport = false // 仅视口外落标记(在屏行文法零动——KD-15)
208
+ return rec
209
+ })
210
+ return {
211
+ url: String(r.url ?? ""),
212
+ title: String(r.title ?? ""),
213
+ elements,
214
+ total: Number.isInteger(r.total) ? Math.max(r.total, elements.length) : elements.length,
215
+ missing: r.missing === true,
216
+ }
217
+ }
218
+
219
+ /** 核侧引用表(会话级——`byRef` 反查、`byKey` 复用、`counter` 会话内单调)。 */
220
+ export function createRefTable() {
221
+ return { byRef: new Map(), byKey: new Map(), counter: 1 }
222
+ }
223
+
224
+ /** 记录集 → 引用行(key 已在表 ⇒ 复用原号;未见 ⇒ 新号 + `isNew`)。 */
225
+ export function assignRefs(table, elements) {
226
+ const rows = []
227
+ for (const el of elements) {
228
+ const key = `${el.tag}|${el.selector}`
229
+ let entry = table.byKey.get(key)
230
+ const isNew = !entry
231
+ if (isNew) {
232
+ entry = { ref: `e${table.counter++}`, key, selector: el.selector, tag: el.tag, name: el.name }
233
+ table.byKey.set(key, entry)
234
+ table.byRef.set(entry.ref, entry)
235
+ } else {
236
+ entry.name = el.name // 名随页面更新;号不变(F-BT4)
237
+ }
238
+ rows.push({ ...el, ref: entry.ref, isNew })
239
+ }
240
+ return rows
241
+ }
242
+
243
+ /** 结果行文法:`<ref> <role> "<name>"` + `[value=…]`(password 除外)+ `[new]` + `[disabled]` + `[outside]`。 */
244
+ export function rowLine(row) {
245
+ const marks = []
246
+ if (row.role !== "password" && row.value !== undefined && String(row.value) !== "") marks.push(`[value="${row.value}"]`)
247
+ if (row.isNew) marks.push("[new]")
248
+ if (row.disabled) marks.push("[disabled]")
249
+ if (row.inViewport === false) marks.push("[outside]")
250
+ return `${row.ref} ${row.role} "${row.name}"${marks.length ? ` ${marks.join(" ")}` : ""}`
251
+ }
252
+
253
+ /** 页摘行(内容与出处同行——N-BT2 来源标注)。 */
254
+ export function pageDigest(url, title) {
255
+ return `[page] ${url} — "${title}"`
256
+ }
257
+
258
+ /** 紧凑渲染:页摘 ∥ 计数行 ∥ 元素行 ∥ 截断标记;总文本 ≤ 20,000 字符(N-BT3)。 */
259
+ export function renderSnapshot({ url, title, rows = [], total = rows.length }) {
260
+ const lines = [pageDigest(url, title), `[${total} interactive elements (${rows.filter((r) => r.isNew).length} new)]`]
261
+ for (const row of rows) lines.push(rowLine(row))
262
+ if (total > rows.length) lines.push(`[truncated: showing ${rows.length} of ${total} — pass selector to narrow]`)
263
+ return truncateText(lines.join("\n"), MAX_SNAPSHOT_CHARS, "pass selector to narrow")
264
+ }
265
+
266
+ /** 截断(`truncate` 同款标记形态——`tools/shared.mjs:190-193`)。 */
267
+ export function truncateText(text, max, note = "narrow the request") {
268
+ if (text.length <= max) return text
269
+ return `${text.slice(0, max)}\n[... truncated: ${text.length - max} chars omitted — ${note}]`
270
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thincoder/core",
3
- "version": "0.10.5",
3
+ "version": "0.10.6",
4
4
  "description": "ThinCoder shared core — shared mechanism modules + shared prompt content (CLI ↔ VS Code extension).",
5
5
  "keywords": [
6
6
  "ai",
@@ -40,6 +40,7 @@
40
40
  "advisor/",
41
41
  "agent/",
42
42
  "agent-tools/",
43
+ "browser/",
43
44
  "git/",
44
45
  "mcp/",
45
46
  "memory/",