From c18370e3d41e3f66f8787c1480757a4ddd47c2c2 Mon Sep 17 00:00:00 2001 From: Andrew Buchkovich Date: Mon, 13 Jul 2026 14:47:54 -0400 Subject: [PATCH 1/4] =?UTF-8?q?feat(playground):=20browser=20playground=20?= =?UTF-8?q?=E2=80=94=20the=20Go=20compiler=20as=20WebAssembly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unmodified Go compiler built for js/wasm, running against an in-memory Node-style fs shim, with a dependency-free static UI: live recompilation, artifact browser, embedding graph (including computed structural-interface satisfaction), resolved-bundle view, shareable gzip+base64url links, and the determinism digest in the status bar. Deployed to GitHub Pages on push to main. The in-memory source directory is named after the loaded example and the ARD publisher domain matches the repository's own build commands, so the Helio example reproduces the committed dist/ digest exactly and the maintainer example reproduces the repository-root AGENTS.md byte for byte — verified against the Windows CLI during development. Generated assets (typeference.wasm, wasm_exec.js, examples.json) are gitignored and produced by 'make playground'; wasm_exec.js is copied from the building toolchain so it cannot skew from the wasm binary. CI builds the wasm bridge and example packer to prevent bitrot. See ADR-0010. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 5 + .github/workflows/pages.yml | 41 ++ .gitignore | 4 + CHANGELOG.md | 5 + Makefile | 11 +- README.md | 12 + docs/decisions/0010-browser-playground.md | 83 +++ docs/decisions/README.md | 1 + go/cmd/playground-pack/main.go | 182 +++++++ go/cmd/typeference-wasm/main.go | 253 +++++++++ web/playground/README.md | 35 ++ web/playground/app.js | 591 ++++++++++++++++++++++ web/playground/index.html | 76 +++ web/playground/memfs.js | 319 ++++++++++++ web/playground/style.css | 314 ++++++++++++ 15 files changed, 1931 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/pages.yml create mode 100644 docs/decisions/0010-browser-playground.md create mode 100644 go/cmd/playground-pack/main.go create mode 100644 go/cmd/typeference-wasm/main.go create mode 100644 web/playground/README.md create mode 100644 web/playground/app.js create mode 100644 web/playground/index.html create mode 100644 web/playground/memfs.js create mode 100644 web/playground/style.css diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69120ac..de13556 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,11 @@ jobs: run: /tmp/typeference diff examples/helio --against dist --emit-ard --publisher-domain helio.example - name: Eval harness dry run (no API key, no network) run: /tmp/typeference eval examples/helio --scenarios evals/scenarios --out /tmp/eval-dry-run + - name: Playground builds (js/wasm bridge and example packer) + run: | + GOOS=js GOARCH=wasm go build ./cmd/typeference-wasm + go run ./cmd/playground-pack -root .. -out /tmp/examples.json + working-directory: go # NOTE: BETH pack/score is intentionally NOT gating CI yet. The harness is # unproven end to end (no real judged run has been collected), and an # offline pack+score observes nothing — a green scorecard there would be diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..17c7d3b --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,41 @@ +# Builds the browser playground (web/playground) and deploys it to GitHub +# Pages on every push to main. The playground is the unmodified Go compiler +# built for js/wasm; see docs/decisions/0010-browser-playground.md. +name: pages + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: stable + cache-dependency-path: go/go.sum + - name: Build playground (wasm + examples + wasm_exec.js) + run: make playground VERSION=${GITHUB_SHA::12} + - uses: actions/configure-pages@v5 + with: + enablement: true + - uses: actions/upload-pages-artifact@v3 + with: + path: web/playground + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 6053aea..2e3fde8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,7 @@ beth-runs/ .vs/ __pycache__/ *.pyc +# Generated playground assets (make playground) +web/playground/typeference.wasm +web/playground/wasm_exec.js +web/playground/examples.json diff --git a/CHANGELOG.md b/CHANGELOG.md index bcade06..5f2d05b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,11 @@ First tagged release. Everything before this version was unversioned development files with expected-behavior rubrics, LLM-judged adherence scoring, and a dry-run default that emits exact request payloads with no network calls (ADR-0008). Starter corpus under `evals/`. +- **Browser playground** under `web/playground` — the unmodified Go compiler + built for `js/wasm` against an in-memory filesystem, with a dependency-free + UI: live recompilation, artifact browser, embedding graph, resolved-bundle + view, and shareable links. Deployed to GitHub Pages on push to `main`; the + Helio example reproduces the committed `dist/` digest exactly (ADR-0010). - `typeference version` command in both implementations. - Architecture decision records under `docs/decisions/`. - Tag-driven release workflow shipping per-platform archives of the Go CLI with diff --git a/Makefile b/Makefile index 722b5e8..8b385df 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ LDFLAGS := -s -w -X main.version=$(VERSION) BINDIR := bin .PHONY: all build build-go build-dotnet test test-go test-dotnet conformance \ - selfhost selfhost-check fmt vet clean release-binaries + selfhost selfhost-check fmt vet clean release-binaries playground all: build test @@ -56,6 +56,15 @@ vet: cd go && $(GO) vet ./... cd go && test -z "$$(gofmt -l .)" +# Browser playground: the unmodified Go compiler built for js/wasm plus a +# static, dependency-free UI. Everything lands in web/playground; serve that +# directory with any static file server. wasm_exec.js is copied from the +# building toolchain so it always matches the wasm binary's Go version. +playground: + cd go && GOOS=js GOARCH=wasm $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o ../web/playground/typeference.wasm ./cmd/typeference-wasm + cp "$$($(GO) env GOROOT)/lib/wasm/wasm_exec.js" web/playground/wasm_exec.js + cd go && $(GO) run ./cmd/playground-pack -root .. -out ../web/playground/examples.json + clean: rm -rf $(BINDIR) diff --git a/README.md b/README.md index 994348d..63e326a 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,18 @@ skills: Use profiles for reusable organizational, domain, or team defaults that should participate in composition without producing their own target bundle. +## Try it in your browser + +The **[playground](https://buchk.github.io/TypeFerence/)** runs the real Go +compiler — built for WebAssembly, internals untouched — entirely in your tab. +Edit typed source, watch the compiled Codex/Copilot/Cursor/neutral artifacts, +the embedding graph, and the diagnostics update live. The status-bar digest is +the determinism guarantee made interactive: the Helio example reproduces the +digest of this repository's committed `dist/` exactly, and the self-hosting +example recompiles the repository's own root `AGENTS.md` byte for byte. There +is no backend; nothing you type leaves the browser +([ADR-0010](docs/decisions/0010-browser-playground.md)). + ## Quick start Requires Go 1.24+ and nothing else. From clone to compiled artifacts: diff --git a/docs/decisions/0010-browser-playground.md b/docs/decisions/0010-browser-playground.md new file mode 100644 index 0000000..5bbb135 --- /dev/null +++ b/docs/decisions/0010-browser-playground.md @@ -0,0 +1,83 @@ +# 0010 — Browser playground: the Go compiler as WebAssembly + +## Status + +Accepted. + +## Context + +Evaluating TypeFerence required installing a toolchain: clone the repository, +build the Go binary (or the .NET solution), then run `validate` / `build` +against an example. That is a high price for the first five minutes of +curiosity, and it keeps the project's two strongest properties abstract: + +- **Determinism.** "Byte-identical artifacts" is a claim in the README until a + person watches the same digest come out of two different environments. +- **Composition.** The embedding/promotion model is easiest to understand by + editing a profile and watching the compiled `AGENTS.md` change. + +The project already pays for two independent implementations kept +byte-identical by a conformance suite (ADR-0005). That investment makes a +browser port nearly free: Go compiles to `js/wasm` out of the box, and the Go +implementation's only dependency is `gopkg.in/yaml.v3` (ADR-0003), which is +pure Go. + +## Decision + +Ship a static, zero-install playground at `web/playground`, deployed to GitHub +Pages on every push to `main` (`.github/workflows/pages.yml`). + +1. **The compiler is not forked, wrapped, or reimplemented.** A new + `go/cmd/typeference-wasm` entry point builds the existing `internal/` + packages for `GOOS=js GOARCH=wasm` and exposes one function, + `TypeFerence.compile`, that runs the same `compile.Validate` → + `compile.Build` → `compile.HashDirectory` pipeline as the CLI. +2. **File I/O is satisfied, not removed.** Go's `js/wasm` syscall layer + delegates to a global Node-style `fs` object. `web/playground/memfs.js` + implements that API over an in-memory tree, so the compiler's ordinary + `os` calls work unchanged inside the browser tab. +3. **The UI has zero dependencies.** Plain HTML/CSS/JS: an editor with + lightweight syntax coloring, an artifact browser, the embedding graph + (including computed structural-interface satisfaction), the resolved-bundle + view, and a share link (gzip + base64url in the URL fragment). Nothing the + user types leaves the tab; there is no backend. +4. **Digests reproduce the repository's own builds.** The in-memory source + directory is named after the loaded example and the ARD publisher domain + matches the repository's build commands, so the Helio example produces the + exact digest of the committed `dist/`, and the maintainer example + reproduces the repository-root `AGENTS.md` byte for byte. +5. **Generated assets are not committed.** `typeference.wasm`, + `examples.json`, and `wasm_exec.js` are produced by `make playground`; + `wasm_exec.js` is copied from the building toolchain's `GOROOT` so it can + never skew from the wasm binary's Go version. CI builds the wasm bridge and + packer on every push so the playground cannot silently rot. + +## Consequences + +- Anyone can evaluate TypeFerence — including the determinism guarantee — from + a link, with nothing installed and no data leaving their machine. +- The playground is a demonstration surface, not a supported runtime. The CLI + remains the product; the playground intentionally exposes no command surface + beyond compile-on-edit. +- The wasm binary is ~5 MB (~1.8 MB compressed), an acceptable one-time load + for a code playground. +- `memfs.js` implements the private contract between Go's syscall layer and + its JavaScript host. A Go major release could change that contract; because + `make playground` builds binary and shim support files from one toolchain, + such a break surfaces at build/CI time, not silently in production. + +## Alternatives considered + +- **A TypeScript reimplementation of the compiler for the browser.** A third + implementation to keep conformant forever, and it would demonstrate nothing + about the shipped compilers. Rejected outright; the whole value of the + playground is that it runs the real one. +- **A hosted compile API.** Requires a server, introduces cost, latency, and a + privacy story ("your agent definitions are uploaded to..."), and undermines + the static-artifact ethos of the project. The wasm build is strictly better. +- **Compiling the C# reference implementation with .NET's wasm support.** + Works, but produces a far larger payload and a heavier runtime; the Go + implementation exists precisely to be the small, static, portable one + (ADR-0007). +- **Recorded demo (GIF/asciinema) in the README.** Cheap, but not interactive + and proves nothing — a recording of a digest is not a digest you produced. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index c814d38..5cf4a86 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -18,3 +18,4 @@ Format: `NNNN-short-title.md` with sections **Status**, **Context**, **Decision* | [0007](0007-release-distribution.md) | Release distribution: static binaries, no installer | | [0008](0008-eval-harness-scope.md) | Behavioral eval harness: scope and design | | [0009](0009-behavioral-equivalence-harness.md) | BETH: the behavioral equivalence test harness | +| [0010](0010-browser-playground.md) | Browser playground: the Go compiler as WebAssembly | diff --git a/go/cmd/playground-pack/main.go b/go/cmd/playground-pack/main.go new file mode 100644 index 0000000..4900c54 --- /dev/null +++ b/go/cmd/playground-pack/main.go @@ -0,0 +1,182 @@ +// Command playground-pack bundles example source trees into the JSON file +// the browser playground (web/playground) loads at startup. Run via +// `make playground`. +package main + +import ( + "flag" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + + "github.com/buchk/TypeFerence/go/internal/jsonx" +) + +type example struct { + Name string + Title string + Description string + Domain string // ARD publisher domain matching the repo's own builds + Dir string // repo-relative source directory, or + Files map[string]string // inline files when Dir is empty +} + +var examples = []example{ + { + Name: "starter", + Title: "Starter", + Description: "One agent embedding one profile: the smallest complete composition.", + Domain: "playground.example", + Files: starterFiles, + }, + { + Name: "helio", + Title: "Helio Works", + Description: "The full fictional organization from examples/helio: two agents, layered profiles, structural interfaces.", + Domain: "helio.example", + Dir: "examples/helio", + }, + { + Name: "maintainer", + Title: "This repository's maintainer", + Description: "TypeFerence self-hosted: the agent definition that maintains the TypeFerence repository.", + Domain: "typeference.example", + Dir: "agents/maintainer", + }, +} + +func main() { + root := flag.String("root", ".", "repository root") + out := flag.String("out", "web/playground/examples.json", "output file") + flag.Parse() + + list := jsonx.Arr{} + for _, ex := range examples { + files := ex.Files + if ex.Dir != "" { + var err error + files, err = readTree(filepath.Join(*root, filepath.FromSlash(ex.Dir))) + if err != nil { + fatal("reading %s: %s", ex.Dir, err) + } + } + fileObj := jsonx.Obj{} + for _, path := range sortedKeys(files) { + fileObj = append(fileObj, jsonx.Member{K: path, V: jsonx.Str(files[path])}) + } + list = append(list, jsonx.Obj{ + {K: "name", V: jsonx.Str(ex.Name)}, + {K: "title", V: jsonx.Str(ex.Title)}, + {K: "description", V: jsonx.Str(ex.Description)}, + {K: "publisherDomain", V: jsonx.Str(ex.Domain)}, + {K: "files", V: fileObj}, + }) + } + document := jsonx.Indented(jsonx.Obj{{K: "examples", V: list}}) + "\n" + if err := os.WriteFile(*out, []byte(document), 0o644); err != nil { + fatal("writing %s: %s", *out, err) + } + fmt.Printf("Packed %d examples into %s\n", len(examples), *out) +} + +func readTree(root string) (map[string]string, error) { + files := map[string]string{} + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + raw, readErr := os.ReadFile(path) + if readErr != nil { + return readErr + } + rel, relErr := filepath.Rel(root, path) + if relErr != nil { + return relErr + } + files[filepath.ToSlash(rel)] = string(raw) + return nil + }) + return files, err +} + +func sortedKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func fatal(format string, args ...any) { + fmt.Fprintf(os.Stderr, "playground-pack: "+format+"\n", args...) + os.Exit(1) +} + +var starterFiles = map[string]string{ + "agents/support-agent.agent.yaml": `schemaVersion: 3 +kind: agent +id: acme/support-agent@1.0.0 +displayName: Acme Support Agent +description: Answers customer tickets for Acme's widget line. +embeds: + - acme/profiles/support-defaults@1.0.0 +contextFiles: + - context/widgets.md +skills: + - ref: acme/skills/summarize-ticket@1.0.0 + capability: acme/capabilities/summarize-ticket@1.0.0 +`, + "profiles/support-defaults.profile.yaml": `schemaVersion: 3 +kind: profile +id: acme/profiles/support-defaults@1.0.0 +displayName: Acme Support Defaults +description: Reusable tone and escalation defaults for support agents. +slots: + tone: context/tone.md +workingNorms: + - Never promise a refund without a linked policy clause. +contextFiles: + - context/tone.md +`, + "capabilities/summarize-ticket.capability.yaml": `schemaVersion: 3 +kind: capability +id: acme/capabilities/summarize-ticket@1.0.0 +displayName: Summarize Ticket +description: Capability slot for structured ticket summaries. +inputSchema: '{"type":"object","properties":{"ticketId":{"type":"string"}},"additionalProperties":false}' +outputSchema: '{"type":"object","properties":{"summary":{"type":"string"},"nextAction":{"type":"string"}},"required":["summary","nextAction"]}' +`, + "skills/summarize-ticket.skill.yaml": `schemaVersion: 3 +kind: skill +id: acme/skills/summarize-ticket@1.0.0 +binds: acme/capabilities/summarize-ticket@1.0.0 +displayName: Summarize Ticket +description: Summarize a support ticket with the customer's history in view. +instructions: | + Read the ticket and produce a two-sentence summary plus one concrete next action. + Cite the ticket fields you used; never invent order numbers. +inputSchema: '{"type":"object","properties":{"ticketId":{"type":"string"}},"additionalProperties":false}' +outputSchema: '{"type":"object","properties":{"summary":{"type":"string"},"nextAction":{"type":"string"}},"required":["summary","nextAction"]}' +`, + "interfaces/summarizer.interface.yaml": `schemaVersion: 3 +kind: interface +id: acme/interfaces/summarizer@1.0.0 +displayName: Summarizer +description: Contract for agents that can produce structured ticket summaries. +requiresCapabilities: + - acme/capabilities/summarize-ticket@1.0.0 +`, + "context/tone.md": `# Tone + +Warm, direct, and concrete. Lead with what will happen next, not with an +apology. One idea per sentence. +`, + "context/widgets.md": `# Widget line + +Acme sells three widget models: Standard, Pro, and the discontinued Classic. +Classic tickets always require the legacy-parts disclaimer. +`, +} diff --git a/go/cmd/typeference-wasm/main.go b/go/cmd/typeference-wasm/main.go new file mode 100644 index 0000000..9878381 --- /dev/null +++ b/go/cmd/typeference-wasm/main.go @@ -0,0 +1,253 @@ +//go:build js && wasm + +// Command typeference-wasm exposes the unmodified Go compiler to the browser +// playground (web/playground). It registers a global `TypeFerence.compile` +// function that writes the caller's sources into the in-memory filesystem +// provided by memfs.js, runs the same compile.Validate / compile.Build / +// compile.HashDirectory code paths as the CLI, and returns artifacts, +// diagnostics, and the embedding graph as a plain JavaScript object. +// +// The compiler internals are untouched: the playground's determinism digest +// is produced by the identical code that produces it on disk. +package main + +import ( + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "sort" + "strings" + "syscall/js" + + "github.com/buchk/TypeFerence/go/internal/compile" + "github.com/buchk/TypeFerence/go/internal/resource" +) + +// version is stamped by the Pages workflow via +// -ldflags "-X main.version="; development builds report "dev". +var version = "dev" + +const ( + workRoot = "/work" + outputRoot = "/out" +) + +func main() { + js.Global().Set("TypeFerence", js.ValueOf(map[string]any{ + "version": version, + "compile": js.FuncOf(compileFunc), + })) + select {} +} + +// compileFunc implements TypeFerence.compile(request). The request object +// carries files (a path-to-content map), target, emitArd, publisherDomain, +// and sourceName; the result object carries ok, error, agents (id, +// displayName, emit, satisfies, bundle), files, hash, and graph. +// +// sourceName becomes the in-memory source directory's basename. The ARD +// source-package URN is derived from that name, so using the same directory +// name as a CLI invocation reproduces its digest exactly. +func compileFunc(_ js.Value, args []js.Value) (result any) { + defer func() { + if r := recover(); r != nil { + result = map[string]any{"ok": false, "error": fmt.Sprintf("internal error: %v", r)} + } + }() + if len(args) < 1 || args[0].Type() != js.TypeObject { + return map[string]any{"ok": false, "error": "compile requires a request object"} + } + request := args[0] + + sourceName := "src" + if n := request.Get("sourceName"); n.Type() == js.TypeString { + if clean := sanitizeName(n.String()); clean != "" { + sourceName = clean + } + } + sourceRoot := path.Join(workRoot, sourceName) + + if err := writeSources(sourceRoot, request.Get("files")); err != nil { + return map[string]any{"ok": false, "error": err.Error()} + } + + // The embedding graph comes straight from the loaded documents so it can + // be shown even when resolution fails (cycles, ambiguity, missing refs). + graph := map[string]any{"nodes": []any{}, "edges": []any{}} + if resources, err := resource.Load(sourceRoot, ""); err == nil { + graph = buildGraph(resources) + } + + targetValue := "all" + if t := request.Get("target"); t.Type() == js.TypeString && t.String() != "" { + targetValue = t.String() + } + targets, err := compile.ParseTargets(targetValue) + if err != nil { + return map[string]any{"ok": false, "error": err.Error(), "graph": graph} + } + var ard *compile.ArdPublicationOptions + if b := request.Get("emitArd"); b.Type() == js.TypeBoolean && b.Bool() { + domain := "playground.example" + if d := request.Get("publisherDomain"); d.Type() == js.TypeString && d.String() != "" { + domain = d.String() + } + ard = &compile.ArdPublicationOptions{PublisherDomain: domain} + } + + agents, err := compile.Validate(sourceRoot, "") + if err != nil { + return map[string]any{"ok": false, "error": relativizeError(err.Error(), sourceRoot), "graph": graph} + } + if _, err := compile.Build(sourceRoot, outputRoot, targets, ard); err != nil { + return map[string]any{"ok": false, "error": relativizeError(err.Error(), sourceRoot), "graph": graph} + } + hash, err := compile.HashDirectory(outputRoot) + if err != nil { + return map[string]any{"ok": false, "error": err.Error(), "graph": graph} + } + artifacts, err := readTree(outputRoot) + if err != nil { + return map[string]any{"ok": false, "error": err.Error(), "graph": graph} + } + + agentList := make([]any, 0, len(agents)) + for _, agent := range agents { + satisfies := make([]any, 0, len(agent.Satisfies)) + for _, s := range agent.Satisfies { + satisfies = append(satisfies, s) + } + agentList = append(agentList, map[string]any{ + "id": agent.ID, + "displayName": agent.DisplayName, + "emit": agent.Emit, + "satisfies": satisfies, + "bundle": compile.BundleJSON(agent), + }) + } + + return map[string]any{ + "ok": true, + "agents": agentList, + "files": artifacts, + "hash": hash, + "graph": graph, + } +} + +// sanitizeName reduces a requested source-directory name to a safe single +// path segment. +func sanitizeName(name string) string { + var b strings.Builder + for _, r := range name { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '.' { + b.WriteRune(r) + } + } + return strings.Trim(b.String(), ".") +} + +// writeSources resets the work tree and materializes the request's file map +// beneath sourceRoot. +func writeSources(sourceRoot string, files js.Value) error { + if files.Type() != js.TypeObject { + return resource.Errorf("compile request needs a files object") + } + if err := os.RemoveAll(workRoot); err != nil { + return resource.Errorf("cannot reset source root: %s", err) + } + if err := os.MkdirAll(sourceRoot, 0o755); err != nil { + return resource.Errorf("cannot create source root: %s", err) + } + keys := js.Global().Get("Object").Call("keys", files) + for i := 0; i < keys.Length(); i++ { + name := keys.Index(i).String() + clean := path.Clean("/" + name)[1:] + if clean == "" || clean == "." || strings.HasPrefix(clean, "..") { + return resource.Errorf("invalid source path: %s", name) + } + full := filepath.Join(sourceRoot, filepath.FromSlash(clean)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + return resource.Errorf("cannot create directory for %s", name) + } + content := files.Get(name).String() + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + return resource.Errorf("cannot write %s: %s", name, err) + } + } + return nil +} + +// readTree returns every file beneath root keyed by slash-separated path +// relative to root. +func readTree(root string) (map[string]any, error) { + files := map[string]any{} + err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + raw, readErr := os.ReadFile(p) + if readErr != nil { + return readErr + } + rel, relErr := filepath.Rel(root, p) + if relErr != nil { + return relErr + } + files[filepath.ToSlash(rel)] = string(raw) + return nil + }) + if err != nil { + return nil, resource.Errorf("cannot read compiled output: %s", err) + } + return files, nil +} + +// buildGraph extracts the declared composition edges from loaded documents: +// embeds, skill attachments, and capability bindings. +func buildGraph(resources map[string]*resource.Document) map[string]any { + ids := make([]string, 0, len(resources)) + for id := range resources { + ids = append(ids, id) + } + sort.Strings(ids) + + nodes := make([]any, 0, len(ids)) + edges := []any{} + edge := func(from, to, kind string) { + edges = append(edges, map[string]any{"from": from, "to": to, "kind": kind}) + } + for _, id := range ids { + doc := resources[id] + nodes = append(nodes, map[string]any{ + "id": doc.ID, + "kind": doc.Kind, + "displayName": doc.DisplayName, + "emit": doc.Emit, + }) + for _, embedded := range doc.Embeds { + edge(doc.ID, embedded, "embeds") + } + for _, binding := range doc.Skills { + edge(doc.ID, binding.Ref, "skill") + if binding.Capability != nil { + edge(binding.Ref, *binding.Capability, "capability") + } + } + if doc.Kind == "skill" && doc.Binds != "" { + edge(doc.ID, doc.Binds, "binds") + } + for _, capability := range doc.RequiresCapabilities { + edge(doc.ID, capability, "requires") + } + } + return map[string]any{"nodes": nodes, "edges": edges} +} + +// relativizeError strips the in-memory source prefix from diagnostics so the +// playground shows the same paths the user is editing. +func relativizeError(message, sourceRoot string) string { + return strings.ReplaceAll(message, sourceRoot+"/", "") +} diff --git a/web/playground/README.md b/web/playground/README.md new file mode 100644 index 0000000..300e61b --- /dev/null +++ b/web/playground/README.md @@ -0,0 +1,35 @@ +# TypeFerence Playground + +A zero-install, zero-backend playground: the unmodified Go compiler built for +`js/wasm`, running against an in-memory filesystem, recompiling on every edit. +Deployed to GitHub Pages by `.github/workflows/pages.yml`; design rationale in +[ADR-0010](../../docs/decisions/0010-browser-playground.md). + +## Files + +- `index.html`, `style.css`, `app.js` — the UI. No dependencies, no build step. +- `memfs.js` — in-memory implementation of the Node-style `fs` API that Go's + `js/wasm` syscall layer expects. Must load before `wasm_exec.js`. +- Generated by `make playground` (gitignored): + - `typeference.wasm` — the compiler (`go/cmd/typeference-wasm`). + - `wasm_exec.js` — Go's JS support shim, copied from the building + toolchain's `GOROOT` so it always matches the wasm binary. + - `examples.json` — example source trees packed by `go/cmd/playground-pack`. + +## Local development + +```sh +make playground +# then serve this directory with any static file server, e.g.: +python3 -m http.server -d web/playground 8737 +``` + +The page needs no special headers; if your server does not send +`application/wasm`, the loader falls back to non-streaming instantiation. + +## Determinism check + +The status bar digest is `compile.HashDirectory` over the compiled artifact +tree — the same code path as `typeference build`. Loading the Helio example +must reproduce the digest of the committed `dist/`; the maintainer example +must reproduce the repository-root `AGENTS.md` byte for byte. diff --git a/web/playground/app.js b/web/playground/app.js new file mode 100644 index 0000000..3926da3 --- /dev/null +++ b/web/playground/app.js @@ -0,0 +1,591 @@ +// TypeFerence Playground. Vanilla JS, no dependencies: the page loads the +// real Go compiler as WebAssembly (typeference.wasm), gives it an in-memory +// filesystem (memfs.js), and recompiles on every edit. Nothing leaves the tab. +"use strict"; + +/* ---------------------------------------------------------------- state */ + +const state = { + files: new Map(), // path -> content (the editable source tree) + activePath: null, + example: null, // name of the example the tree started from + examples: [], + result: null, // last successful compile result + activeArtifact: null, + activeAgent: null, + ready: false, +}; + +const $ = (id) => document.getElementById(id); +const els = { + exampleSelect: $("example-select"), + reset: $("reset-btn"), + emitArd: $("emit-ard"), + share: $("share-btn"), + theme: $("theme-btn"), + fileList: $("file-list"), + addFile: $("add-file"), + editor: $("editor"), + highlight: document.querySelector("#highlight code"), + highlightPre: $("highlight"), + diagnostics: $("diagnostics"), + outputPane: $("output-pane"), + artifactList: $("artifact-list"), + artifactView: document.querySelector("#artifact-view code"), + graph: $("graph"), + graphLegend: $("graph-legend"), + bundleAgent: $("bundle-agent"), + bundleView: document.querySelector("#bundle-view code"), + status: $("status"), +}; + +/* ---------------------------------------------------------------- theme */ + +function initTheme() { + const saved = localStorage.getItem("tf-theme"); + const preferred = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + document.documentElement.dataset.theme = saved || preferred; + els.theme.addEventListener("click", () => { + const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark"; + document.documentElement.dataset.theme = next; + localStorage.setItem("tf-theme", next); + if (state.result) renderGraph(state.result); // repaint kind colors + }); +} + +/* ------------------------------------------------------------ utilities */ + +const escapeHTML = (s) => + s.replace(/&/g, "&").replace(//g, ">"); + +function toast(message) { + let el = $("toast"); + if (!el) { + el = document.createElement("div"); + el.id = "toast"; + document.body.appendChild(el); + } + el.textContent = message; + el.classList.add("show"); + clearTimeout(el._timer); + el._timer = setTimeout(() => el.classList.remove("show"), 2200); +} + +function setStatus(kind, html) { + els.status.className = kind; + els.status.innerHTML = html; +} + +/* ------------------------------------------------------ syntax coloring */ + +function highlightYAML(text) { + return text.split("\n").map((line) => { + const comment = line.match(/^(\s*)(#.*)$/); + if (comment) return escapeHTML(comment[1]) + `${escapeHTML(comment[2])}`; + const kv = line.match(/^(\s*(?:-\s+)?)([A-Za-z0-9._-]+)(:)( .*|$)/); + if (kv) { + const [, indent, key, colon, rest] = kv; + return `${escapeHTML(indent)}` + + `${escapeHTML(key)}` + + `${colon}` + + highlightYAMLValue(rest); + } + const item = line.match(/^(\s*-\s+)(.*)$/); + if (item) return `${escapeHTML(item[1])}` + highlightYAMLValue(item[2]); + return escapeHTML(line); + }).join("\n"); +} + +function highlightYAMLValue(value) { + const trimmed = value.trim(); + if (/^['"].*['"]$/.test(trimmed) || /^[|>]-?$/.test(trimmed)) { + return `${escapeHTML(value)}`; + } + if (/^-?\d+(\.\d+)?$/.test(trimmed) || trimmed === "true" || trimmed === "false") { + return `${escapeHTML(value)}`; + } + return escapeHTML(value); +} + +function highlightMarkdown(text) { + return text.split("\n").map((line) => { + if (/^#{1,6}\s/.test(line)) return `${escapeHTML(line)}`; + return escapeHTML(line).replace(/`[^`]+`/g, (m) => `${m}`); + }).join("\n"); +} + +function highlightJSON(text) { + let html = ""; + const re = /("(?:[^"\\]|\\.)*")(\s*:)?|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|([{}\[\],:])/g; + let last = 0, m; + while ((m = re.exec(text)) !== null) { + html += escapeHTML(text.slice(last, m.index)); + if (m[1] !== undefined) { + html += `${escapeHTML(m[1])}${m[2] || ""}`; + } else if (m[3] !== undefined) { + html += `${escapeHTML(m[3])}`; + } else { + html += `${escapeHTML(m[4])}`; + } + last = m.index + m[0].length; + } + return html + escapeHTML(text.slice(last)); +} + +function highlightFor(path, text) { + if (path.endsWith(".yaml") || path.endsWith(".yml")) return highlightYAML(text); + if (path.endsWith(".md")) return highlightMarkdown(text); + if (path.endsWith(".json")) return highlightJSON(text); + return escapeHTML(text); +} + +/* --------------------------------------------------------------- editor */ + +function openFile(path) { + state.activePath = path; + els.editor.value = state.files.get(path) ?? ""; + refreshHighlight(); + els.editor.scrollTop = 0; + renderFileList(); +} + +function refreshHighlight() { + // Trailing newline keeps the pre's height in sync with the textarea. + els.highlight.innerHTML = highlightFor(state.activePath || "", els.editor.value) + "\n"; +} + +function initEditor() { + els.editor.addEventListener("input", () => { + if (state.activePath) state.files.set(state.activePath, els.editor.value); + refreshHighlight(); + scheduleCompile(); + }); + els.editor.addEventListener("scroll", () => { + els.highlightPre.scrollTop = els.editor.scrollTop; + els.highlightPre.scrollLeft = els.editor.scrollLeft; + }); + els.editor.addEventListener("keydown", (e) => { + if (e.key === "Tab") { + e.preventDefault(); + const { selectionStart: start, selectionEnd: end, value } = els.editor; + els.editor.value = value.slice(0, start) + " " + value.slice(end); + els.editor.selectionStart = els.editor.selectionEnd = start + 2; + els.editor.dispatchEvent(new Event("input")); + } + }); +} + +/* ------------------------------------------------------------ file pane */ + +function renderFileList() { + els.fileList.innerHTML = ""; + const paths = [...state.files.keys()].sort(); + let currentDir = null; + for (const path of paths) { + const slash = path.lastIndexOf("/"); + const dir = slash < 0 ? "" : path.slice(0, slash); + if (dir !== currentDir) { + currentDir = dir; + if (dir !== "") { + const dirEl = document.createElement("li"); + dirEl.className = "dir"; + dirEl.textContent = dir + "/"; + els.fileList.appendChild(dirEl); + } + } + const li = document.createElement("li"); + li.title = path; + if (path === state.activePath) li.classList.add("active"); + const name = document.createElement("span"); + name.textContent = slash < 0 ? path : path.slice(slash + 1); + li.appendChild(name); + const del = document.createElement("button"); + del.className = "del"; + del.textContent = "✕"; + del.title = `Delete ${path}`; + del.addEventListener("click", (e) => { + e.stopPropagation(); + state.files.delete(path); + if (state.activePath === path) openFile(paths.find((p) => p !== path) ?? null); + renderFileList(); + scheduleCompile(); + }); + li.appendChild(del); + li.addEventListener("click", () => openFile(path)); + els.fileList.appendChild(li); + } +} + +function initFilePane() { + els.addFile.addEventListener("click", () => { + const path = prompt("New file path (e.g. skills/triage.skill.yaml):"); + if (!path || state.files.has(path)) return; + const clean = path.replace(/^\/+/, ""); + const template = clean.endsWith(".md") + ? "# Notes\n" + : "schemaVersion: 3\nkind: skill\nid: acme/skills/new-skill@1.0.0\nbinds: acme/capabilities/change-me@1.0.0\ndescription: Describe the implementation.\ninstructions: |\n Say what this skill does.\n"; + state.files.set(clean, template); + openFile(clean); + scheduleCompile(); + }); +} + +/* -------------------------------------------------------------- compile */ + +let compileTimer = null; +function scheduleCompile() { + clearTimeout(compileTimer); + compileTimer = setTimeout(compileNow, 300); +} + +function compileNow() { + if (!state.ready) return; + const started = performance.now(); + const example = state.examples.find((e) => e.name === state.example); + const request = { + files: Object.fromEntries(state.files), + target: "all", + emitArd: els.emitArd.checked, + publisherDomain: example?.publisherDomain || "playground.example", + sourceName: state.example || "src", + }; + let result; + try { + result = globalThis.TypeFerence.compile(request); + } catch (err) { + result = { ok: false, error: String(err) }; + } + const elapsed = Math.max(1, Math.round(performance.now() - started)); + + if (!result || !result.ok) { + const message = result && result.error ? result.error : "compiler returned no result"; + els.diagnostics.hidden = false; + els.diagnostics.textContent = message; + els.outputPane.classList.add("stale"); + setStatus("error", `compile failed · ${elapsed} ms — details under the editor`); + if (result && result.graph) renderGraph(result); + return; + } + + els.diagnostics.hidden = true; + els.outputPane.classList.remove("stale"); + state.result = result; + const fileCount = Object.keys(result.files).length; + const digest = result.hash.replace(/^sha256:/, "").slice(0, 16); + const emitted = result.agents.filter((a) => a.emit).length; + setStatus("ok", + `${emitted} agent${emitted === 1 ? "" : "s"} · ${fileCount} artifacts · ` + + `SHA-256 ${digest}… · ${elapsed} ms · ` + + `same bytes the CLI produces`); + renderArtifacts(result); + renderGraph(result); + renderBundle(result); +} + +/* ------------------------------------------------------------ artifacts */ + +function renderArtifacts(result) { + const paths = Object.keys(result.files).sort(); + els.artifactList.innerHTML = ""; + let currentDir = null; + for (const path of paths) { + const slash = path.lastIndexOf("/"); + const dir = slash < 0 ? "" : path.slice(0, slash); + if (dir !== currentDir) { + currentDir = dir; + if (dir !== "") { + const dirEl = document.createElement("div"); + dirEl.className = "dir"; + dirEl.textContent = dir + "/"; + els.artifactList.appendChild(dirEl); + } + } + const el = document.createElement("div"); + el.className = "file"; + el.title = path; + el.textContent = slash < 0 ? path : path.slice(slash + 1); + el.addEventListener("click", () => openArtifact(path)); + el.dataset.path = path; + els.artifactList.appendChild(el); + } + if (!state.activeArtifact || !result.files[state.activeArtifact]) { + state.activeArtifact = paths.find((p) => p.endsWith("AGENTS.md")) ?? paths[0]; + } + openArtifact(state.activeArtifact); +} + +function openArtifact(path) { + if (!state.result || !(path in state.result.files)) return; + state.activeArtifact = path; + els.artifactView.innerHTML = highlightFor(path, state.result.files[path]); + for (const el of els.artifactList.querySelectorAll(".file")) { + el.classList.toggle("active", el.dataset.path === path); + } +} + +/* ---------------------------------------------------------------- graph */ + +const KIND_COLORS = { agent: "--kind-agent", profile: "--kind-profile", skill: "--kind-skill", capability: "--kind-capability", interface: "--kind-interface" }; +const EDGE_KIND_COLOR = { embeds: "--text-dim", satisfies: "--kind-interface", skill: "--kind-skill", binds: "--kind-skill", capability: "--kind-capability", requires: "--kind-capability" }; + +const shortName = (id) => { + const noVersion = id.split("@")[0]; + return noVersion.slice(noVersion.lastIndexOf("/") + 1); +}; + +function renderGraph(result) { + const graph = result.graph || { nodes: [], edges: [] }; + const nodes = graph.nodes.map((n) => ({ ...n })); + const edges = graph.edges.map((e) => ({ ...e })); + // Structural interface satisfaction is computed, not declared; show it. + for (const agent of result.agents || []) { + for (const iface of agent.satisfies || []) { + edges.push({ from: agent.id, to: iface, kind: "satisfies" }); + } + } + + const byId = new Map(nodes.map((n) => [n.id, n])); + const validEdges = edges.filter((e) => byId.has(e.from) && byId.has(e.to)); + + // Longest-path layering, top-down. Guard against cycles (they are a + // compile error, but the graph still renders while one exists). + const depth = new Map(nodes.map((n) => [n.id, 0])); + for (let pass = 0; pass < nodes.length + 1; pass++) { + let changed = false; + for (const e of validEdges) { + const want = depth.get(e.from) + 1; + if (want > depth.get(e.to) && want <= nodes.length) { + depth.set(e.to, want); + changed = true; + } + } + if (!changed) break; + } + + const KIND_ORDER = { agent: 0, profile: 1, interface: 2, skill: 3, capability: 4 }; + const rows = []; + for (const node of nodes) { + const d = depth.get(node.id); + (rows[d] = rows[d] || []).push(node); + } + for (const row of rows) { + if (row) row.sort((a, b) => (KIND_ORDER[a.kind] - KIND_ORDER[b.kind]) || a.id.localeCompare(b.id)); + } + + const css = getComputedStyle(document.documentElement); + const color = (v) => css.getPropertyValue(v).trim(); + const charW = 7, padX = 12, nodeH = 40, rowGap = 104, gap = 22; + const pos = new Map(); + let maxRowWidth = 0; + rows.forEach((row) => { + if (!row) return; + const width = row.reduce((w, n) => w + Math.max(90, shortName(n.id).length * charW + padX * 2) + gap, -gap); + maxRowWidth = Math.max(maxRowWidth, width); + }); + const svgWidth = Math.max(560, maxRowWidth + 48); + rows.forEach((row, d) => { + if (!row) return; + const rowWidth = row.reduce((w, n) => w + Math.max(90, shortName(n.id).length * charW + padX * 2) + gap, -gap); + let x = (svgWidth - rowWidth) / 2; + for (const node of row) { + const w = Math.max(90, shortName(node.id).length * charW + padX * 2); + pos.set(node.id, { x, y: 36 + d * rowGap, w, h: nodeH }); + x += w + gap; + } + }); + const svgHeight = 36 + rows.length * rowGap + 20; + + const svgParts = []; + for (const e of validEdges) { + const a = pos.get(e.from), b = pos.get(e.to); + if (!a || !b) continue; + const x1 = a.x + a.w / 2, y1 = a.y + a.h; + const x2 = b.x + b.w / 2, y2 = b.y; + const stroke = color(EDGE_KIND_COLOR[e.kind] || "--text-dim"); + svgParts.push( + `` + + `${escapeHTML(`${e.from} ${e.kind} ${e.to}`)}`, + ); + } + for (const node of nodes) { + const p = pos.get(node.id); + const stroke = color(KIND_COLORS[node.kind] || "--text-dim"); + svgParts.push( + `${escapeHTML(node.id)}` + + `` + + `${node.kind}` + + `${escapeHTML(shortName(node.id))}` + + ``, + ); + } + els.graph.setAttribute("viewBox", `0 0 ${svgWidth} ${svgHeight}`); + els.graph.setAttribute("width", svgWidth); + els.graph.setAttribute("height", svgHeight); + els.graph.innerHTML = svgParts.join(""); + + els.graphLegend.innerHTML = + Object.entries(KIND_COLORS).map(([kind, v]) => + `${kind}`).join("") + + `embeds` + + `satisfies (structural)`; +} + +/* --------------------------------------------------------------- bundle */ + +function renderBundle(result) { + const agents = result.agents || []; + els.bundleAgent.innerHTML = ""; + for (const agent of agents) { + const opt = document.createElement("option"); + opt.value = agent.id; + opt.textContent = agent.id; + els.bundleAgent.appendChild(opt); + } + if (!agents.some((a) => a.id === state.activeAgent)) { + state.activeAgent = agents.length ? agents[0].id : null; + } + if (state.activeAgent) els.bundleAgent.value = state.activeAgent; + showBundle(); +} + +function showBundle() { + const agent = (state.result?.agents || []).find((a) => a.id === state.activeAgent); + els.bundleView.innerHTML = agent ? highlightJSON(agent.bundle) : ""; +} + +/* ----------------------------------------------------------------- tabs */ + +function initTabs() { + for (const button of document.querySelectorAll(".tabs button")) { + button.addEventListener("click", () => { + for (const b of document.querySelectorAll(".tabs button")) b.classList.toggle("active", b === button); + for (const tab of document.querySelectorAll(".tab")) { + tab.classList.toggle("active", tab.id === "tab-" + button.dataset.tab); + } + }); + } + els.bundleAgent.addEventListener("change", () => { + state.activeAgent = els.bundleAgent.value; + showBundle(); + }); +} + +/* ------------------------------------------------------------- examples */ + +function loadExample(name) { + const example = state.examples.find((e) => e.name === name); + if (!example) return; + state.example = name; + state.files = new Map(Object.entries(example.files)); + state.activeArtifact = null; + state.activeAgent = null; + const paths = [...state.files.keys()].sort(); + openFile(paths.find((p) => p.includes("agent")) ?? paths[0]); + scheduleCompile(); +} + +function initExamples() { + for (const example of state.examples) { + const opt = document.createElement("option"); + opt.value = example.name; + opt.textContent = example.title; + opt.title = example.description; + els.exampleSelect.appendChild(opt); + } + els.exampleSelect.addEventListener("change", () => loadExample(els.exampleSelect.value)); + els.reset.addEventListener("click", () => loadExample(els.exampleSelect.value)); + els.emitArd.addEventListener("change", scheduleCompile); +} + +/* ---------------------------------------------------------------- share */ + +const b64url = { + encode: (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))) + .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""), + decode: (s) => Uint8Array.from(atob(s.replace(/-/g, "+").replace(/_/g, "/")), (c) => c.charCodeAt(0)), +}; + +async function shareLink() { + const payload = JSON.stringify({ example: state.example, files: Object.fromEntries(state.files) }); + const stream = new Blob([payload]).stream().pipeThrough(new CompressionStream("gzip")); + const compressed = await new Response(stream).arrayBuffer(); + const url = new URL(location.href); + url.hash = "code=" + b64url.encode(compressed); + history.replaceState(null, "", url); + try { + await navigator.clipboard.writeText(url.href); + toast("Link copied to clipboard"); + } catch { + toast("Link is in the address bar"); + } +} + +async function restoreFromHash() { + const match = location.hash.match(/^#code=(.+)$/); + if (!match) return false; + try { + const stream = new Blob([b64url.decode(match[1])]).stream() + .pipeThrough(new DecompressionStream("gzip")); + const payload = JSON.parse(await new Response(stream).text()); + state.example = payload.example ?? state.examples[0]?.name; + if (state.examples.some((e) => e.name === state.example)) { + els.exampleSelect.value = state.example; + } + state.files = new Map(Object.entries(payload.files)); + const paths = [...state.files.keys()].sort(); + openFile(paths.find((p) => p.includes("agent")) ?? paths[0]); + return true; + } catch { + return false; + } +} + +/* ----------------------------------------------------------------- boot */ + +async function loadCompiler() { + const go = new Go(); + const response = fetch("typeference.wasm"); + let instance; + try { + ({ instance } = await WebAssembly.instantiateStreaming(response, go.importObject)); + } catch { + // Some static hosts serve wasm with a generic MIME type. + const bytes = await (await fetch("typeference.wasm")).arrayBuffer(); + ({ instance } = await WebAssembly.instantiate(bytes, go.importObject)); + } + go.run(instance); // resolves only on exit; main blocks forever + for (let i = 0; i < 200 && !globalThis.TypeFerence; i++) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + if (!globalThis.TypeFerence) throw new Error("compiler did not initialize"); +} + +async function boot() { + initTheme(); + initEditor(); + initFilePane(); + initTabs(); + els.share.addEventListener("click", shareLink); + + try { + const [examples] = await Promise.all([ + fetch("examples.json").then((r) => { + if (!r.ok) throw new Error(`examples.json: HTTP ${r.status}`); + return r.json(); + }), + loadCompiler(), + ]); + state.examples = examples.examples; + } catch (err) { + setStatus("error", `failed to load: ${escapeHTML(String(err))}`); + return; + } + + initExamples(); + state.ready = true; + const restored = await restoreFromHash(); + if (!restored) loadExample(state.examples[0].name); + else scheduleCompile(); +} + +boot(); diff --git a/web/playground/index.html b/web/playground/index.html new file mode 100644 index 0000000..a4935b9 --- /dev/null +++ b/web/playground/index.html @@ -0,0 +1,76 @@ + + + + + + TypeFerence Playground + + + + + + + + +
+
+ +

