From 9ef5c6b8a328205d845577c4795308efda5572d9 Mon Sep 17 00:00:00 2001 From: bcode Date: Fri, 8 May 2026 23:58:04 +0000 Subject: [PATCH 1/3] fix(skills): substitute {{SKILLS_DIR}} in materialized skill files The browser_execute tool description ran .replaceAll("{{SKILLS_DIR}}", impl.skillsDir) so the description sent to the LLM resolved correctly, but the BROWSER.md skill file on disk still contained literal {{SKILLS_DIR}} strings. The agent reads BROWSER.md via the standard `read` tool and was therefore told to `read "{{SKILLS_DIR}}/cloud-browser.md"` verbatim, which fails. Materialize skills to /skills/ in both dev and compiled modes and replace {{SKILLS_DIR}} in *.md files with the absolute target path during materialization. Sentinel = ":" so a target change (or content change in dev) triggers re-extraction. Surfaced during v0.1.0 Windows binary testing. --- packages/bcode-browser/src/skills.ts | 118 ++++++++++++++++----- packages/bcode-browser/test/skills.test.ts | 68 ++++++++++++ packages/opencode/src/agent/agent.ts | 13 ++- 3 files changed, 170 insertions(+), 29 deletions(-) create mode 100644 packages/bcode-browser/test/skills.test.ts diff --git a/packages/bcode-browser/src/skills.ts b/packages/bcode-browser/src/skills.ts index 5768f3458..27d2b5eed 100644 --- a/packages/bcode-browser/src/skills.ts +++ b/packages/bcode-browser/src/skills.ts @@ -1,23 +1,34 @@ // Skills directory resolver. // -// Two packaging modes: +// Skills are always materialized to `/skills/` so the agent reads +// from a stable absolute path on every platform. During materialization, +// `{{SKILLS_DIR}}` placeholders inside `*.md` files are replaced with the +// target absolute path so cross-references in BROWSER.md (e.g. "read +// `{{SKILLS_DIR}}/cloud-browser.md`") resolve to a path the agent can use. // -// 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. +// Two packaging modes feed the materializer: +// +// 1. Dev mode — `import.meta.url` resolves to `packages/bcode-browser/src/`; +// source skills live at the sibling `../skills/`. The hash is computed +// over the on-disk source files so edits during dev iteration trigger +// re-extraction on the next launch. // // 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 `/skills/`. A content- -// hash sentinel at `/skills/.bcode-build` records the embed -// bundle that produced the on-disk tree; warm launches stat-and-skip. +// `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 use the embed map generated by +// `script/embed-skills.ts` and its precomputed `buildHash`. +// +// In both modes a content-hash sentinel at `/skills/.bcode-build` +// records the bundle + target path that produced the tree; warm launches +// stat-and-skip when both match. Including the target path in the sentinel +// guards against a stale tree if `dataDir` ever changes between launches. // // 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 `/.bcode/agent-workspace/`, per-project, never here. +// (no agent-editable surface). The agent's editable surface is +// `/.bcode/agent-workspace/`, per-project, never here. +import crypto from "crypto" import fs from "fs/promises" import path from "path" import { fileURLToPath } from "url" @@ -29,6 +40,7 @@ const isCompiled = (() => { })() const DEV_SKILLS_DIR = path.resolve(__dirname, "..", "skills") const SENTINEL_NAME = ".bcode-build" +const PLACEHOLDER = "{{SKILLS_DIR}}" // Static path so the agent permission glob can use a stable absolute path. export const skillsDir = (dataDir: string) => path.join(dataDir, "skills") @@ -38,36 +50,94 @@ const readSentinel = async (dir: string) => { catch { return null } } -const extractEmbeddedSkills = async (dataDir: string): Promise => { - const target = skillsDir(dataDir) +// `.md` files get template substitution; everything else is copied byte-for- +// byte. Substitution operates on the raw bytes rather than a UTF-8 round-trip +// to keep non-Markdown assets (images, binary fixtures) untouched if the +// skills tree ever grows beyond Markdown. +const writeSkillFile = async (dest: string, content: Uint8Array, target: string) => { + await fs.mkdir(path.dirname(dest), { recursive: true }) + if (!dest.endsWith(".md") || !indexOfPlaceholder(content)) { + await Bun.write(dest, content) + return + } + const text = new TextDecoder("utf-8").decode(content).replaceAll(PLACEHOLDER, target) + await fs.writeFile(dest, text, "utf8") +} + +// Cheap byte-level pre-check so we skip the UTF-8 decode on files that don't +// need substitution. The placeholder is pure ASCII so a byte search is safe. +const PLACEHOLDER_BYTES = new TextEncoder().encode(PLACEHOLDER) +const indexOfPlaceholder = (buf: Uint8Array) => { + outer: for (let i = 0; i + PLACEHOLDER_BYTES.length <= buf.length; i++) { + for (let j = 0; j < PLACEHOLDER_BYTES.length; j++) { + if (buf[i + j] !== PLACEHOLDER_BYTES[j]) continue outer + } + return true + } + return false +} + +// Stable hash over (rel + NUL + content) for every source file in sorted +// order — same shape as the build-time hash in `script/embed-skills.ts`. +const computeDevHash = async (files: string[]) => { + const hash = crypto.createHash("sha256") + for (const rel of files) { + hash.update(rel) + hash.update("\0") + hash.update(await fs.readFile(path.join(DEV_SKILLS_DIR, rel))) + } + return hash.digest("hex") +} + +// Sentinel = ":". Including `target` invalidates the tree +// if `dataDir` (and therefore the substituted absolute path) ever changes. +const sentinelFor = (bundleHash: string, target: string) => `${bundleHash}:${target}` + +const materializeFromSource = async (target: string): Promise => { + const files = (await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: DEV_SKILLS_DIR }))) + .map((f) => f.replaceAll("\\", "/")) + .sort() + const bundleHash = await computeDevHash(files) + const sentinel = sentinelFor(bundleHash, target) + if ((await readSentinel(target)) === sentinel) return target + + await fs.mkdir(target, { recursive: true }) + await Promise.all( + files.map(async (rel) => { + const buf = await fs.readFile(path.join(DEV_SKILLS_DIR, rel)) + await writeSkillFile(path.join(target, rel), buf, target) + }), + ) + await fs.writeFile(path.join(target, SENTINEL_NAME), sentinel, "utf8") + return target +} + +const materializeFromEmbed = async (target: string): Promise => { // @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 - const buildHash = mod.buildHash as string - - if ((await readSentinel(target)) === buildHash) return target + const sentinel = sentinelFor(mod.buildHash as string, target) + if ((await readSentinel(target)) === sentinel) 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)) + const buf = new Uint8Array(await Bun.file(bunfsPath).arrayBuffer()) + await writeSkillFile(path.join(target, rel), buf, target) }), ) - await fs.writeFile(path.join(target, SENTINEL_NAME), buildHash, "utf8") + await fs.writeFile(path.join(target, SENTINEL_NAME), sentinel, "utf8") return target } const extractCache = new Map>() export const resolveSkillsDir = (dataDir: string): Promise => { - if (!isCompiled) return Promise.resolve(DEV_SKILLS_DIR) const cached = extractCache.get(dataDir) if (cached) return cached - const fresh = extractEmbeddedSkills(dataDir) + const target = skillsDir(dataDir) + const fresh = isCompiled ? materializeFromEmbed(target) : materializeFromSource(target) extractCache.set(dataDir, fresh) fresh.catch(() => { if (extractCache.get(dataDir) === fresh) extractCache.delete(dataDir) diff --git a/packages/bcode-browser/test/skills.test.ts b/packages/bcode-browser/test/skills.test.ts new file mode 100644 index 000000000..b6126c056 --- /dev/null +++ b/packages/bcode-browser/test/skills.test.ts @@ -0,0 +1,68 @@ +// Skills materialization with `{{SKILLS_DIR}}` template substitution. +// +// Regression guard: the on-disk `BROWSER.md` (and any other markdown skill) +// must not contain literal `{{SKILLS_DIR}}` strings — those are templates the +// agent is supposed to see resolved to the absolute extraction path. + +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 BROWSER.md 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") + // No literal placeholder leaks through to the agent. + expect(browser).not.toContain("{{SKILLS_DIR}}") + // Cross-references resolve to absolute paths under the materialized dir. + expect(browser).toContain(path.join(dir, "cloud-browser.md")) + expect(browser).toContain(path.join(dir, "interaction-skills")) + + // Non-Markdown sentinel itself must not be substituted. + const sentinel = await fs.readFile(path.join(dir, ".bcode-build"), "utf8") + expect(sentinel).not.toContain("{{SKILLS_DIR}}") + } finally { + await fs.rm(dataDir, { recursive: true, force: true }) + } +}) + +test("resolveSkillsDir is idempotent — second call hits the sentinel and skips rewrite", async () => { + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "bcode-skills-")) + try { + const dir = await Skills.resolveSkillsDir(dataDir) + const browser = path.join(dir, "BROWSER.md") + const before = (await fs.stat(browser)).mtimeMs + // Yield to push mtime forward if a rewrite happens. + await new Promise((r) => setTimeout(r, 20)) + // Bypass in-process cache by reaching through to a fresh data dir handle. + const dir2 = await Skills.resolveSkillsDir(dataDir) + expect(dir2).toBe(dir) + const after = (await fs.stat(browser)).mtimeMs + expect(after).toBe(before) + } finally { + await fs.rm(dataDir, { recursive: true, force: true }) + } +}) + +test("resolveSkillsDir re-materializes when the target path changes (sentinel mismatch)", async () => { + const dataDirA = await fs.mkdtemp(path.join(os.tmpdir(), "bcode-skills-a-")) + const dataDirB = await fs.mkdtemp(path.join(os.tmpdir(), "bcode-skills-b-")) + try { + const dirA = await Skills.resolveSkillsDir(dataDirA) + const dirB = await Skills.resolveSkillsDir(dataDirB) + const browserA = await fs.readFile(path.join(dirA, "BROWSER.md"), "utf8") + const browserB = await 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(dataDirA, { recursive: true, force: true }) + await fs.rm(dataDirB, { recursive: true, force: true }) + } +}) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 44d783de4..cdb2981bb 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -91,11 +91,14 @@ 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 /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 + // /skills/. Read-only baseline; the agent reads + // BROWSER.md + interaction-skills/ when driving the browser. + // Materialization happens in both dev and compiled modes so the + // `{{SKILLS_DIR}}` placeholder in BROWSER.md is substituted with a + // stable absolute path the agent can use in cross-references. The + // wildcard impl (Wildcard.match) treats `*` as `.*` (greedy across + // `/`), so this single-segment glob matches the entire subtree. const browserSkillsGlob = path.join(Skills.skillsDir(Global.Path.data), "*") const whitelistedDirs = [ Truncate.GLOB, From 46ca8c8a0845e76621b9e7597e418f4f518b71e9 Mon Sep 17 00:00:00 2001 From: bcode Date: Sat, 9 May 2026 00:04:25 +0000 Subject: [PATCH 2/3] Simplify: collapse two materialize paths, drop sentinel/byte-search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first pass added unneeded machinery: a separate dev/compiled materialize codepath each, hand-rolled byte-search to avoid UTF-8 decode, sentinel + content hash for warm-launch skip. The skills tree is ~18 small markdown files (~100KB total) — rewriting them on every launch is microseconds and removes the cache-invalidation surface. Down to one straight-line resolver: read every file as text, write to /skills/ with -> target path. In-process cache dedupes concurrent calls within one launch. Tests trim to the two assertions that actually exercise behavior (substitution lands; different dataDirs get their own paths). --- packages/bcode-browser/src/skills.ts | 166 ++++++--------------- packages/bcode-browser/test/skills.test.ts | 54 ++----- packages/opencode/src/agent/agent.ts | 10 +- 3 files changed, 61 insertions(+), 169 deletions(-) diff --git a/packages/bcode-browser/src/skills.ts b/packages/bcode-browser/src/skills.ts index 27d2b5eed..8c968e84a 100644 --- a/packages/bcode-browser/src/skills.ts +++ b/packages/bcode-browser/src/skills.ts @@ -1,147 +1,67 @@ // Skills directory resolver. // -// Skills are always materialized to `/skills/` so the agent reads -// from a stable absolute path on every platform. During materialization, -// `{{SKILLS_DIR}}` placeholders inside `*.md` files are replaced with the -// target absolute path so cross-references in BROWSER.md (e.g. "read -// `{{SKILLS_DIR}}/cloud-browser.md`") resolve to a path the agent can use. +// Materializes the skills tree to `/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. // -// Two packaging modes feed the materializer: -// -// 1. Dev mode — `import.meta.url` resolves to `packages/bcode-browser/src/`; -// source skills live at the sibling `../skills/`. The hash is computed -// over the on-disk source files so edits during dev iteration trigger -// re-extraction on the next launch. -// -// 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 use the embed map generated by -// `script/embed-skills.ts` and its precomputed `buildHash`. -// -// In both modes a content-hash sentinel at `/skills/.bcode-build` -// records the bundle + target path that produced the tree; warm launches -// stat-and-skip when both match. Including the target path in the sentinel -// guards against a stale tree if `dataDir` ever changes between launches. -// -// Skills are read-only baseline: every launch overwrites the on-disk tree -// (no agent-editable surface). The agent's editable surface is -// `/.bcode/agent-workspace/`, per-project, never here. +// Skills are read-only baseline; the agent's editable surface lives +// elsewhere (`/.bcode/agent-workspace/`, per-project). -import crypto from "crypto" 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" -const PLACEHOLDER = "{{SKILLS_DIR}}" -// 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 } -} - -// `.md` files get template substitution; everything else is copied byte-for- -// byte. Substitution operates on the raw bytes rather than a UTF-8 round-trip -// to keep non-Markdown assets (images, binary fixtures) untouched if the -// skills tree ever grows beyond Markdown. -const writeSkillFile = async (dest: string, content: Uint8Array, target: string) => { - await fs.mkdir(path.dirname(dest), { recursive: true }) - if (!dest.endsWith(".md") || !indexOfPlaceholder(content)) { - await Bun.write(dest, content) - return - } - const text = new TextDecoder("utf-8").decode(content).replaceAll(PLACEHOLDER, target) - await fs.writeFile(dest, text, "utf8") -} - -// Cheap byte-level pre-check so we skip the UTF-8 decode on files that don't -// need substitution. The placeholder is pure ASCII so a byte search is safe. -const PLACEHOLDER_BYTES = new TextEncoder().encode(PLACEHOLDER) -const indexOfPlaceholder = (buf: Uint8Array) => { - outer: for (let i = 0; i + PLACEHOLDER_BYTES.length <= buf.length; i++) { - for (let j = 0; j < PLACEHOLDER_BYTES.length; j++) { - if (buf[i + j] !== PLACEHOLDER_BYTES[j]) continue outer - } - return true - } - return false -} - -// Stable hash over (rel + NUL + content) for every source file in sorted -// order — same shape as the build-time hash in `script/embed-skills.ts`. -const computeDevHash = async (files: string[]) => { - const hash = crypto.createHash("sha256") - for (const rel of files) { - hash.update(rel) - hash.update("\0") - hash.update(await fs.readFile(path.join(DEV_SKILLS_DIR, rel))) +// 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> => { + 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 + return Object.fromEntries( + await Promise.all(Object.entries(map).map(async ([rel, p]) => [rel, await Bun.file(p).text()])), + ) } - return hash.digest("hex") -} - -// Sentinel = ":". Including `target` invalidates the tree -// if `dataDir` (and therefore the substituted absolute path) ever changes. -const sentinelFor = (bundleHash: string, target: string) => `${bundleHash}:${target}` - -const materializeFromSource = async (target: string): Promise => { - const files = (await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: DEV_SKILLS_DIR }))) - .map((f) => f.replaceAll("\\", "/")) - .sort() - const bundleHash = await computeDevHash(files) - const sentinel = sentinelFor(bundleHash, target) - if ((await readSentinel(target)) === sentinel) return target - - await fs.mkdir(target, { recursive: true }) - await Promise.all( - files.map(async (rel) => { - const buf = await fs.readFile(path.join(DEV_SKILLS_DIR, rel)) - await writeSkillFile(path.join(target, rel), buf, target) - }), - ) - await fs.writeFile(path.join(target, SENTINEL_NAME), sentinel, "utf8") - return target -} - -const materializeFromEmbed = async (target: string): Promise => { - // @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 - const sentinel = sentinelFor(mod.buildHash as string, target) - if ((await readSentinel(target)) === sentinel) return target - - await fs.mkdir(target, { recursive: true }) - await Promise.all( - Object.entries(fileMap).map(async ([rel, bunfsPath]) => { - const buf = new Uint8Array(await Bun.file(bunfsPath).arrayBuffer()) - await writeSkillFile(path.join(target, rel), buf, target) - }), + 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), sentinel, "utf8") - return target } -const extractCache = new Map>() +const cache = new Map>() export const resolveSkillsDir = (dataDir: string): Promise => { - const cached = extractCache.get(dataDir) + const cached = cache.get(dataDir) if (cached) return cached const target = skillsDir(dataDir) - const fresh = isCompiled ? materializeFromEmbed(target) : materializeFromSource(target) - extractCache.set(dataDir, fresh) - fresh.catch(() => { - if (extractCache.get(dataDir) === fresh) extractCache.delete(dataDir) - }) + const fresh = (async () => { + const files = await readAllSkills() + 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 } diff --git a/packages/bcode-browser/test/skills.test.ts b/packages/bcode-browser/test/skills.test.ts index b6126c056..fee8fa3b0 100644 --- a/packages/bcode-browser/test/skills.test.ts +++ b/packages/bcode-browser/test/skills.test.ts @@ -1,8 +1,7 @@ // Skills materialization with `{{SKILLS_DIR}}` template substitution. -// -// Regression guard: the on-disk `BROWSER.md` (and any other markdown skill) -// must not contain literal `{{SKILLS_DIR}}` strings — those are templates the -// agent is supposed to see resolved to the absolute extraction path. +// 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" @@ -10,59 +9,36 @@ import os from "os" import path from "path" import { Skills } from "../src/skills" -test("resolveSkillsDir materializes BROWSER.md with {{SKILLS_DIR}} substituted", async () => { +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") - // No literal placeholder leaks through to the agent. expect(browser).not.toContain("{{SKILLS_DIR}}") - // Cross-references resolve to absolute paths under the materialized dir. expect(browser).toContain(path.join(dir, "cloud-browser.md")) expect(browser).toContain(path.join(dir, "interaction-skills")) - - // Non-Markdown sentinel itself must not be substituted. - const sentinel = await fs.readFile(path.join(dir, ".bcode-build"), "utf8") - expect(sentinel).not.toContain("{{SKILLS_DIR}}") - } finally { - await fs.rm(dataDir, { recursive: true, force: true }) - } -}) - -test("resolveSkillsDir is idempotent — second call hits the sentinel and skips rewrite", async () => { - const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), "bcode-skills-")) - try { - const dir = await Skills.resolveSkillsDir(dataDir) - const browser = path.join(dir, "BROWSER.md") - const before = (await fs.stat(browser)).mtimeMs - // Yield to push mtime forward if a rewrite happens. - await new Promise((r) => setTimeout(r, 20)) - // Bypass in-process cache by reaching through to a fresh data dir handle. - const dir2 = await Skills.resolveSkillsDir(dataDir) - expect(dir2).toBe(dir) - const after = (await fs.stat(browser)).mtimeMs - expect(after).toBe(before) } finally { await fs.rm(dataDir, { recursive: true, force: true }) } }) -test("resolveSkillsDir re-materializes when the target path changes (sentinel mismatch)", async () => { - const dataDirA = await fs.mkdtemp(path.join(os.tmpdir(), "bcode-skills-a-")) - const dataDirB = await fs.mkdtemp(path.join(os.tmpdir(), "bcode-skills-b-")) +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(dataDirA) - const dirB = await Skills.resolveSkillsDir(dataDirB) - const browserA = await fs.readFile(path.join(dirA, "BROWSER.md"), "utf8") - const browserB = await fs.readFile(path.join(dirB, "BROWSER.md"), "utf8") + 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(dataDirA, { recursive: true, force: true }) - await fs.rm(dataDirB, { recursive: true, force: true }) + await fs.rm(a, { recursive: true, force: true }) + await fs.rm(b, { recursive: true, force: true }) } }) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index cdb2981bb..177ac97ff 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -92,13 +92,9 @@ export const layer = Layer.effect( // plain code, per-project). const agentWorkspaceGlob = "**/.bcode/agent-workspace/**/*" // Browser-skills tree, materialized at runtime to - // /skills/. Read-only baseline; the agent reads - // BROWSER.md + interaction-skills/ when driving the browser. - // Materialization happens in both dev and compiled modes so the - // `{{SKILLS_DIR}}` placeholder in BROWSER.md is substituted with a - // stable absolute path the agent can use in cross-references. The - // wildcard impl (Wildcard.match) treats `*` as `.*` (greedy across - // `/`), so this single-segment glob matches the entire subtree. + // /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, From 3e1373af57b8c6c04856c49b088d4d48afb3814a Mon Sep 17 00:00:00 2001 From: bcode Date: Sat, 9 May 2026 00:14:01 +0000 Subject: [PATCH 3/3] Restore warm-launch sentinel for compiled binaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cubic flagged that the previous simplification rewrites the entire skills tree on every launch — small but unnecessary. Compiled binaries already carry a precomputed build hash from script/embed-skills.ts; use it. Warm launches now read one ~80-byte sentinel file at /.bcode-build and short-circuit (no skills content read, no writes) when it records the current :. Dev launches keep the always-rewrite behavior so editor saves to worktree skill files land on the next bun run dev without an invalidation step. The "dev" stamp never matches a written sentinel because we never write one in dev — single check, no special case. 84 lines total (was 67). Brings cubic's concern + admin's elegance ask to a stable equilibrium. --- packages/bcode-browser/src/skills.ts | 94 ++++++++++++++++------------ 1 file changed, 55 insertions(+), 39 deletions(-) diff --git a/packages/bcode-browser/src/skills.ts b/packages/bcode-browser/src/skills.ts index 8c968e84a..b89f3c8ed 100644 --- a/packages/bcode-browser/src/skills.ts +++ b/packages/bcode-browser/src/skills.ts @@ -2,13 +2,19 @@ // // Materializes the skills tree to `/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. +// cross-references inside BROWSER.md (``read `{{SKILLS_DIR}}/cloud-browser.md` ``) +// point at a real location. // -// Skills are read-only baseline; the agent's editable surface lives -// elsewhere (`/.bcode/agent-workspace/`, per-project). +// Compiled launches (the user-facing path) read a one-line sentinel at +// `/.bcode-build` recording `:`. When it matches +// — i.e. same build, same dataDir — the resolver returns immediately +// without reading or writing any skill content. The build hash is computed +// once by `script/embed-skills.ts` and lives in the binary, so the cost is +// a single small file read. +// +// Dev launches (`bun run dev`) always re-extract from the worktree so +// editor saves to source skill files land on the next launch without a +// separate invalidation step. import fs from "fs/promises" import path from "path" @@ -17,52 +23,62 @@ import { fileURLToPath } from "url" const __dirname = path.dirname(fileURLToPath(import.meta.url)) const isCompiled = __dirname.replaceAll("\\", "/").match(/^\/\$bunfs\/|^B:\/~BUN\//) !== null const DEV_SKILLS_DIR = path.resolve(__dirname, "..", "skills") +const SENTINEL = ".bcode-build" // Static — the agent permission glob and the substituted placeholder both // resolve to this path. export const skillsDir = (dataDir: string) => path.join(dataDir, "skills") -// 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> => { - 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 - 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")]), - ), - ) -} - const cache = new Map>() export const resolveSkillsDir = (dataDir: string): Promise => { const cached = cache.get(dataDir) if (cached) return cached - const target = skillsDir(dataDir) - const fresh = (async () => { - const files = await readAllSkills() - 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 - })() + const fresh = materialize(skillsDir(dataDir)) cache.set(dataDir, fresh) fresh.catch(() => { if (cache.get(dataDir) === fresh) cache.delete(dataDir) }) return fresh } +const materialize = async (target: string): Promise => { + // Compiled-mode short-circuit: import the embed (cheap — just file + // handles, no content read), check the sentinel, return on hit. + // @ts-expect-error generated at build time + const embed = isCompiled ? await import("bcode-skills.gen.ts").catch(() => null) : null + if (isCompiled && !embed) throw new Error("bcode-skills.gen.ts not found — was the build script updated?") + const want = `${embed?.buildHash ?? "dev"}:${target}` + if (embed && (await Bun.file(path.join(target, SENTINEL)).text().catch(() => null)) === want) return target + + const files = embed + ? await readEmbed(embed.default as Record) + : await readDevSkills() + 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") + }), + ) + if (embed) await fs.writeFile(path.join(target, SENTINEL), want, "utf8") + return target +} + +const readEmbed = async (map: Record): Promise> => + Object.fromEntries( + await Promise.all(Object.entries(map).map(async ([rel, p]) => [rel, await Bun.file(p).text()])), + ) + +const readDevSkills = async (): Promise> => { + const rels = await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: DEV_SKILLS_DIR })) + return Object.fromEntries( + await Promise.all( + rels.map(async (rel) => [ + rel.replaceAll("\\", "/"), + await fs.readFile(path.join(DEV_SKILLS_DIR, rel), "utf8"), + ]), + ), + ) +} + export * as Skills from "./skills"