Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 45 additions & 55 deletions packages/bcode-browser/src/skills.ts
Original file line number Diff line number Diff line change
@@ -1,77 +1,67 @@
// Skills directory resolver.
//
// Two packaging modes:
// Materializes the skills tree to `<dataDir>/skills/` and substitutes the
// `{{SKILLS_DIR}}` placeholder in every file with that absolute path so
// cross-references inside BROWSER.md (e.g. ``read `{{SKILLS_DIR}}/cloud-
// browser.md` ``) point at a real location. Source is `bcode-skills.gen.ts`
// in compiled mode (a `bun build --compile` virtual fs) and the in-tree
// `../skills/` in dev mode.
//
// 1. Dev mode — `import.meta.url` resolves to `packages/bcode-browser/src/`
// on disk, skills live at the sibling `../skills/`. Used by `bun run
// --cwd packages/opencode dev` and tests.
//
// 2. Compiled mode — running from a `bun build --compile` binary.
// `import.meta.dir` lives under `/$bunfs/` (or `B:/~BUN/` on Windows),
// a read-only virtual filesystem the agent's `read` tool can't see in a
// useful path shape. We extract the embedded skills (built into the
// binary by `script/embed-skills.ts`) to `<dataDir>/skills/`. A content-
// hash sentinel at `<dataDir>/skills/.bcode-build` records the embed
// bundle that produced the on-disk tree; warm launches stat-and-skip.
//
// Skills are read-only baseline: every launch overwrites the on-disk tree
// from the binary's embed (no agent-editable surface). The agent's editable
// surface is `<projectDir>/.bcode/agent-workspace/`, per-project, never here.
// Skills are read-only baseline; the agent's editable surface lives
// elsewhere (`<projectDir>/.bcode/agent-workspace/`, per-project).

import fs from "fs/promises"
import path from "path"
import { fileURLToPath } from "url"

const __dirname = path.dirname(fileURLToPath(import.meta.url))
const isCompiled = (() => {
const d = __dirname.replaceAll("\\", "/")
return d.startsWith("/$bunfs/") || d.startsWith("B:/~BUN/")
})()
const isCompiled = __dirname.replaceAll("\\", "/").match(/^\/\$bunfs\/|^B:\/~BUN\//) !== null
const DEV_SKILLS_DIR = path.resolve(__dirname, "..", "skills")
const SENTINEL_NAME = ".bcode-build"

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

const readSentinel = async (dir: string) => {
try { return await fs.readFile(path.join(dir, SENTINEL_NAME), "utf8") }
catch { return null }
}

const extractEmbeddedSkills = async (dataDir: string): Promise<string> => {
const target = skillsDir(dataDir)
// @ts-expect-error generated at build time
const mod = await import("bcode-skills.gen.ts").catch(() => null)
if (!mod) throw new Error("bcode-skills.gen.ts not found in compiled binary — was the build script updated?")
const fileMap = mod.default as Record<string, string>
const buildHash = mod.buildHash as string

if ((await readSentinel(target)) === buildHash) return target

await fs.mkdir(target, { recursive: true })
// Skills are baseline-overwrite — every file from the embed lands on disk.
await Promise.all(
Object.entries(fileMap).map(async ([rel, bunfsPath]) => {
const dest = path.join(target, rel)
await fs.mkdir(path.dirname(dest), { recursive: true })
await Bun.write(dest, Bun.file(bunfsPath))
}),
// Returns `{ rel: text }` for every skill file, sourced from the build-time
// embed in compiled mode and from the worktree in dev mode.
const readAllSkills = async (): Promise<Record<string, string>> => {
if (isCompiled) {
// @ts-expect-error generated at build time
const mod = await import("bcode-skills.gen.ts").catch(() => null)
if (!mod) throw new Error("bcode-skills.gen.ts not found — was the build script updated?")
const map = mod.default as Record<string, string>
return Object.fromEntries(
await Promise.all(Object.entries(map).map(async ([rel, p]) => [rel, await Bun.file(p).text()])),
)
}
const files = await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: DEV_SKILLS_DIR }))
return Object.fromEntries(
await Promise.all(
files.map(async (rel) => [rel.replaceAll("\\", "/"), await fs.readFile(path.join(DEV_SKILLS_DIR, rel), "utf8")]),
),
)
await fs.writeFile(path.join(target, SENTINEL_NAME), buildHash, "utf8")
return target
}