TypeFerence Playground

+
+ + + + The real Go compiler, in your tab. Nothing you type leaves the browser. + + + + GitHub +
+ +
+ + +
+
+ + +
+ +
+ +
+ +
+ +
+
+
+
+
+
+
+
+ + resolved composition: every field with its provenance +
+
+
+
+
+ +
Loading compiler…
+ + diff --git a/web/playground/memfs.js b/web/playground/memfs.js new file mode 100644 index 0000000..54a445c --- /dev/null +++ b/web/playground/memfs.js @@ -0,0 +1,319 @@ +// In-memory filesystem for the Go js/wasm runtime. +// +// Go's syscall layer on js/wasm delegates every file operation to a global +// `fs` object with Node.js-style callback APIs (see $GOROOT/src/syscall/ +// fs_js.go). This file provides that object backed by a plain in-memory +// tree, so the unmodified TypeFerence compiler can read sources and write +// artifacts entirely inside the browser tab. It must be loaded BEFORE +// wasm_exec.js, which only installs its non-functional stub when no `fs` +// global exists. +// +// Error objects carry a `code` property from Go's errnoByCode table +// (ENOENT, EEXIST, EISDIR, ...); Go panics on codes it does not know. +"use strict"; +(() => { + const decoder = new TextDecoder("utf-8"); + + const S_IFDIR = 0o40000; + const S_IFREG = 0o100000; + + const constants = { + O_RDONLY: 0, + O_WRONLY: 1, + O_RDWR: 2, + O_CREAT: 64, + O_EXCL: 128, + O_TRUNC: 512, + O_APPEND: 1024, + O_DIRECTORY: 65536, + }; + + let nextIno = 1; + const makeDir = () => ({ dir: true, ino: nextIno++, entries: new Map(), mtimeMs: Date.now() }); + const makeFile = () => ({ dir: false, ino: nextIno++, data: new Uint8Array(0), mtimeMs: Date.now() }); + + let root = makeDir(); + + const errno = (code) => { + const err = new Error(code); + err.code = code; + return err; + }; + + // "/a/./b/../c" -> ["a", "c"]; relative paths resolve from "/". + const segmentsOf = (path) => { + const out = []; + for (const part of String(path).split("/")) { + if (part === "" || part === ".") continue; + if (part === "..") out.pop(); + else out.push(part); + } + return out; + }; + + const lookup = (path) => { + let node = root; + for (const segment of segmentsOf(path)) { + if (!node.dir) return null; + node = node.entries.get(segment); + if (!node) return null; + } + return node; + }; + + // Returns [parentDirNode, finalName] or null when an ancestor is missing. + const lookupParent = (path) => { + const segments = segmentsOf(path); + if (segments.length === 0) return null; + let node = root; + for (const segment of segments.slice(0, -1)) { + if (!node.dir) return null; + node = node.entries.get(segment); + if (!node) return null; + } + if (!node.dir) return null; + return [node, segments[segments.length - 1]]; + }; + + const statOf = (node) => ({ + dev: 1, + ino: node.ino, + mode: (node.dir ? S_IFDIR : S_IFREG) | 0o644, + nlink: 1, + uid: 0, + gid: 0, + rdev: 0, + size: node.dir ? 0 : node.data.length, + blksize: 4096, + blocks: node.dir ? 0 : Math.ceil(node.data.length / 512), + atimeMs: node.mtimeMs, + mtimeMs: node.mtimeMs, + ctimeMs: node.mtimeMs, + isDirectory() { return node.dir; }, + isFile() { return !node.dir; }, + isSymbolicLink() { return false; }, + }); + + // Open file descriptors. 0/1/2 are reserved for the standard streams. + const fds = new Map(); + let nextFd = 100; + + // Captured standard output, mirrored to the console line by line. + const output = { stdout: "", stderr: "" }; + const lineBuf = { 1: "", 2: "" }; + const writeStream = (fd, bytes) => { + const text = decoder.decode(bytes); + if (fd === 1) output.stdout += text; + else output.stderr += text; + lineBuf[fd] += text; + let nl; + while ((nl = lineBuf[fd].indexOf("\n")) >= 0) { + const line = lineBuf[fd].slice(0, nl); + lineBuf[fd] = lineBuf[fd].slice(nl + 1); + (fd === 1 ? console.log : console.error)(line); + } + return bytes.length; + }; + + const writeAt = (node, bytes, position) => { + const end = position + bytes.length; + if (end > node.data.length) { + const grown = new Uint8Array(end); + grown.set(node.data); + node.data = grown; + } + node.data.set(bytes, position); + node.mtimeMs = Date.now(); + return bytes.length; + }; + + globalThis.fs = { + constants, + + writeSync(fd, buf) { + if (fd === 1 || fd === 2) return writeStream(fd, buf); + const handle = fds.get(fd); + if (!handle || handle.node.dir) throw errno(fd in fds ? "EISDIR" : "EBADF"); + const n = writeAt(handle.node, buf, handle.pos); + handle.pos += n; + return n; + }, + + write(fd, buf, offset, length, position, callback) { + const bytes = buf.subarray(offset, offset + length); + if (fd === 1 || fd === 2) { + callback(null, writeStream(fd, bytes)); + return; + } + const handle = fds.get(fd); + if (!handle) return callback(errno("EBADF")); + if (handle.node.dir) return callback(errno("EISDIR")); + const at = position === null || position === undefined ? handle.pos : position; + const n = writeAt(handle.node, bytes, at); + if (position === null || position === undefined) handle.pos += n; + callback(null, n); + }, + + read(fd, buffer, offset, length, position, callback) { + const handle = fds.get(fd); + if (!handle) return callback(errno("EBADF")); + if (handle.node.dir) return callback(errno("EISDIR")); + const at = position === null || position === undefined ? handle.pos : position; + const available = Math.max(0, handle.node.data.length - at); + const n = Math.min(length, available); + buffer.set(handle.node.data.subarray(at, at + n), offset); + if (position === null || position === undefined) handle.pos += n; + callback(null, n); + }, + + open(path, flags, mode, callback) { + let node = lookup(path); + if (flags & constants.O_CREAT) { + if (node && flags & constants.O_EXCL) return callback(errno("EEXIST")); + if (!node) { + const parent = lookupParent(path); + if (!parent) return callback(errno("ENOENT")); + node = makeFile(); + parent[0].entries.set(parent[1], node); + } + } + if (!node) return callback(errno("ENOENT")); + if (flags & constants.O_DIRECTORY && !node.dir) return callback(errno("ENOTDIR")); + if (flags & constants.O_TRUNC && !node.dir) { + node.data = new Uint8Array(0); + node.mtimeMs = Date.now(); + } + const fd = nextFd++; + fds.set(fd, { + node, + pos: flags & constants.O_APPEND && !node.dir ? node.data.length : 0, + }); + callback(null, fd); + }, + + close(fd, callback) { + fds.delete(fd); + callback(null); + }, + + stat(path, callback) { + const node = lookup(path); + if (!node) return callback(errno("ENOENT")); + callback(null, statOf(node)); + }, + + lstat(path, callback) { + globalThis.fs.stat(path, callback); + }, + + fstat(fd, callback) { + const handle = fds.get(fd); + if (!handle) return callback(errno("EBADF")); + callback(null, statOf(handle.node)); + }, + + readdir(path, callback) { + const node = lookup(path); + if (!node) return callback(errno("ENOENT")); + if (!node.dir) return callback(errno("ENOTDIR")); + callback(null, [...node.entries.keys()]); + }, + + mkdir(path, perm, callback) { + if (lookup(path)) return callback(errno("EEXIST")); + const parent = lookupParent(path); + if (!parent) return callback(errno("ENOENT")); + parent[0].entries.set(parent[1], makeDir()); + callback(null); + }, + + rmdir(path, callback) { + const parent = lookupParent(path); + const node = parent && parent[0].entries.get(parent[1]); + if (!node) return callback(errno("ENOENT")); + if (!node.dir) return callback(errno("ENOTDIR")); + if (node.entries.size > 0) return callback(errno("ENOTEMPTY")); + parent[0].entries.delete(parent[1]); + callback(null); + }, + + unlink(path, callback) { + const parent = lookupParent(path); + const node = parent && parent[0].entries.get(parent[1]); + if (!node) return callback(errno("ENOENT")); + if (node.dir) return callback(errno("EISDIR")); + parent[0].entries.delete(parent[1]); + callback(null); + }, + + rename(from, to, callback) { + const fromParent = lookupParent(from); + const node = fromParent && fromParent[0].entries.get(fromParent[1]); + if (!node) return callback(errno("ENOENT")); + const toParent = lookupParent(to); + if (!toParent) return callback(errno("ENOENT")); + fromParent[0].entries.delete(fromParent[1]); + toParent[0].entries.set(toParent[1], node); + callback(null); + }, + + truncate(path, length, callback) { + const node = lookup(path); + if (!node) return callback(errno("ENOENT")); + if (node.dir) return callback(errno("EISDIR")); + const data = new Uint8Array(length); + data.set(node.data.subarray(0, Math.min(length, node.data.length))); + node.data = data; + node.mtimeMs = Date.now(); + callback(null); + }, + + ftruncate(fd, length, callback) { + const handle = fds.get(fd); + if (!handle) return callback(errno("EBADF")); + if (handle.node.dir) return callback(errno("EISDIR")); + const data = new Uint8Array(length); + data.set(handle.node.data.subarray(0, Math.min(length, handle.node.data.length))); + handle.node.data = data; + handle.node.mtimeMs = Date.now(); + callback(null); + }, + + utimes(path, atime, mtime, callback) { + const node = lookup(path); + if (!node) return callback(errno("ENOENT")); + node.mtimeMs = mtime * 1000; + callback(null); + }, + + chmod(path, mode, callback) { callback(lookup(path) ? null : errno("ENOENT")); }, + fchmod(fd, mode, callback) { callback(fds.has(fd) ? null : errno("EBADF")); }, + chown(path, uid, gid, callback) { callback(lookup(path) ? null : errno("ENOENT")); }, + fchown(fd, uid, gid, callback) { callback(fds.has(fd) ? null : errno("EBADF")); }, + lchown(path, uid, gid, callback) { callback(lookup(path) ? null : errno("ENOENT")); }, + fsync(fd, callback) { callback(null); }, + link(from, to, callback) { callback(errno("EPERM")); }, + symlink(from, to, callback) { callback(errno("EPERM")); }, + readlink(path, callback) { callback(errno("EINVAL")); }, + }; + + // syscall.Open resolves every opened path through this. + globalThis.path = { + resolve(...parts) { + return "/" + segmentsOf(parts.join("/")).join("/"); + }, + }; + + // Test and debugging surface; the playground UI reads captured output. + globalThis.__memfs = { + output, + reset() { + root = makeDir(); + fds.clear(); + output.stdout = ""; + output.stderr = ""; + lineBuf[1] = ""; + lineBuf[2] = ""; + }, + }; +})(); diff --git a/web/playground/style.css b/web/playground/style.css new file mode 100644 index 0000000..6e9787d --- /dev/null +++ b/web/playground/style.css @@ -0,0 +1,314 @@ +/* TypeFerence Playground — no dependencies, light/dark via custom properties. */ + +:root { + --bg: #f4f5f8; + --panel: #ffffff; + --panel-2: #eef0f4; + --border: #d8dce4; + --text: #1b2330; + --text-dim: #5b6472; + --accent: #4f46e5; + --accent-dim: #eef2ff; + --error: #b91c1c; + --error-bg: #fef2f2; + --ok: #047857; + --sel: #dbe3ff; + /* syntax */ + --syn-key: #7c3aed; + --syn-str: #0f766e; + --syn-num: #b45309; + --syn-comment: #94a3b8; + --syn-punct: #64748b; + /* graph node kinds */ + --kind-agent: #4f46e5; + --kind-profile: #0891b2; + --kind-skill: #059669; + --kind-capability: #d97706; + --kind-interface: #9333ea; +} +[data-theme="dark"] { + --bg: #0d1117; + --panel: #161b22; + --panel-2: #1c2330; + --border: #2d3543; + --text: #e6edf3; + --text-dim: #8b949e; + --accent: #818cf8; + --accent-dim: #1e2242; + --error: #f87171; + --error-bg: #2c1516; + --ok: #34d399; + --sel: #26304d; + --syn-key: #c4b5fd; + --syn-str: #5eead4; + --syn-num: #fbbf24; + --syn-comment: #586374; + --syn-punct: #8b949e; + --kind-agent: #818cf8; + --kind-profile: #22d3ee; + --kind-skill: #34d399; + --kind-capability: #fbbf24; + --kind-interface: #c084fc; +} + +* { box-sizing: border-box; } +html, body { height: 100%; margin: 0; } +body { + display: flex; + flex-direction: column; + font: 14px/1.45 ui-sans-serif, system-ui, "Segoe UI", sans-serif; + background: var(--bg); + color: var(--text); +} +code, pre, textarea, select, .mono { font-family: ui-monospace, "Cascadia Code", Consolas, monospace; } + +/* ---------- header ---------- */ +header { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 14px; + background: var(--panel); + border-bottom: 1px solid var(--border); + flex-wrap: wrap; +} +.brand { display: flex; align-items: center; gap: 9px; } +.brand h1 { font-size: 16px; margin: 0; font-weight: 650; letter-spacing: .1px; } +.brand h1 span { color: var(--accent); font-weight: 500; } +.tagline { color: var(--text-dim); font-size: 12px; } +.spacer { flex: 1; } +.control { display: inline-flex; align-items: center; gap: 6px; color: var(--text-dim); font-size: 12.5px; } +.control select { + background: var(--panel-2); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 4px 6px; + font-size: 12.5px; +} +.control.checkbox input { accent-color: var(--accent); } +button.ghost, a.ghost { + background: none; + border: 1px solid var(--border); + border-radius: 6px; + color: var(--text); + padding: 4px 10px; + font-size: 12.5px; + cursor: pointer; + text-decoration: none; +} +button.ghost:hover, a.ghost:hover { border-color: var(--accent); color: var(--accent); } + +/* ---------- layout ---------- */ +main { + flex: 1; + display: grid; + grid-template-columns: 190px minmax(320px, 43fr) minmax(320px, 57fr); + min-height: 0; +} +@media (max-width: 900px) { + main { grid-template-columns: 150px 1fr; grid-template-rows: 45vh 45vh; } + #output-pane { grid-column: 1 / -1; } +} + +/* ---------- file pane ---------- */ +#file-pane { + background: var(--panel); + border-right: 1px solid var(--border); + overflow-y: auto; + min-height: 0; +} +.pane-head { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 10px 4px; + font-size: 11px; + font-weight: 650; + text-transform: uppercase; + letter-spacing: .8px; + color: var(--text-dim); +} +.pane-head .ghost { padding: 0 7px; font-size: 14px; line-height: 1.4; } +#file-list { list-style: none; margin: 0; padding: 0 0 10px; } +#file-list li { + display: flex; + align-items: center; + justify-content: space-between; + padding: 3px 10px 3px 14px; + font-size: 12.5px; + font-family: ui-monospace, Consolas, monospace; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border-left: 2px solid transparent; +} +#file-list li:hover { background: var(--panel-2); } +#file-list li.active { background: var(--sel); border-left-color: var(--accent); } +#file-list li .del { visibility: hidden; border: 0; background: none; color: var(--text-dim); cursor: pointer; font-size: 12px; padding: 0 2px; } +#file-list li:hover .del { visibility: visible; } +#file-list li .del:hover { color: var(--error); } +#file-list li.dir { + cursor: default; + color: var(--text-dim); + font-size: 11px; + padding-top: 7px; + text-transform: none; + font-family: ui-sans-serif, system-ui, sans-serif; +} +#file-list li.dir:hover { background: none; } + +/* ---------- editor ---------- */ +#editor-pane { display: flex; flex-direction: column; min-width: 0; min-height: 0; border-right: 1px solid var(--border); } +.editor { position: relative; flex: 1; min-height: 0; background: var(--panel); } +.editor pre, .editor textarea { + position: absolute; + inset: 0; + margin: 0; + padding: 12px 14px; + font-size: 13px; + line-height: 1.5; + tab-size: 2; + white-space: pre; + overflow: auto; + border: 0; +} +.editor pre { pointer-events: none; } +.editor pre code { display: block; min-height: 100%; } +.editor textarea { + width: 100%; + height: 100%; + resize: none; + background: transparent; + color: transparent; + caret-color: var(--text); + outline: none; + overflow: auto; +} +.editor textarea::selection { background: var(--sel); color: transparent; } + +#diagnostics { + border-top: 1px solid var(--border); + background: var(--error-bg); + color: var(--error); + padding: 8px 14px; + font-size: 12.5px; + font-family: ui-monospace, Consolas, monospace; + white-space: pre-wrap; + max-height: 30%; + overflow-y: auto; +} + +/* ---------- output pane ---------- */ +#output-pane { display: flex; flex-direction: column; min-width: 0; min-height: 0; background: var(--panel); } +.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); padding: 0 8px; background: var(--panel); } +.tabs button { + background: none; + border: 0; + border-bottom: 2px solid transparent; + color: var(--text-dim); + padding: 8px 12px; + font-size: 13px; + cursor: pointer; +} +.tabs button:hover { color: var(--text); } +.tabs button.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; } +.tab { display: none; flex: 1; min-height: 0; } +.tab.active { display: flex; } +#output-pane.stale .tab { opacity: .45; } + +#tab-artifacts { display: none; } +#tab-artifacts.active { display: flex; } +#artifact-list { + width: 240px; + overflow-y: auto; + border-right: 1px solid var(--border); + padding: 6px 0; + flex-shrink: 0; +} +#artifact-list .dir { padding: 6px 10px 2px; font-size: 11px; color: var(--text-dim); } +#artifact-list .file { + padding: 2px 10px 2px 18px; + font-size: 12px; + font-family: ui-monospace, Consolas, monospace; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + border-left: 2px solid transparent; +} +#artifact-list .file:hover { background: var(--panel-2); } +#artifact-list .file.active { background: var(--sel); border-left-color: var(--accent); } +#artifact-view, #bundle-view { + flex: 1; + margin: 0; + padding: 12px 14px; + overflow: auto; + font-size: 12.5px; + line-height: 1.5; + min-width: 0; +} + +#tab-graph { flex-direction: column; } +#graph-legend { display: flex; gap: 14px; flex-wrap: wrap; padding: 8px 14px; font-size: 12px; color: var(--text-dim); border-bottom: 1px solid var(--border); } +#graph-legend .swatch { display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-right: 5px; vertical-align: -1px; } +#graph-legend .edge-sample { display: inline-block; width: 22px; height: 2px; margin-right: 5px; vertical-align: 3px; } +#graph-scroll { flex: 1; overflow: auto; } +#graph { display: block; margin: 0 auto; } +#graph .node rect { rx: 7px; fill: var(--panel-2); stroke-width: 1.6px; } +#graph .node text { font: 11.5px ui-monospace, Consolas, monospace; fill: var(--text); } +#graph .node .kind-label { font-size: 9px; fill: var(--text-dim); text-transform: uppercase; letter-spacing: .5px; } +#graph .edge { fill: none; stroke-width: 1.4px; opacity: .75; } +#graph .edge.satisfies { stroke-dasharray: 5 4; } +#graph .edge.skill, #graph .edge.binds, #graph .edge.capability, #graph .edge.requires { stroke-dasharray: 2 3; } +#graph .node:hover rect { fill: var(--sel); } + +#tab-bundle { flex-direction: column; } +.bundle-head { display: flex; align-items: center; gap: 12px; padding: 8px 14px; border-bottom: 1px solid var(--border); } +.hint { color: var(--text-dim); font-size: 12px; } + +/* ---------- status bar ---------- */ +footer { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 14px; + border-top: 1px solid var(--border); + background: var(--panel); + font-size: 12.5px; + color: var(--text-dim); + min-height: 30px; +} +footer.ok::before { content: "✓"; color: var(--ok); font-weight: 700; } +footer.error { color: var(--error); } +footer.error::before { content: "✕"; font-weight: 700; } +footer.loading::before { content: "…"; } +footer .digest { font-family: ui-monospace, Consolas, monospace; color: var(--text-dim); } +footer .digest b { color: var(--text); font-weight: 600; } + +/* ---------- syntax highlighting ---------- */ +.tok-key { color: var(--syn-key); } +.tok-str { color: var(--syn-str); } +.tok-num { color: var(--syn-num); } +.tok-comment { color: var(--syn-comment); font-style: italic; } +.tok-punct { color: var(--syn-punct); } +.tok-head { color: var(--syn-key); font-weight: 600; } + +/* toast */ +#toast { + position: fixed; + bottom: 46px; + left: 50%; + transform: translateX(-50%); + background: var(--text); + color: var(--bg); + border-radius: 8px; + padding: 8px 16px; + font-size: 13px; + opacity: 0; + transition: opacity .25s; + pointer-events: none; + z-index: 10; +} +#toast.show { opacity: .95; } From ce344e73cf0b421b16ff3a99fbc30d7bcafb03cb Mon Sep 17 00:00:00 2001 From: Andrew Buchkovich Date: Mon, 13 Jul 2026 22:31:40 -0400 Subject: [PATCH 2/4] wip(playground): BYOK Run tab scaffolding + pivot decision to BETH console Working, browser-verified bring-your-own-key Run tab (Anthropic, OpenAI Responses API, Gemini, GitHub Models; SSE streaming verified against synthetic wire-format streams; error paths verified against live endpoints; keys memory-only, opt-in persistence, CSP pinning connect-src). Kept as scaffolding only: ADR-0011 records the decision to replace it with a no-secrets BETH operator console (pack in wasm, copy/paste collection per ADR-0009's operator model, tar.gz export, local scoring, scorecard visualization) before this branch merges. Also captures the typed-context-shapes idea as a design note (docs/design-notes/) for a future ADR. Co-Authored-By: Claude Fable 5 --- docs/decisions/0011-playground-live-runs.md | 84 ++++++++ docs/decisions/README.md | 1 + docs/design-notes/typed-context-shapes.md | 48 +++++ web/playground/app.js | 177 +++++++++++++++++ web/playground/index.html | 30 +++ web/playground/providers.js | 201 ++++++++++++++++++++ web/playground/style.css | 96 ++++++++++ 7 files changed, 637 insertions(+) create mode 100644 docs/decisions/0011-playground-live-runs.md create mode 100644 docs/design-notes/typed-context-shapes.md create mode 100644 web/playground/providers.js diff --git a/docs/decisions/0011-playground-live-runs.md b/docs/decisions/0011-playground-live-runs.md new file mode 100644 index 0000000..66fe3fe --- /dev/null +++ b/docs/decisions/0011-playground-live-runs.md @@ -0,0 +1,84 @@ +# 0011 — Playground equivalence console: no secrets in the browser + +## Status + +Accepted. Extends ADR-0010; implementation in progress on +`feature/playground-run` (an interim bring-your-own-key Run tab exists on +that branch as scaffolding and will be replaced before merge). + +## Context + +The playground (ADR-0010) shows what TypeFerence compiles but not what the +compiled definition *does*. The natural extension — fire the compiled agent +at real models and compare — is also the project's central question: BETH +(ADR-0009) exists because the same declared intent, run on different hosts, +may or may not behave equivalently. + +A first implementation did this with bring-your-own-key adapters: paste an +Anthropic/OpenAI/Gemini/GitHub Models key into the page, requests go directly +from the browser to the provider, keys never persist unless opted in, and a +CSP pins `connect-src` to the four provider hosts. It worked, and it was +still wrong. Asking visitors to paste provider credentials into a hosted page +is exactly the anti-pattern this project's trust posture argues against: the +"keys never leave your browser" claim is unverifiable without auditing the +served JavaScript on every load, and one compromised deploy would turn the +page into a key harvester. A project whose pitch is provenance and +fail-closed trust should not train users to paste secrets into web pages. + +There is also no real OAuth escape hatch: no target provider offers +third-party OAuth for API access, GitHub Copilot has no public API (and the +token exchange unofficial clients use violates GitHub's terms), and a backend +proxy would route other people's keys and prompts through project +infrastructure — strictly worse. + +## Decision + +The playground never asks for a secret. The Run surface becomes a **BETH +operator console** that keeps every credential in the user's own terminal: + +1. **Pack in the browser.** The wasm bridge grows a `pack` entry point over + the existing `eval.Pack`, laying out scenario × surface cells for the + compiled definition (scenario files ship with the examples). +2. **Collect by copy and paste.** Each cell renders as a card: copy the + prompt, run it in a real host (Claude Code, Copilot, Cursor, a raw API + call — the operator's choice, with the operator's own keys), paste the + response back. This is not a workaround; a human operator collecting one + attested response per cell is BETH's defined collection model (ADR-0009). +3. **Export the run.** The console assembles the pasted responses into the + run-directory layout and downloads it as a `.tar.gz` (dependency-free tar + writer plus the browser's native gzip `CompressionStream`). +4. **Score locally.** The console prints the exact + `typeference equivalence score --live` command; the judge key lives + in the local environment, where keys belong. +5. **Visualize the scorecard.** Dropping the resulting `scorecard.json` back + onto the page renders adherence per surface, cross-surface agreement, and + every divergence. Reading a local file requires no trust. + +## Consequences + +- The full BETH loop — author, compile, pack, collect, score, inspect — + becomes walkable from a browser tab, and the only step that touches a + credential happens in the user's terminal against the provider's own CLI + or API. +- The interim BYOK adapters (`providers.js`) and key UI are deleted before + merge; the verified per-provider transport knowledge they encode (CORS + behavior, SSE wire formats) is preserved in this ADR's history should a + legitimate need return. +- The console must not blur illustration into measurement: it emits and + consumes BETH's own run-directory and scorecard shapes rather than + inventing a parallel result format. +- Manual paste-back limits collection volume. That is BETH's existing, + documented trade-off (one observation per surface, operator-attested), not + a new one. + +## Alternatives considered + +- **Bring-your-own-key direct calls** (built, then rejected): unverifiable + trust ask; normalizes pasting API keys into web pages; one bad deploy from + being harmful. +- **OAuth/SSO to providers**: not offered for third-party API access by any + target provider. +- **A backend proxy**: centralizes other people's keys and prompts; worse on + every axis this project cares about. +- **Keeping BYOK behind an "advanced" toggle**: still trains the behavior; + the honest console makes the same demo possible without it. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 5cf4a86..d4ecfd5 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -19,3 +19,4 @@ Format: `NNNN-short-title.md` with sections **Status**, **Context**, **Decision* | [0008](0008-eval-harness-scope.md) | Behavioral eval harness: scope and design | | [0009](0009-behavioral-equivalence-harness.md) | BETH: the behavioral equivalence test harness | | [0010](0010-browser-playground.md) | Browser playground: the Go compiler as WebAssembly | +| [0011](0011-playground-live-runs.md) | Playground equivalence console: no secrets in the browser | diff --git a/docs/design-notes/typed-context-shapes.md b/docs/design-notes/typed-context-shapes.md new file mode 100644 index 0000000..b86f752 --- /dev/null +++ b/docs/design-notes/typed-context-shapes.md @@ -0,0 +1,48 @@ +# Design note: typed context shapes + +Status: idea captured 2026-07-13, not designed, not scheduled. A future ADR +decides; this note only pins the problem and the hook points so the thought +survives. + +## Problem + +v3 disciplines composition: profiles, capabilities, skills, and overrides are +typed, versioned, promoted with ambiguity checks, and carry provenance. But +`contextFiles` are opaque markdown. Everything the type system squeezed out of +instruction files can silently migrate into referenced context files — +org charts, timelines, policies, and de-facto behavioral overrides living as +free prose that no compile-time check can see. The sprawl TypeFerence exists +to eliminate does not disappear; it relocates to the one untyped surface. + +## Gesture at a solution + +1. **Governable context types.** A small starter registry of shapes a context + file can declare itself to be — e.g. `cast-of-characters` (roster: names, + roles, authority), `timeline` (ordered events; **decision points** as + first-class entries on a timeline, not a separate type), `glossary`, + `policy` (addressable clauses). Declaring a type is optional; *using* one + is strict: the compiler validates the file against the shape and fails + closed on drift, same as every other v3 check. +2. **Extensible registry, strict instances.** Do not enumerate the world's + context types — runtime-adjacent shapes will keep appearing. Make adding a + type cheap (a shape schema is itself a versioned resource a source tree + can carry), but make conformance to a chosen type non-negotiable. Strict + about use, liberal about the universe. +3. **Overrides belong in YAML, not prose.** If an agent deviates from an + embedded profile's behavior, that override should be a declared member on + the typed resource — baked into the compiled skill/bundle with provenance — + never a paragraph in a context file that quietly contradicts what the + profile promoted. Possible compile-time stance: typed context files simply + have no vocabulary for behavioral instruction, which is what makes them + governable. + +## Hook points when this becomes real + +- Loader/validation: `resource.validateShape` and friends already own + referenced-file checks; typed context validation slots beside them. +- Spec: a "Typed context" section; canonical form questions (ordering, + formatting) get the ADR-0004 treatment so digests stay stable. +- Conformance: new fixtures per shape, including failure fixtures. +- ARD: each context type maps naturally to a media type on published entries. +- Self-hosting: `agents/maintainer/context/*` is the first test corpus — + ADR-0006 already catalogs where its prose is doing type-system work. diff --git a/web/playground/app.js b/web/playground/app.js index 3926da3..d234d1c 100644 --- a/web/playground/app.js +++ b/web/playground/app.js @@ -37,6 +37,12 @@ const els = { bundleAgent: $("bundle-agent"), bundleView: document.querySelector("#bundle-view code"), status: $("status"), + runAgent: $("run-agent"), + runSystemView: $("run-system-view"), + runMessage: $("run-message"), + runBtn: $("run-btn"), + rememberKeys: $("remember-keys"), + providerCards: $("provider-cards"), }; /* ---------------------------------------------------------------- theme */ @@ -280,6 +286,7 @@ function compileNow() { renderArtifacts(result); renderGraph(result); renderBundle(result); + renderRun(result); } /* ------------------------------------------------------------ artifacts */ @@ -453,6 +460,174 @@ function showBundle() { els.bundleView.innerHTML = agent ? highlightJSON(agent.bundle) : ""; } +/* ------------------------------------------------------------------ run */ +// Fire the compiled agent at real providers, side by side. Bring-your-own- +// key: requests go straight from this page to the provider; see providers.js. + +const keyStore = new Map(); // provider name -> API key (page memory) +let runAbort = null; +let messageDirty = false; + +const DEFAULT_MESSAGES = { + starter: "Ticket #4812: a Classic-model widget arrived without the mounting bracket. Customer wants a refund. Summarize and propose the next action.", + helio: "Give me a status update on the payments repository ahead of the release cut.", + maintainer: "A contributor's PR changes canonical JSON escaping in the Go implementation only. What has to happen before it can merge?", +}; + +const agentLeaf = (id) => { + const noVersion = id.split("@")[0]; + return noVersion.slice(noVersion.lastIndexOf("/") + 1); +}; + +function systemPromptFor(agentId) { + if (!state.result) return ""; + return state.result.files[`neutral/${agentLeaf(agentId)}/AGENTS.md`] ?? ""; +} + +function renderRun(result) { + const emitted = (result.agents || []).filter((a) => a.emit); + const previous = els.runAgent.value; + els.runAgent.innerHTML = ""; + for (const agent of emitted) { + const opt = document.createElement("option"); + opt.value = agent.id; + opt.textContent = agent.id; + els.runAgent.appendChild(opt); + } + if (emitted.some((a) => a.id === previous)) els.runAgent.value = previous; + if (!messageDirty) { + els.runMessage.value = DEFAULT_MESSAGES[state.example] ?? ""; + } + refreshRunSystem(); +} + +function refreshRunSystem() { + els.runSystemView.textContent = systemPromptFor(els.runAgent.value) || "(compile a source tree with at least one emitted agent)"; +} + +function loadStoredKeys() { + try { + if (localStorage.getItem("tf-remember-keys") !== "1") return; + els.rememberKeys.checked = true; + const stored = JSON.parse(localStorage.getItem("tf-keys") || "{}"); + for (const [name, key] of Object.entries(stored)) keyStore.set(name, key); + } catch { /* corrupted storage is not worth surfacing */ } +} + +function persistKeys() { + if (els.rememberKeys.checked) { + localStorage.setItem("tf-remember-keys", "1"); + localStorage.setItem("tf-keys", JSON.stringify(Object.fromEntries(keyStore))); + } else { + localStorage.removeItem("tf-remember-keys"); + localStorage.removeItem("tf-keys"); + } +} + +function initRun() { + loadStoredKeys(); + for (const [name, provider] of Object.entries(PROVIDERS)) { + const card = document.createElement("div"); + card.className = "provider-card disabled"; + card.dataset.provider = name; + card.innerHTML = ` +
+ + ${provider.label} + +
+
+
+ + get key +
+ + +
+
`; + const enable = card.querySelector(".provider-head input"); + const keyInput = card.querySelector(".key"); + if (keyStore.has(name)) { + keyInput.value = keyStore.get(name); + enable.checked = true; + card.classList.remove("disabled"); + } + enable.addEventListener("change", () => card.classList.toggle("disabled", !enable.checked)); + keyInput.addEventListener("change", async () => { + keyStore.set(name, keyInput.value.trim()); + persistKeys(); + if (!keyInput.value.trim()) return; + enable.checked = true; + card.classList.remove("disabled"); + try { + const models = await provider.listModels(keyInput.value.trim()); + card.querySelector(`#models-${name}`).innerHTML = + models.map((m) => `