diff --git a/biome.json b/biome.json index 317cdd2..50401a9 100644 --- a/biome.json +++ b/biome.json @@ -40,6 +40,10 @@ "!**/node_modules/**", "!**/dist/**", "!packages/dsh-plugin-browserskill/lib/**", + "!packages/dsh-plugin-browserskill/src/skill-content.generated.ts", + "!packages/dsh-plugin-browserskill/src/client/bsk-ui.nomodule.css", + "!packages/dsh-plugin-browserskill/.bsk-ui.raw.css", + "!packages/dsh-plugin-browserskill/.bsk-ui.input.css", "!**/target/**", "!**/.wxt/**", "!**/.output/**", diff --git a/packages/dsh-plugin-browserskill/.gitignore b/packages/dsh-plugin-browserskill/.gitignore index a6d91cc..e1e2da4 100644 --- a/packages/dsh-plugin-browserskill/.gitignore +++ b/packages/dsh-plugin-browserskill/.gitignore @@ -2,3 +2,4 @@ lib/ src/client/bsk-ui.nomodule.css .bsk-ui.input.css .bsk-ui.raw.css +src/skill-content.generated.ts diff --git a/packages/dsh-plugin-browserskill/README.md b/packages/dsh-plugin-browserskill/README.md index 872b049..1f41b19 100644 --- a/packages/dsh-plugin-browserskill/README.md +++ b/packages/dsh-plugin-browserskill/README.md @@ -23,6 +23,19 @@ extension keep owning the actual browser control — this package is a thin, wel | `browser_screenshot` | `bsk screenshot` | PNG capture of the tab or a ref-cropped element; inlines the image when the deployment supports image input, otherwise returns a file path. | | `browser_emulate` | `bsk emulate` | Apply or clear mobile device emulation on the active tab. | +## Agent skill (progressive disclosure) + +Beyond the tools, the plugin publishes the **`browser-skill` agent skill** through the harness's +official skill seam (`ctx.skills.register`): the catalog entry (name + routing description) is +resident in ``, and the body is loaded only when the model invokes the `skill` +tool. The body is assembled at build time from two parts, so there is exactly one source of +truth: a dsh-specific prelude (`skill/prelude.md` — tool↔CLI map, owned-session semantics, +plugin-only overrides) followed verbatim by the canonical CLI skill (`skill/SKILL.md` at the +repo root — the same file `crates/bsk-cli/build.rs` mirrors for the CLI package; workflows, +stop-when-done rules, refs usage, sandbox rules). Registration and every pre-step catalog +snapshot are pure in-memory reads (no disk/process/daemon); compositions without the skill +seam degrade silently. + ## Multi-session model One agent conversation can drive several browser sessions at once: diff --git a/packages/dsh-plugin-browserskill/package.json b/packages/dsh-plugin-browserskill/package.json index 709cb9f..8d8398f 100644 --- a/packages/dsh-plugin-browserskill/package.json +++ b/packages/dsh-plugin-browserskill/package.json @@ -34,10 +34,11 @@ } }, "scripts": { - "build": "node scripts/build-client-css.mjs && tsdown", - "typecheck": "tsc --noEmit", - "test": "pnpm run build:css && vitest run", - "build:css": "node scripts/build-client-css.mjs" + "build": "node scripts/build-client-css.mjs && node scripts/build-skill-content.mjs && tsdown", + "typecheck": "pnpm run build:skill && tsc --noEmit", + "test": "pnpm run build:css && pnpm run build:skill && vitest run", + "build:css": "node scripts/build-client-css.mjs", + "build:skill": "node scripts/build-skill-content.mjs" }, "peerDependencies": { "@deepseek-ai/cordis": "^4.0.1", diff --git a/packages/dsh-plugin-browserskill/scripts/build-skill-content.mjs b/packages/dsh-plugin-browserskill/scripts/build-skill-content.mjs new file mode 100644 index 0000000..8348fba --- /dev/null +++ b/packages/dsh-plugin-browserskill/scripts/build-skill-content.mjs @@ -0,0 +1,43 @@ +// Assemble the plugin's skill content: the dsh-specific prelude (committed in +// this package at skill/prelude.md) followed VERBATIM by the canonical CLI +// skill body (repo-root skill/SKILL.md — the same source crates/bsk-cli's +// build.rs mirrors, so the two can never drift apart). The original's YAML +// frontmatter is stripped: name/description are supplied by the registration. +// Output: src/skill-content.generated.ts (gitignored, built before tsdown and +// before vitest) so the markdown ships inlined in the bundle and registration +// is a pure in-memory read — never a per-step disk access. +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = dirname(fileURLToPath(import.meta.url)); +const pkg = join(here, ".."); +const prelude = readFileSync(join(pkg, "skill", "prelude.md"), "utf8"); +const upstream = readFileSync(join(pkg, "..", "..", "skill", "SKILL.md"), "utf8"); + +function stripFrontmatter(markdown) { + if (!markdown.startsWith("---\n")) return markdown; + const end = markdown.indexOf("\n---\n", 4); + return end === -1 ? markdown : markdown.slice(end + 5).replace(/^\s*\n/, ""); +} + +const NAME = "browser-skill"; +const DESCRIPTION = + "Browser automation against the user's logged-in Chromium through this plugin's browser_* " + + "tools (bsk engine): visit and read pages, fill forms, click through flows, scrape data, " + + "smoke-test UI changes. Use when a task needs a real browser; requires the bsk CLI and the " + + "browser-skill extension connected."; + +const content = `${prelude.trim()}\n\n${stripFrontmatter(upstream).trim()}\n`; + +const banner = + "// GENERATED by scripts/build-skill-content.mjs — do not edit.\n" + + "// Sources: skill/prelude.md (this package) + ../../../skill/SKILL.md (repo root).\n"; + +writeFileSync( + join(pkg, "src", "skill-content.generated.ts"), + `${banner}export const BSK_SKILL_NAME = ${JSON.stringify(NAME)};\n` + + `export const BSK_SKILL_DESCRIPTION = ${JSON.stringify(DESCRIPTION)};\n` + + `export const BSK_SKILL_MARKDOWN = ${JSON.stringify(content)};\n`, +); +console.log(`skill-content.generated.ts written (${content.length} chars)`); diff --git a/packages/dsh-plugin-browserskill/skill/prelude.md b/packages/dsh-plugin-browserskill/skill/prelude.md new file mode 100644 index 0000000..fb5a6b9 --- /dev/null +++ b/packages/dsh-plugin-browserskill/skill/prelude.md @@ -0,0 +1,50 @@ +# browser-skill (dsh edition) + +Drive the user's **real Chromium browser** (logins, cookies, Agent Window isolation) +through this plugin's `browser_*` tools. Each tool spawns `bsk --json` — the +same engine the canonical CLI skill documents — so the workflow, judgment rules, and +safety constraints are identical. + +## Tool ↔ CLI map + +The tools below are thin, structured wrappers over the bsk CLI verbs of the same +name; `session` parameters take the 4-letter session id (no `--session` flag +spelling here): + +| dsh tool | bsk equivalent | +|---|---| +| `browser_session_start` / `browser_session_stop` / `browser_session_list` | `bsk session start/stop/list` | +| `browser_navigate` | `bsk navigate` | +| `browser_snapshot` / `browser_observe` | `bsk snapshot` / `bsk observe` | +| `browser_click` / `browser_fill` / `browser_press` | `bsk click` / `bsk fill` / `bsk press` | +| `browser_screenshot` | `bsk screenshot` | +| `browser_emulate` | `bsk emulate` | + +(`bsk console` / `bsk network` have no tool counterpart in this plugin yet.) + +Plugin-specific semantics that override or narrow the CLI rules below: + +- **Owned sessions only**: this plugin sees and acts on just the sessions IT + started via `browser_session_start`. `browser_session_list` shows only those + (with the current marker); passing a session id created by another program + (or person) sharing the bsk daemon is refused outright — never retry those + with the same id. +- **Current session**: omitted `session` parameters resolve to the most + recently started or used OWNED session. With several sessions in flight, + pass `session` explicitly to avoid surprises. +- **Screenshots**: when the model route accepts images, `browser_screenshot` + returns the PNG inline as an image attachment; otherwise it returns the + saved file path. +- **`browser_emulate mobile`**: requires `width`+`height` — the bsk daemon + refuses `--mobile` without viewport dimensions. +- **Live observation**: in the dsh Web UI, every owned session is watched by + the floating overlay (thumbnails, action, interrupt) — observational side + traffic is free and never steals your command turn; just keep acting. + +The canonical CLI skill body follows verbatim (source of truth: `skill/SKILL.md` +in the BrowserSkill repo, mirrored by build into `crates/bsk-cli/skill/SKILL.md`). +Read `bsk --session ` as "the `browser_` tool with +`session: `"; everything else — when to use, the stop-when-done rules, the +interaction loop, refs, sandbox rules — applies unchanged. + +--- diff --git a/packages/dsh-plugin-browserskill/src/index.ts b/packages/dsh-plugin-browserskill/src/index.ts index a065aa4..7a580f5 100644 --- a/packages/dsh-plugin-browserskill/src/index.ts +++ b/packages/dsh-plugin-browserskill/src/index.ts @@ -17,6 +17,7 @@ import { registerObservationRoutes } from "./observation-http"; import { KeyedExecutor } from "./queue"; import { type BskRunner, createBskRunner } from "./runner"; import { SessionRegistry } from "./sessions"; +import { registerBskSkill } from "./skill"; import { type PluginConfig, registerTools } from "./tools"; export const name = "dsh-plugin-browserskill"; @@ -80,6 +81,10 @@ export function apply( }); registerTools({ ctx, runner, registry, config: resolved, observation, queue }); + // Progressive disclosure of the BSK agent skill (catalog entry resident, + // body on demand) through the official skill seam; silent no-op when the + // composition lacks it. + const unregisterSkill = registerBskSkill(ctx); // Route registration rides ctx.inject: the webServer service may be provided // AFTER this plugin loads, and in headless compositions it never appears (the // callback simply never runs, leaving the rest of the plugin unaffected). @@ -110,6 +115,7 @@ export function apply( // are swallowed so one stale handle cannot abort the rest. ctx.effect(() => { return () => { + unregisterSkill(); removeRoutes(); observation.dispose(); runner.killAll(); diff --git a/packages/dsh-plugin-browserskill/src/skill.ts b/packages/dsh-plugin-browserskill/src/skill.ts new file mode 100644 index 0000000..b67bce8 --- /dev/null +++ b/packages/dsh-plugin-browserskill/src/skill.ts @@ -0,0 +1,45 @@ +/** + * Progressive disclosure of the BrowserSkill agent skill through the harness's + * official skill seam (`ctx.skills`). The catalog entry (name + description) + * is resident; the body is loaded only when the model invokes the `skill` + * tool. The markdown is build-time assembled into a static module (dsh + * prelude + the canonical CLI skill body verbatim), so registration and every + * pre-step catalog snapshot are pure in-memory reads — no disk, no process, + * no daemon. + */ + +import type { Context } from "@deepseek-ai/cordis"; +import { + BSK_SKILL_DESCRIPTION, + BSK_SKILL_MARKDOWN, + BSK_SKILL_NAME, +} from "./skill-content.generated"; + +/** Structural view of the skill registry seam (absent in bare compositions). */ +interface SkillsLike { + register(skill: { + name: string; + description: string; + content: string; + source?: string; + }): () => void; +} + +/** + * Publish the BSK skill as an embedded runtime skill. Returns the unregistration + * disposer. A composition without the `skills` service (or with dsh-tool-skill + * retired) leaves the rest of the plugin unaffected — the no-op is silent. + */ +export function registerBskSkill(ctx: Context): () => void { + const skills = ctx.get("skills") as SkillsLike | undefined | null; + if (skills == null || typeof skills.register !== "function") { + return () => {}; + } + return skills.register({ + name: BSK_SKILL_NAME, + description: BSK_SKILL_DESCRIPTION, + content: BSK_SKILL_MARKDOWN, + // Prompt-visible origin bucket: packaged with a plugin, not user/project files. + source: "bundled", + }); +} diff --git a/packages/dsh-plugin-browserskill/tests/skill.test.ts b/packages/dsh-plugin-browserskill/tests/skill.test.ts new file mode 100644 index 0000000..b36cab2 --- /dev/null +++ b/packages/dsh-plugin-browserskill/tests/skill.test.ts @@ -0,0 +1,67 @@ +// BSK skill injection: registration wiring, catalog content, progressive-load +// weight, and silent degradation without the skill seam. + +import { describe, expect, it } from "vitest"; +import { registerBskSkill } from "../src/skill"; + +function fakeCtx(skills?: unknown) { + return { get: (key: string) => (key === "skills" ? skills : undefined) } as never; +} + +describe("registerBskSkill", () => { + it("registers the catalog entry with the assembled body", () => { + const registrations: { skill: Record; disposer: () => void }[] = []; + const skills = { + register(skill: Record) { + const disposer = () => { + skill.__disposed = true; + }; + registrations.push({ skill, disposer }); + return disposer; + }, + }; + const unregister = registerBskSkill(fakeCtx(skills)); + expect(registrations).toHaveLength(1); + const skill = registrations[0].skill; + expect(skill.name).toBe("browser-skill"); + expect(typeof skill.description).toBe("string"); + expect(String(skill.description)).toContain("browser_*"); + // body = dsh prelude + canonical CLI skill body + const content = String(skill.content); + expect(content).toContain("browser_observe"); + expect(content).toContain("Owned sessions only"); + expect(content).toContain("Mandatory workflow"); + expect(content).toContain("Refs invalidate after navigation"); + expect(content.length).toBeGreaterThan(10_000); + expect(skill.source).toBe("bundled"); + // the original YAML frontmatter must not leak into the body + expect(content.includes("name: browser-skill\ndescription:")).toBe(false); + // disposer passthrough + unregister(); + expect(skill.__disposed).toBe(true); + }); + + it("keeps one in-memory copy: repeated reads share the same content", () => { + let captured: Record | undefined; + const skills = { + register(skill: Record) { + captured = skill; + return () => {}; + }, + }; + registerBskSkill(fakeCtx(skills)); + // Pre-step catalog snapshots re-read the registration: the body must be + // the identical in-memory string every time (no reload, no copy). + expect(captured?.content).toBe(captured?.content); + expect(typeof captured?.content).toBe("string"); + }); + + it("degrades silently when the skills seam is absent or foreign", () => { + expect(() => registerBskSkill(fakeCtx())).not.toThrow(); + expect(() => registerBskSkill(fakeCtx(null))).not.toThrow(); + expect(() => registerBskSkill(fakeCtx({}))).not.toThrow(); + const disposer = registerBskSkill(fakeCtx()); + expect(typeof disposer).toBe("function"); + expect(() => disposer()).not.toThrow(); + }); +});