const extractCache = new Map<string, Promise<string>>()
const cache = new Map<string, Promise<string>>()

export const resolveSkillsDir = (dataDir: string): Promise<string> => {
if (!isCompiled) return Promise.resolve(DEV_SKILLS_DIR)
const cached = extractCache.get(dataDir)
const cached = cache.get(dataDir)
if (cached) return cached
const fresh = extractEmbeddedSkills(dataDir)
extractCache.set(dataDir, fresh)
fresh.catch(() => {
if (extractCache.get(dataDir) === fresh) extractCache.delete(dataDir)
})
const target = skillsDir(dataDir)
const fresh = (async () => {
const files = await readAllSkills()
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
await fs.mkdir(target, { recursive: true })
await Promise.all(
Object.entries(files).map(async ([rel, text]) => {
const dest = path.join(target, rel)
await fs.mkdir(path.dirname(dest), { recursive: true })
await fs.writeFile(dest, text.replaceAll("{{SKILLS_DIR}}", target), "utf8")
}),
)
return target
})()
cache.set(dataDir, fresh)
fresh.catch(() => { if (cache.get(dataDir) === fresh) cache.delete(dataDir) })
return fresh
}

Expand Down
44 changes: 44 additions & 0 deletions packages/bcode-browser/test/skills.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Skills materialization with `{{SKILLS_DIR}}` template substitution.
// Regression guard: the on-disk skill files must not contain literal
// `{{SKILLS_DIR}}` strings — those are templates the agent reads as
// resolved absolute paths.

import { expect, test } from "bun:test"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { Skills } from "../src/skills"

test("resolveSkillsDir materializes skills with {{SKILLS_DIR}} substituted", async () => {
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "bcode-skills-"))
try {
const dir = await Skills.resolveSkillsDir(dataDir)
expect(dir).toBe(path.join(dataDir, "skills"))
const browser = await fs.readFile(path.join(dir, "BROWSER.md"), "utf8")
expect(browser).not.toContain("{{SKILLS_DIR}}")
expect(browser).toContain(path.join(dir, "cloud-browser.md"))
expect(browser).toContain(path.join(dir, "interaction-skills"))
} finally {
await fs.rm(dataDir, { recursive: true, force: true })
}
})

test("different dataDirs get their own substituted paths", async () => {
const a = await fs.mkdtemp(path.join(os.tmpdir(), "bcode-skills-a-"))
const b = await fs.mkdtemp(path.join(os.tmpdir(), "bcode-skills-b-"))
try {
const dirA = await Skills.resolveSkillsDir(a)
const dirB = await Skills.resolveSkillsDir(b)
const [browserA, browserB] = await Promise.all([
fs.readFile(path.join(dirA, "BROWSER.md"), "utf8"),
fs.readFile(path.join(dirB, "BROWSER.md"), "utf8"),
])
expect(browserA).toContain(dirA)
expect(browserB).toContain(dirB)
expect(browserA).not.toContain(dirB)
expect(browserB).not.toContain(dirA)
} finally {
await fs.rm(a, { recursive: true, force: true })
await fs.rm(b, { recursive: true, force: true })
}
})
9 changes: 4 additions & 5 deletions packages/opencode/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,10 @@ export const layer = Layer.effect(
// to whichever project is open (Phase H hard rule #3 — workspace as
// plain code, per-project).
const agentWorkspaceGlob = "**/.bcode/agent-workspace/**/*"
// Browser-skills tree shipped inside the binary, extracted at runtime
// to <Global.Path.data>/skills/. Read-only baseline; the agent reads
// BROWSER.md + interaction-skills/ when driving the browser. In dev
// mode the skills live inside the worktree, so this glob is a no-op
// there.
// Browser-skills tree, materialized at runtime to
// <Global.Path.data>/skills/ in both dev and compiled modes (so the
// `{{SKILLS_DIR}}` placeholder in BROWSER.md gets substituted with a
// stable absolute path). Read-only baseline.
const browserSkillsGlob = path.join(Skills.skillsDir(Global.Path.data), "*")
const whitelistedDirs = [
Truncate.GLOB,
Expand Down
Loading