opencode-browser-annotation-plugin 0.5.1 → 0.6.1

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
@@ -41,7 +41,23 @@ Configure with environment variables if needed:
41
41
  - `OPENCODE_ANNOTATION_PORT` (default `39517`)
42
42
  - `OPENCODE_ANNOTATION_HOST` (default `127.0.0.1`)
43
43
 
44
- ### 2. Extension (in your desktop Chrome)
44
+ ### 2. Skill (on the OpenCode host)
45
+
46
+ The agent learns how to interpret annotations from a bundled skill, so the
47
+ annotation payload stays lean (no repeated instructions). Copy it into your
48
+ OpenCode skills directory:
49
+
50
+ ```bash
51
+ mkdir -p ~/.config/opencode/skills/browser-annotation
52
+ cp node_modules/opencode-browser-annotation-plugin/skills/browser-annotation/SKILL.md \
53
+ ~/.config/opencode/skills/browser-annotation/SKILL.md
54
+ ```
55
+
56
+ Restart OpenCode. The agent auto-loads this skill when an annotation arrives
57
+ (the payload identifies itself and references the skill). Verify with
58
+ `opencode debug skill`.
59
+
60
+ ### 3. Extension (in your desktop Chrome)
45
61
 
46
62
  Until it is on the Chrome Web Store, load it unpacked:
47
63
 
@@ -49,7 +65,7 @@ Until it is on the Chrome Web Store, load it unpacked:
49
65
  2. Open `chrome://extensions`, enable **Developer mode**, click **Load unpacked**, and select `extension/`.
50
66
  3. Use a dedicated Chrome profile — the same debug profile you drive with the agent is a good choice.
51
67
 
52
- ### 3. Tunnel (when OpenCode is remote)
68
+ ### 4. Tunnel (when OpenCode is remote)
53
69
 
54
70
  The extension (on your desktop) needs to reach the plugin server (on the host),
55
71
  so use a **local forward**. Run on your desktop, in a separate window:
@@ -72,30 +88,48 @@ extension's **Settings** page.
72
88
 
73
89
  ## Use
74
90
 
75
- 1. Send at least one message in OpenCode so the plugin knows the active session.
91
+ 1. Send at least one message in OpenCode so the plugin knows the active session
92
+ (or pick a target session in the sidebar).
76
93
  2. Press **Alt+A** on any page, then click an element (highlight follows the
77
94
  cursor; works inside shadow DOM). A popup opens next to it.
78
- 3. In the popup, type an instruction and choose **Act now** (agent responds
79
- immediately) or **Queue** (held for context until the next Act). Then either:
95
+ 3. In the popup, type an instruction, then either:
80
96
  - **Send** (or Cmd/Ctrl+Enter) — submit this one right away, no sidebar needed.
81
97
  - **Add to list** — stash it in the sidebar to batch with others.
82
98
  4. Press **Alt+Shift+A** (or click the toolbar icon) to open the **list sidebar**,
83
- review pending annotations, and **Submit** them together. The footer shows the
84
- connection status; a toast confirms how many were sent vs queued.
99
+ review pending annotations (each with a thumbnail), pick the **target session**,
100
+ and **Submit** them together. The footer shows a steady connection status.
85
101
 
86
102
  Shortcuts: `Alt+A` picks an element, `Alt+Shift+A` toggles the list. If they do
87
103
  nothing after loading an unpacked build, set them at `chrome://extensions/shortcuts`.
88
104
 
105
+ ## How the agent handles it
106
+
107
+ When you submit, the plugin injects a short user turn into the chosen session
108
+ that identifies itself as a browser annotation and references the
109
+ `browser-annotation` skill. The agent loads that skill (installed in step 2) to
110
+ interpret the element metadata and locate the code — so the interpretation rules
111
+ live once in the skill, not in every message.
112
+
113
+ If your agents use a project `AGENTS.md`, you can make routing explicit by adding
114
+ a line such as:
115
+
116
+ ```
117
+ When a turn is a browser annotation (it says so and references the
118
+ `browser-annotation` skill), load that skill and use the element metadata
119
+ (component path, data-testid/id/role, ancestors, nearest region, text) to locate
120
+ and change the corresponding code. Confirm the element exists before editing.
121
+ ```
122
+
89
123
  ## Payload
