Skip to content

Commit 50afa3b

Browse files
authored
Merge pull request #43 from browser-use/fix/skills-dir-template-substitution
fix(skills): substitute {{SKILLS_DIR}} in materialized skill files
2 parents 9a7a71d + 3e1373a commit 50afa3b

3 files changed

Lines changed: 101 additions & 52 deletions

File tree

Lines changed: 53 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,84 @@
11
// Skills directory resolver.
22
//
3-
// Two packaging modes:
3+
// Materializes the skills tree to `<dataDir>/skills/` and substitutes the
4+
// `{{SKILLS_DIR}}` placeholder in every file with that absolute path so
5+
// cross-references inside BROWSER.md (``read `{{SKILLS_DIR}}/cloud-browser.md` ``)
6+
// point at a real location.
47
//
5-
// 1. Dev mode — `import.meta.url` resolves to `packages/bcode-browser/src/`
6-
// on disk, skills live at the sibling `../skills/`. Used by `bun run
7-
// --cwd packages/opencode dev` and tests.
8+
// Compiled launches (the user-facing path) read a one-line sentinel at
9+
// `<target>/.bcode-build` recording `<buildHash>:<target>`. When it matches
10+
// — i.e. same build, same dataDir — the resolver returns immediately
11+
// without reading or writing any skill content. The build hash is computed
12+
// once by `script/embed-skills.ts` and lives in the binary, so the cost is
13+
// a single small file read.
814
//
9-
// 2. Compiled mode — running from a `bun build --compile` binary.
10-
// `import.meta.dir` lives under `/$bunfs/` (or `B:/~BUN/` on Windows),
11-
// a read-only virtual filesystem the agent's `read` tool can't see in a
12-
// useful path shape. We extract the embedded skills (built into the
13-
// binary by `script/embed-skills.ts`) to `<dataDir>/skills/`. A content-
14-
// hash sentinel at `<dataDir>/skills/.bcode-build` records the embed
15-
// bundle that produced the on-disk tree; warm launches stat-and-skip.
16-
//
17-
// Skills are read-only baseline: every launch overwrites the on-disk tree
18-
// from the binary's embed (no agent-editable surface). The agent's editable
19-
// surface is `<projectDir>/.bcode/agent-workspace/`, per-project, never here.
15+
// Dev launches (`bun run dev`) always re-extract from the worktree so
16+
// editor saves to source skill files land on the next launch without a
17+
// separate invalidation step.
2018

2119
import fs from "fs/promises"
2220
import path from "path"
2321
import { fileURLToPath } from "url"
2422