90
124
 
91
125
  The extension POSTs to `/annotations`:
92
126
 
93
127
  ```json
94
128
  {
95
- "extensionVersion": "0.2.0",
129
+ "extensionVersion": "0.6.0",
130
+ "sessionID": "ses_...",
96
131
  "annotations": [
97
132
  {
98
- "mode": "act",
99
133
  "instruction": "Make this button larger and blue",
100
134
  "page": { "url": "https://example.com/app", "title": "My App" },
101
135
  "element": {
@@ -107,22 +141,28 @@ The extension POSTs to `/annotations`:
107
141
  "ariaLabel": "Sign up",
108
142
  "classes": ["cta", "primary"],
109
143
  "text": "Sign up",
144
+ "componentPath": "App > Header > SignupButton",
145
+ "framework": "react",
146
+ "landmark": "header[role=banner]",
147
+ "ancestors": ["div.actions", "header[role=banner]"],
110
148
  "bounds": { "x": 100, "y": 200, "width": 120, "height": 40 },
111
149
  "inShadow": false,
112
- "html": "<button class=\"cta\">Sign up</button>"
150
+ "html": "<button class=\"cta\" id=\"signup\">"
113
151
  }
114
152
  }
115
153
  ]
116
154
  }
117
155
  ```
118
156
 
119
- `mode` is `"act"` (respond now, flushing any queued) or `"queue"` (hold for
120
- context until the next act). The plugin surfaces the most code-locatable
121
- identifiers first (testId, id, name, role) and treats the CSS path and bounds as
122
- weak hints.
157
+ The plugin surfaces the most code-locatable identifiers first (component path,
158
+ testId, id, name, role, ancestors, region) and treats the CSS path and bounds as
159
+ weak hints. Build-generated/hashed class names are filtered out, text is
160
+ whitespace-collapsed, and `html` is the element's opening tag with secret-looking
161
+ attribute values redacted. `sessionID` is optional; without it the plugin targets
162
+ the last-active session.
123
163
 
124
- `GET /status` returns `{ ok, activeSession, sessionID, sessionTitle, queued, host, port }`
125
- for a quick health check and to show the target session.
164
+ `GET /status` returns `{ ok, activeSession, sessionID, sessionTitle, sessions, host, port }`
165
+ for a quick health check and to list/target sessions.
126
166
 
127
167
  ## Develop
128
168
 
package/dist/plugin.js CHANGED
@@ -74,7 +74,7 @@ function formatElement(el) {
74
74
  lines.push(` viewport bounds: ${Math.round(b.width)}×${Math.round(b.height)} at (${Math.round(b.x)}, ${Math.round(b.y)})`);
75
75
  }
76
76
  if (el.html)
77
- lines.push(` outer html: ${truncate(el.html.trim(), 500)}`);
77
+ lines.push(` opening tag: ${truncate(el.html.trim(), 500)}`);
78
78
  return lines.join("\n");
79
79
  }
80
80
  function annotationBlock(a, index, total) {
@@ -92,21 +92,15 @@ function annotationBlock(a, index, total) {
92
92
  .join("\n");
93
93
  }
94
94
  function buildPrompt(annotations) {
95
+ // The static interpretation rules live in the `browser-annotation` skill, not
96
+ // in every payload. The header marks this as a browser annotation and tells
97
+ // the agent to use that skill, so the rules are stated once (in the skill),
98
+ // never repeated per message.
95
99
  const header = annotations.length > 1
96
- ? `The user made ${annotations.length} annotations in the browser. Address each one.`
97
- : "The user made an annotation in the browser.";
100
+ ? `Browser annotations (${annotations.length}) handle using the \`browser-annotation\` skill. Address each one.`
101
+ : "Browser annotation handle using the `browser-annotation` skill.";
98
102
  const blocks = annotations.map((a, i) => annotationBlock(a, i, annotations.length));
99
- return [
100
- header,
101
- "",
102
- ...blocks,
103
- "",
104
- "Guidance:",
105
- "- Locate the code for each element using the most stable identifier available: framework component path first if given, then data-testid, id, name, role, then the ancestor chain and nearest region to disambiguate, then unique class or text. Treat the CSS path and viewport bounds as weak hints only.",
106
- "- Use the ancestors and nearest region to find the right component/file when the element itself is generic (e.g. a bare button that appears many times).",
107
- "- Confirm the element actually exists in this codebase before editing; if you cannot find it, say so instead of guessing.",
108
- "- No screenshot is attached; reason from the metadata and the code.",
109
- ].join("\n");
103
+ return [header, "", ...blocks].join("\n");
110
104
  }
111
105
  async function readJsonBody(req, limitBytes = 2_000_000) {
112
106
  return await new Promise((resolve, reject) => {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "OpenCode Browser Annotation",
4
- "version": "0.5.1",
4
+ "version": "0.6.1",
5
5
  "description": "Alt+A to annotate an element, Alt+Shift+A for the annotation list. Sends element metadata to your OpenCode agent.",
6
6
  "permissions": [
7
7
  "activeTab",
@@ -338,6 +338,48 @@
338
338
  return undefined;
339
339
  }
340
340
 
341
+ // Collapse runs of whitespace to single spaces and trim, so a container's
342
+ // textContent doesn't arrive as a wall of newlines.
343
+ function cleanText(s) {
344
+ const t = (s || "").replace(/\s+/g, " ").trim();
345
+ return t ? t.slice(0, 300) : undefined;
346
+ }
347
+
348
+ // Names/attributes whose values may be secrets and must never be sent.
349
+ const SECRET_ATTR = /(token|csrf|authenticity|secret|api[-_]?key|password|session|auth|nonce|signature)/i;
350
+
351
+ // For the outer HTML, send only the element's own opening tag (not the whole
352
+ // subtree), with secret-looking attribute values redacted. This avoids leaking
353
+ // CSRF tokens / API keys and keeps the payload small for large containers.
354
+ function safeOpenTag(el) {
355
+ try {
356
+ const tag = el.tagName.toLowerCase();
357
+ const attrs = [];
358
+ const nameAttr = el.getAttribute("name") || "";
359
+ const typeAttr = el.getAttribute("type") || "";
360
+ // Redact the value= of any input whose name/type looks sensitive (CSRF
361
+ // tokens, passwords, hidden auth fields), regardless of input type.
362
+ const valueIsSecret = SECRET_ATTR.test(nameAttr) || /password|hidden/i.test(typeAttr);
363
+ for (const a of Array.from(el.attributes || [])) {
364
+ // Skip class/style: class is already captured (cleaned) in `classes`,
365
+ // and inline style is noise. This avoids duplication and the ugly
366
+ // mid-class truncation.
367
+ if (a.name === "class" || a.name === "style") continue;
368
+ let v = a.value;
369
+ if (SECRET_ATTR.test(a.name) || (a.name === "value" && valueIsSecret)) {
370
+ v = "[redacted]";
371
+ } else if (v && v.length > 120) {
372
+ v = v.slice(0, 120) + "…";
373
+ }
374
+ attrs.push(v === "" ? a.name : `${a.name}="${v}"`);
375
+ }
376
+ const open = `<${tag}${attrs.length ? " " + attrs.join(" ") : ""}>`;
377
+ return open;
378
+ } catch {
379
+ return undefined;
380
+ }
381
+ }
382
+
341
383
  function elementMeta(el, inShadow) {
342
384
  const r = el.getBoundingClientRect();
343
385
  const fw = frameworkComponents(el);
@@ -350,7 +392,7 @@
350
392
  role: el.getAttribute("role") || undefined,
351
393
  ariaLabel: el.getAttribute("aria-label") || undefined,
352
394
  classes: cleanClasses(el.classList ? Array.from(el.classList) : []),
353
- text: (el.textContent || "").trim().slice(0, 500) || undefined,
395
+ text: cleanText(el.textContent),
354
396
  href: el.getAttribute("href") || undefined,
355
397
  src: el.getAttribute("src") || undefined,
356
398
  bounds: { x: r.left, y: r.top, width: r.width, height: r.height },
@@ -360,8 +402,11 @@
360
402
  landmark: nearestLandmark(el),
361
403
  componentPath: fw ? fw.path : undefined,
362
404
  framework: fw ? fw.framework : undefined,
363
- html: (el.outerHTML || "").slice(0, 800),
405
+ html: safeOpenTag(el),
364
406
  };
407
+ // Drop the opening tag if it carries no attributes beyond the tag name
408
+ // (redundant with `tag` once class/style are excluded).
409
+ if (m.html && /^<[a-z0-9-]+>$/i.test(m.html)) delete m.html;
365
410
  for (const k of Object.keys(m)) if (m[k] === undefined) delete m[k];
366
411
  if (Array.isArray(m.ancestors) && m.ancestors.length === 0) delete m.ancestors;
367
412
  return m;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-browser-annotation-plugin",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
4
4
  "description": "Select an element in your browser, type an instruction, and send it to your OpenCode agent over a loopback + SSH tunnel. Text and element metadata only (no screenshots).",
5
5
  "keywords": [
6
6
  "opencode",
@@ -28,6 +28,7 @@
28
28
  "files": [
29
29
  "dist",
30
30
  "extension",
31
+ "skills",
31
32
  "README.md"
32
33
  ],
33
34
  "scripts": {
@@ -0,0 +1,60 @@
1
+ ---
2
+ name: browser-annotation
3
+ description: >-
4
+ Interpret and act on browser annotations sent by the
5
+ opencode-browser-annotation extension. Load this whenever a message says it is
6
+ a "Browser annotation" or references the `browser-annotation` skill, or when a
7
+ turn contains a "Selected element:" block with fields like data-testid,
8
+ ancestors, nearest region, or a component path. It explains how to turn that
9
+ element metadata into the correct code location and change.
10
+ ---
11
+
12
+ # Browser annotations
13
+
14
+ Messages from the `opencode-browser-annotation` extension describe a DOM element
15
+ the user selected in their browser plus an instruction. Your job is to map that
16
+ element to the right code and carry out the instruction. The payload is text and
17
+ element metadata only — no screenshot is attached.
18
+
19
+ ## How to locate the code
20
+
21
+ Use the most stable identifier available, in this order:
22
+
23
+ 1. **Framework component path** (e.g. `App > Dashboard > SignupCard`) — the
24
+ strongest hint. Find that component's source file first.
25
+ 2. **`data-testid` / `id` / `name` / `role`** on the element itself — usually
26
+ maps directly to source.
27
+ 3. **Ancestors (nearest first) + nearest region** — use these to disambiguate a
28
+ generic element (e.g. a bare `<button>` that appears many times). They tell
29
+ you which container/feature/file the element belongs to.
30
+ 4. **Unique class or text** — human-authored class names and the element's text
31
+ are good locators. Build-generated/hashed class names are already filtered
32
+ out; treat any remaining opaque tokens as weak.
33
+ 5. **CSS path and viewport bounds** — weak hints only; do not rely on them.
34
+
35
+ ## Rules
36
+
37
+ - Confirm the element actually exists in this codebase before editing. If you
38
+ cannot find it, say so instead of guessing or inventing a file.
39
+ - Reason from the metadata and the actual code; there is no image.
40
+ - The `opening tag` field is the element's own opening tag with secret-looking
41
+ attribute values redacted — do not expect a full HTML subtree, and never try
42
+ to recover redacted values.
43
+ - If several annotations arrive together, address each one.
44
+ - Treat the page URL as context for which route/screen the user was on.
45
+
46
+ ## Field reference
47
+
48
+ - `Element` — tag name.
49
+ - `data-testid`, `id`, `name`, `role`, `aria-label` — element identifiers.
50
+ - `classes` — authored class names (hashed/build-generated ones removed).
51
+ - `text` — the element's visible text (whitespace-collapsed, truncated).
52
+ - `href` / `src` — link or media source when present.
53
+ - `<framework> components` — component hierarchy from React/Preact/Vue when
54
+ detectable.
55
+ - `nearest region` — closest semantic landmark/region ancestor.
56
+ - `ancestors (nearest first)` — parent chain signatures, shadow DOM pierced.
57
+ - `css path` — a positional selector; weak.
58
+ - `context` — notes such as "inside a shadow DOM" or "inside an iframe".
59
+ - `viewport bounds` — on-screen size/position; weak.
60
+ - `opening tag` — redacted opening tag of the element.