2523
const __dirname = path.dirname(fileURLToPath(import.meta.url))
26-
const isCompiled = (() => {
27-
const d = __dirname.replaceAll("\\", "/")
28-
return d.startsWith("/$bunfs/") || d.startsWith("B:/~BUN/")
29-
})()
24+
const isCompiled = __dirname.replaceAll("\\", "/").match(/^\/\$bunfs\/|^B:\/~BUN\//) !== null
3025
const DEV_SKILLS_DIR = path.resolve(__dirname, "..", "skills")
31-
const SENTINEL_NAME = ".bcode-build"
26+
const SENTINEL = ".bcode-build"
3227

33-
// Static path so the agent permission glob can use a stable absolute path.
28+
// Static — the agent permission glob and the substituted placeholder both
29+
// resolve to this path.
3430
export const skillsDir = (dataDir: string) => path.join(dataDir, "skills")
3531

36-
const readSentinel = async (dir: string) => {
37-
try { return await fs.readFile(path.join(dir, SENTINEL_NAME), "utf8") }
38-
catch { return null }
32+
const cache = new Map<string, Promise<string>>()
33+
34+
export const resolveSkillsDir = (dataDir: string): Promise<string> => {
35+
const cached = cache.get(dataDir)
36+
if (cached) return cached
37+
const fresh = materialize(skillsDir(dataDir))
38+
cache.set(dataDir, fresh)
39+
fresh.catch(() => { if (cache.get(dataDir) === fresh) cache.delete(dataDir) })
40+
return fresh
3941
}
4042

41-
const extractEmbeddedSkills = async (dataDir: string): Promise<string> => {
42-
const target = skillsDir(dataDir)
43+
const materialize = async (target: string): Promise<string> => {
44+
// Compiled-mode short-circuit: import the embed (cheap — just file
45+
// handles, no content read), check the sentinel, return on hit.
4346
// @ts-expect-error generated at build time
44-
const mod = await import("bcode-skills.gen.ts").catch(() => null)
45-
if (!mod) throw new Error("bcode-skills.gen.ts not found in compiled binary — was the build script updated?")
46-
const fileMap = mod.default as Record<string, string>
47-
const buildHash = mod.buildHash as string
48-
49-
if ((await readSentinel(target)) === buildHash) return target
47+
const embed = isCompiled ? await import("bcode-skills.gen.ts").catch(() => null) : null
48+
if (isCompiled && !embed) throw new Error("bcode-skills.gen.ts not found — was the build script updated?")
49+
const want = `${embed?.buildHash ?? "dev"}:${target}`
50+
if (embed && (await Bun.file(path.join(target, SENTINEL)).text().catch(() => null)) === want) return target
5051

52+
const files = embed
53+
? await readEmbed(embed.default as Record<string, string>)
54+
: await readDevSkills()
5155
await fs.mkdir(target, { recursive: true })
52-
// Skills are baseline-overwrite — every file from the embed lands on disk.
5356
await Promise.all(
54-
Object.entries(fileMap).map(async ([rel, bunfsPath]) => {
57+
Object.entries(files).map(async ([rel, text]) => {
5558
const dest = path.join(target, rel)
5659
await fs.mkdir(path.dirname(dest), { recursive: true })
57-
await Bun.write(dest, Bun.file(bunfsPath))
60+
await fs.writeFile(dest, text.replaceAll("{{SKILLS_DIR}}", target), "utf8")
5861
}),
5962
)
60-
await fs.writeFile(path.join(target, SENTINEL_NAME), buildHash, "utf8")
63+
if (embed) await fs.writeFile(path.join(target, SENTINEL), want, "utf8")
6164
return target
6265
}
6366

64-
const extractCache = new Map<string, Promise<string>>()
67+
const readEmbed = async (map: Record<string, string>): Promise<Record<string, string>> =>
68+
Object.fromEntries(
69+
await Promise.all(Object.entries(map).map(async ([rel, p]) => [rel, await Bun.file(p).text()])),
70+
)
6571

66-
export const resolveSkillsDir = (dataDir: string): Promise<string> => {
67-
if (!isCompiled) return Promise.resolve(DEV_SKILLS_DIR)
68-
const cached = extractCache.get(dataDir)
69-
if (cached) return cached
70-
const fresh = extractEmbeddedSkills(dataDir)
71-
extractCache.set(dataDir, fresh)
72-
fresh.catch(() => {
73-
if (extractCache.get(dataDir) === fresh) extractCache.delete(dataDir)
74-
})
75-
return fresh
72+
const readDevSkills = async (): Promise<Record<string, string>> => {
73+
const rels = await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: DEV_SKILLS_DIR }))
74+
return Object.fromEntries(
75+
await Promise.all(
76+
rels.map(async (rel) => [
77+
rel.replaceAll("\\", "/"),
78+
await fs.readFile(path.join(DEV_SKILLS_DIR, rel), "utf8"),
79+
]),
80+
),
81+
)
7682
}
7783

7884
export * as Skills from "./skills"
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Skills materialization with `{{SKILLS_DIR}}` template substitution.
2+
// Regression guard: the on-disk skill files must not contain literal
3+
// `{{SKILLS_DIR}}` strings — those are templates the agent reads as
4+
// resolved absolute paths.
5+
6+
import { expect, test } from "bun:test"
7+
import fs from "fs/promises"
8+
import os from "os"
9+
import path from "path"
10+
import { Skills } from "../src/skills"
11+
12+
test("resolveSkillsDir materializes skills with {{SKILLS_DIR}} substituted", async () => {
13+
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "bcode-skills-"))
14+
try {
15+
const dir = await Skills.resolveSkillsDir(dataDir)
16+
expect(dir).toBe(path.join(dataDir, "skills"))
17+
const browser = await fs.readFile(path.join(dir, "BROWSER.md"), "utf8")
18+
expect(browser).not.toContain("{{SKILLS_DIR}}")
19+
expect(browser).toContain(path.join(dir, "cloud-browser.md"))
20+
expect(browser).toContain(path.join(dir, "interaction-skills"))
21+
} finally {
22+
await fs.rm(dataDir, { recursive: true, force: true })
23+
}
24+
})
25+
26+
test("different dataDirs get their own substituted paths", async () => {
27+
const a = await fs.mkdtemp(path.join(os.tmpdir(), "bcode-skills-a-"))
28+
const b = await fs.mkdtemp(path.join(os.tmpdir(), "bcode-skills-b-"))
29+
try {
30+
const dirA = await Skills.resolveSkillsDir(a)
31+
const dirB = await Skills.resolveSkillsDir(b)
32+
const [browserA, browserB] = await Promise.all([
33+
fs.readFile(path.join(dirA, "BROWSER.md"), "utf8"),
34+
fs.readFile(path.join(dirB, "BROWSER.md"), "utf8"),
35+
])
36+
expect(browserA).toContain(dirA)
37+
expect(browserB).toContain(dirB)
38+
expect(browserA).not.toContain(dirB)
39+
expect(browserB).not.toContain(dirA)
40+
} finally {
41+
await fs.rm(a, { recursive: true, force: true })
42+
await fs.rm(b, { recursive: true, force: true })
43+
}
44+
})

packages/opencode/src/agent/agent.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,11 +91,10 @@ export const layer = Layer.effect(
9191
// to whichever project is open (Phase H hard rule #3 — workspace as
9292
// plain code, per-project).
9393
const agentWorkspaceGlob = "**/.bcode/agent-workspace/**/*"
94-
// Browser-skills tree shipped inside the binary, extracted at runtime
95-
// to <Global.Path.data>/skills/. Read-only baseline; the agent reads
96-
// BROWSER.md + interaction-skills/ when driving the browser. In dev
97-
// mode the skills live inside the worktree, so this glob is a no-op
98-
// there.
94+
// Browser-skills tree, materialized at runtime to
95+
// <Global.Path.data>/skills/ in both dev and compiled modes (so the
96+
// `{{SKILLS_DIR}}` placeholder in BROWSER.md gets substituted with a
97+
// stable absolute path). Read-only baseline.
9998
const browserSkillsGlob = path.join(Skills.skillsDir(Global.Path.data), "*")
10099
const whitelistedDirs = [
101100
Truncate.GLOB,

0 commit comments

Comments
 (0)