From ec4e872f7a55fb2fc0b278dfef5ba3ce60816ec5 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Tue, 25 Aug 2026 13:19:59 -0700 Subject: [PATCH 001/124] Rename Workbench to Workshop (wb to ws) Rename the desktop app from Workbench to Workshop across the whole workspace. The crates promptforge-wb and promptforge-wb-server become promptforge-ws and promptforge-ws-server: directory moves, package and binary names, the workspace dependency table, Cargo.lock, and the library path promptforge_ws_server. CI renames the check-workbench job to check-workshop and updates every --exclude flag. The example config workbench.example.toml becomes workshop.example.toml, the two design documents are renamed to match, and the UI modules workbench-provider.ts and workbench-socket.ts become workshop-provider.ts and workshop-socket.ts with their WorkshopProvider and WorkshopSocket classes. Prose sweeps cover the READMEs, doc comments, .gitignore comments, the gateway user guide, and UI comments and error strings. Most files move at 100 percent similarity; where content changed, the edits are confined to name strings. - The WebSocket surface already used the ws abbreviation before this rename and is untouched: the GET /ws route and the chat_ws module mean WebSocket, not Workshop. The new -ws crate suffix collides with that abbreviation knowingly. - Config discovery keeps a migration path: the server binary reads workshop.toml from the current directory and falls back to a leftover workbench.toml when the canonical name is missing; the desktop shell tries workshop.toml then workbench.toml at each of its three search locations (beside the executable, the current directory, the profile directory), with a regression test. First-run generation writes only workshop.toml. - The old promptforge-wb README shows as a delete plus a new promptforge-ws README rather than a rename, because the density of name changes pushed it below git's similarity threshold. --- .github/workflows/ci.yml | 24 ++--- .gitignore | 10 +- Cargo.lock | 6 +- Cargo.toml | 4 +- README.md | 6 +- .../user-guide-promptforge-gateway.md | 2 +- crates/promptforge-wb/README.md | 33 ------ .../Cargo.toml | 6 +- .../README.md | 24 ++--- .../build.rs | 2 +- .../src/app.rs | 8 +- .../src/catalog.rs | 0 .../src/chat_ws.rs | 2 +- .../src/config.rs | 35 +++--- .../src/gateway.rs | 6 +- .../src/heartbeat.rs | 0 .../src/lib.rs | 4 +- .../src/main.rs | 24 +++-- .../src/provision.rs | 36 +++---- .../src/segment.rs | 0 .../src/serve.rs | 16 +-- .../src/status.rs | 0 .../src/tape.rs | 6 +- .../src/transcribe.rs | 2 +- .../src/voice.rs | 2 +- .../ui/build.mjs | 0 .../ui/index.html | 0 .../ui/package-lock.json | 4 +- .../ui/package.json | 4 +- .../ui/pcm-worklet.js | 0 .../ui/src/chat/LICENSE | 0 .../ui/src/chat/PROVENANCE.md | 2 +- .../ui/src/chat/components/dropdown.ts | 0 .../ui/src/chat/components/feed-items.ts | 0 .../ui/src/chat/components/feed-node.ts | 0 .../ui/src/chat/components/feed.ts | 0 .../ui/src/chat/components/header.ts | 0 .../ui/src/chat/components/input.ts | 0 .../ui/src/chat/components/message-node.ts | 0 .../ui/src/chat/components/sidebar.ts | 0 .../ui/src/chat/core/chat-engine.ts | 0 .../ui/src/chat/core/msg-utils.ts | 0 .../ui/src/chat/core/providers/openai.ts | 0 .../ui/src/chat/core/session-manager.ts | 0 .../ui/src/chat/core/storage/indexed-db.ts | 0 .../ui/src/chat/core/storage/remote.ts | 0 .../ui/src/chat/core/store.ts | 0 .../ui/src/chat/core/stream-reducer.ts | 0 .../ui/src/chat/core/types.ts | 0 .../chat/highlighter/.vendor-manifest.json | 0 .../chat/highlighter/THIRD_PARTY_NOTICES.md | 0 .../ui/src/chat/highlighter/chat.ts | 0 .../ui/src/chat/highlighter/core.ts | 0 .../ui/src/chat/highlighter/index.ts | 0 .../ui/src/chat/highlighter/languages/bash.ts | 0 .../ui/src/chat/highlighter/languages/c.ts | 0 .../src/chat/highlighter/languages/clike.ts | 0 .../ui/src/chat/highlighter/languages/cpp.ts | 0 .../src/chat/highlighter/languages/csharp.ts | 0 .../ui/src/chat/highlighter/languages/diff.ts | 0 .../chat/highlighter/languages/dockerfile.ts | 0 .../ui/src/chat/highlighter/languages/go.ts | 0 .../src/chat/highlighter/languages/graphql.ts | 0 .../src/chat/highlighter/languages/index.ts | 0 .../ui/src/chat/highlighter/languages/java.ts | 0 .../chat/highlighter/languages/javascript.ts | 0 .../ui/src/chat/highlighter/languages/json.ts | 0 .../src/chat/highlighter/languages/kotlin.ts | 0 .../chat/highlighter/languages/markdown.ts | 0 .../src/chat/highlighter/languages/markup.ts | 0 .../ui/src/chat/highlighter/languages/php.ts | 0 .../src/chat/highlighter/languages/python.ts | 0 .../ui/src/chat/highlighter/languages/ruby.ts | 0 .../ui/src/chat/highlighter/languages/rust.ts | 0 .../src/chat/highlighter/languages/shared.ts | 0 .../ui/src/chat/highlighter/languages/sql.ts | 0 .../src/chat/highlighter/languages/swift.ts | 0 .../ui/src/chat/highlighter/languages/toml.ts | 0 .../chat/highlighter/languages/typescript.ts | 0 .../ui/src/chat/highlighter/languages/yaml.ts | 0 .../ui/src/chat/highlighter/theme.css | 0 .../ui/src/chat/index.ts | 0 .../ui/src/chat/main.ts | 0 .../agent-thinking/agent-thinking-plugin.ts | 0 .../plugins/agent-thinking/agent-thinking.css | 0 .../plugins/attachment/attachment-plugin.ts | 0 .../chat/plugins/attachment/attachment.css | 0 .../ui/src/chat/plugins/copy/copy-plugin.ts | 0 .../ui/src/chat/plugins/edit/edit-plugin.ts | 0 .../ui/src/chat/plugins/edit/edit.css | 0 .../chat/plugins/settings/settings-plugin.ts | 0 .../ui/src/chat/plugins/settings/settings.css | 0 .../chat/plugins/thinking/thinking-plugin.ts | 0 .../ui/src/chat/plugins/thinking/thinking.css | 0 .../ui/src/chat/plugins/tools/tools-plugin.ts | 0 .../ui/src/chat/plugins/tools/tools.css | 0 .../ui/src/chat/router.ts | 0 .../ui/src/chat/styles/base.css | 0 .../ui/src/chat/styles/css.d.ts | 0 .../ui/src/chat/styles/dropdown.css | 0 .../ui/src/chat/styles/feed.css | 0 .../ui/src/chat/styles/input.css | 0 .../ui/src/chat/styles/sidebar.css | 0 .../ui/src/chat/utils/device.ts | 0 .../ui/src/chat/utils/dom.ts | 0 .../ui/src/chat/utils/html.ts | 0 .../ui/src/chat/utils/icons.ts | 0 .../ui/src/chat/utils/sse.ts | 0 .../ui/src/chat/utils/uuid.ts | 0 .../ui/src/chat/with-css.ts | 0 .../ui/src/main.ts | 18 ++-- .../ui/src/memory-storage.ts | 0 .../ui/src/status-bar.ts | 2 +- .../ui/src/voice.ts | 0 .../ui/src/workshop-provider.ts} | 10 +- .../ui/src/workshop-socket.ts} | 10 +- .../ui/style.css | 12 +-- .../ui/test/smoke.mjs | 0 .../ui/tsconfig.json | 0 .../Cargo.toml | 10 +- crates/promptforge-ws/README.md | 33 ++++++ .../src/discover.rs | 60 ++++++++--- .../src/health.rs | 2 +- .../src/main.rs | 22 ++-- .../src/window.rs | 6 +- ...ench.md => design-promptforge-workshop.md} | 48 ++++----- ...rge-wb-1.md => design-promptforge-ws-1.md} | 102 +++++++++--------- ...ench.example.toml => workshop.example.toml | 12 +-- 128 files changed, 324 insertions(+), 291 deletions(-) delete mode 100644 crates/promptforge-wb/README.md rename crates/{promptforge-wb-server => promptforge-ws-server}/Cargo.toml (84%) rename crates/{promptforge-wb-server => promptforge-ws-server}/README.md (82%) rename crates/{promptforge-wb-server => promptforge-ws-server}/build.rs (98%) rename crates/{promptforge-wb-server => promptforge-ws-server}/src/app.rs (99%) rename crates/{promptforge-wb-server => promptforge-ws-server}/src/catalog.rs (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/src/chat_ws.rs (99%) rename crates/{promptforge-wb-server => promptforge-ws-server}/src/config.rs (95%) rename crates/{promptforge-wb-server => promptforge-ws-server}/src/gateway.rs (99%) rename crates/{promptforge-wb-server => promptforge-ws-server}/src/heartbeat.rs (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/src/lib.rs (89%) rename crates/{promptforge-wb-server => promptforge-ws-server}/src/main.rs (55%) rename crates/{promptforge-wb-server => promptforge-ws-server}/src/provision.rs (97%) rename crates/{promptforge-wb-server => promptforge-ws-server}/src/segment.rs (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/src/serve.rs (95%) rename crates/{promptforge-wb-server => promptforge-ws-server}/src/status.rs (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/src/tape.rs (98%) rename crates/{promptforge-wb-server => promptforge-ws-server}/src/transcribe.rs (99%) rename crates/{promptforge-wb-server => promptforge-ws-server}/src/voice.rs (99%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/build.mjs (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/index.html (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/package-lock.json (99%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/package.json (71%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/pcm-worklet.js (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/LICENSE (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/PROVENANCE.md (93%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/components/dropdown.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/components/feed-items.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/components/feed-node.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/components/feed.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/components/header.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/components/input.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/components/message-node.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/components/sidebar.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/core/chat-engine.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/core/msg-utils.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/core/providers/openai.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/core/session-manager.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/core/storage/indexed-db.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/core/storage/remote.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/core/store.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/core/stream-reducer.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/core/types.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/highlighter/.vendor-manifest.json (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/highlighter/THIRD_PARTY_NOTICES.md (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/highlighter/chat.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/highlighter/core.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/highlighter/index.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/highlighter/languages/bash.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/highlighter/languages/c.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/highlighter/languages/clike.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/highlighter/languages/cpp.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/highlighter/languages/csharp.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/highlighter/languages/diff.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/highlighter/languages/dockerfile.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/highlighter/languages/go.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/highlighter/languages/graphql.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/highlighter/languages/index.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/highlighter/languages/java.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/highlighter/languages/javascript.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/highlighter/languages/json.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/highlighter/languages/kotlin.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/highlighter/languages/markdown.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/highlighter/languages/markup.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/highlighter/languages/php.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/highlighter/languages/python.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/highlighter/languages/ruby.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/highlighter/languages/rust.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/highlighter/languages/shared.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/highlighter/languages/sql.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/highlighter/languages/swift.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/highlighter/languages/toml.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/highlighter/languages/typescript.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/highlighter/languages/yaml.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/highlighter/theme.css (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/index.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/main.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/plugins/agent-thinking/agent-thinking-plugin.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/plugins/agent-thinking/agent-thinking.css (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/plugins/attachment/attachment-plugin.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/plugins/attachment/attachment.css (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/plugins/copy/copy-plugin.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/plugins/edit/edit-plugin.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/plugins/edit/edit.css (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/plugins/settings/settings-plugin.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/plugins/settings/settings.css (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/plugins/thinking/thinking-plugin.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/plugins/thinking/thinking.css (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/plugins/tools/tools-plugin.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/plugins/tools/tools.css (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/router.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/styles/base.css (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/styles/css.d.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/styles/dropdown.css (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/styles/feed.css (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/styles/input.css (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/styles/sidebar.css (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/utils/device.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/utils/dom.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/utils/html.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/utils/icons.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/utils/sse.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/utils/uuid.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/chat/with-css.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/main.ts (92%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/memory-storage.ts (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/status-bar.ts (98%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/src/voice.ts (100%) rename crates/{promptforge-wb-server/ui/src/workbench-provider.ts => promptforge-ws-server/ui/src/workshop-provider.ts} (86%) rename crates/{promptforge-wb-server/ui/src/workbench-socket.ts => promptforge-ws-server/ui/src/workshop-socket.ts} (96%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/style.css (97%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/test/smoke.mjs (100%) rename crates/{promptforge-wb-server => promptforge-ws-server}/ui/tsconfig.json (100%) rename crates/{promptforge-wb => promptforge-ws}/Cargo.toml (66%) create mode 100644 crates/promptforge-ws/README.md rename crates/{promptforge-wb => promptforge-ws}/src/discover.rs (74%) rename crates/{promptforge-wb => promptforge-ws}/src/health.rs (97%) rename crates/{promptforge-wb => promptforge-ws}/src/main.rs (76%) rename crates/{promptforge-wb => promptforge-ws}/src/window.rs (92%) rename design/{design-promptforge-workbench.md => design-promptforge-workshop.md} (81%) rename design/{design-promptforge-wb-1.md => design-promptforge-ws-1.md} (90%) rename workbench.example.toml => workshop.example.toml (88%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e669b594..6cb2bdfc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,20 +26,20 @@ jobs: run: cargo fmt --all --check - name: Clippy - run: cargo clippy --workspace --exclude promptforge-wb --exclude promptforge-wb-server --all-targets --all-features -- -D warnings + run: cargo clippy --workspace --exclude promptforge-ws --exclude promptforge-ws-server --all-targets --all-features -- -D warnings - name: Test - run: cargo test --locked --workspace --exclude promptforge-wb --exclude promptforge-wb-server --all-features + run: cargo test --locked --workspace --exclude promptforge-ws --exclude promptforge-ws-server --all-features - name: Doctests - run: cargo test --workspace --exclude promptforge-wb --exclude promptforge-wb-server --doc + run: cargo test --workspace --exclude promptforge-ws --exclude promptforge-ws-server --doc - name: Docs env: RUSTDOCFLAGS: -D warnings - run: cargo doc --workspace --no-deps --all-features --exclude promptforge-wb --exclude promptforge-wb-server + run: cargo doc --workspace --no-deps --all-features --exclude promptforge-ws --exclude promptforge-ws-server - check-workbench: + check-workshop: runs-on: windows-latest steps: - uses: actions/checkout@v4 @@ -56,14 +56,14 @@ jobs: node-version: 22 - name: Install UI dependencies - working-directory: crates/promptforge-wb-server/ui + working-directory: crates/promptforge-ws-server/ui run: npm ci - - name: Clippy (workbench) - run: cargo clippy -p promptforge-wb -p promptforge-wb-server --all-targets -- -D warnings + - name: Clippy (workshop) + run: cargo clippy -p promptforge-ws -p promptforge-ws-server --all-targets -- -D warnings - - name: Test (workbench) - run: cargo test --locked -p promptforge-wb -p promptforge-wb-server + - name: Test (workshop) + run: cargo test --locked -p promptforge-ws -p promptforge-ws-server msrv: runs-on: ubuntu-latest @@ -77,8 +77,8 @@ jobs: - name: Build and test on MSRV run: | - cargo build --locked --workspace --exclude promptforge-wb --exclude promptforge-wb-server --all-features - cargo test --locked --workspace --exclude promptforge-wb --exclude promptforge-wb-server --all-features + cargo build --locked --workspace --exclude promptforge-ws --exclude promptforge-ws-server --all-features + cargo test --locked --workspace --exclude promptforge-ws --exclude promptforge-ws-server --all-features supply-chain: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 99d94189..7253589e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,10 +5,10 @@ /local/ /guide/book/ *.env -# Voice test fixtures, downloaded out of band (see design-promptforge-wb-1.md). -/crates/promptforge-wb-server/tests/fixtures/ +# Voice test fixtures, downloaded out of band (see design-promptforge-ws-1.md). +/crates/promptforge-ws-server/tests/fixtures/ # UI build pipeline: npm install target and esbuild output (rebuilt by build.rs). -/crates/promptforge-wb-server/ui/node_modules/ -/crates/promptforge-wb-server/ui/dist/ -# Workbench tape, written to the cwd when the server runs from the repo root. +/crates/promptforge-ws-server/ui/node_modules/ +/crates/promptforge-ws-server/ui/dist/ +# Workshop tape, written to the cwd when the server runs from the repo root. /tape.jsonl diff --git a/Cargo.lock b/Cargo.lock index 1f8c6d97..63c0a808 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4027,18 +4027,18 @@ dependencies = [ ] [[package]] -name = "promptforge-wb" +name = "promptforge-ws" version = "0.1.0" dependencies = [ "anyhow", - "promptforge-wb-server", + "promptforge-ws-server", "tao", "tempfile", "wry", ] [[package]] -name = "promptforge-wb-server" +name = "promptforge-ws-server" version = "0.1.0" dependencies = [ "anyhow", diff --git a/Cargo.toml b/Cargo.toml index ba0133ee..fe8a06c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ promptforge-core = { path = "crates/promptforge-core", version = "0.1.0" } promptforge-gateway-config = { path = "crates/promptforge-gateway-config", version = "0.1.0" } promptforge-webfetch = { path = "crates/promptforge-webfetch", version = "0.1.0" } promptforge-tool-picker = { path = "crates/promptforge-tool-picker", version = "0.1.0" } -promptforge-wb-server = { path = "crates/promptforge-wb-server", version = "0.1.0" } +promptforge-ws-server = { path = "crates/promptforge-ws-server", version = "0.1.0" } pulldown-cmark = "0.12" serde = { version = "1", features = ["derive"] } serde_yaml_ng = "0.10" @@ -54,7 +54,7 @@ rmcp = { version = "3.1.0", features = [ ] } tower = { version = "0.5", features = ["util"] } arc-swap = "1" -# Serves the workbench UI: reads ui/dist from disk in debug builds, embeds it +# Serves the workshop UI: reads ui/dist from disk in debug builds, embeds it # into the binary in release builds. rust-embed = "8" glob = "0.3" diff --git a/README.md b/README.md index 689c2302..35bd4b90 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A runtime that executes AI prompt pipelines defined in a single markdown file. The markdown is the program, the model is the CPU. YAML frontmatter for metadata, embedded Lua for logic, prose blocks for model instructions, and a credential-holding gateway that keeps vendor keys off the prompt process. Write a prompt, run it, get a result. -![Workbench](images/banner-01.png) +![Workshop](images/banner-01.png) ## What you get @@ -112,8 +112,8 @@ flowchart LR | [promptforge-tool-picker](crates/promptforge-tool-picker) | Semantic tool resolution via sentence embeddings | [![Crates.io](https://img.shields.io/crates/v/promptforge-tool-picker.svg)](https://crates.io/crates/promptforge-tool-picker) | | [promptforge-webfetch](crates/promptforge-webfetch) | SSRF-safe web fetch tool for model-supplied URLs | [![Crates.io](https://img.shields.io/crates/v/promptforge-webfetch.svg)](https://crates.io/crates/promptforge-webfetch) | | [promptforge-dev](crates/promptforge-dev) | Interactive prompt development with watch mode | [![Crates.io](https://img.shields.io/crates/v/promptforge-dev.svg)](https://crates.io/crates/promptforge-dev) | -| [promptforge-wb-server](crates/promptforge-wb-server) | Workbench HTTP server: chat relay, session tape, voice transcription | not published | -| [promptforge-wb](crates/promptforge-wb) | Workbench desktop window shell (wry/tao) | not published | +| [promptforge-ws-server](crates/promptforge-ws-server) | Workshop HTTP server: chat relay, session tape, voice transcription | not published | +| [promptforge-ws](crates/promptforge-ws) | Workshop desktop window shell (wry/tao) | not published | ## Documentation diff --git a/crates/promptforge-gateway/user-guide-promptforge-gateway.md b/crates/promptforge-gateway/user-guide-promptforge-gateway.md index 8a13723e..47554b33 100644 --- a/crates/promptforge-gateway/user-guide-promptforge-gateway.md +++ b/crates/promptforge-gateway/user-guide-promptforge-gateway.md @@ -568,7 +568,7 @@ When `sha256` is set, the downloaded file is verified against the digest. ### The blob cache API -Three bearer-authenticated routes let a client (the workbench, for example) download arbitrary blobs into the same cache on demand: +Three bearer-authenticated routes let a client (the workshop, for example) download arbitrary blobs into the same cache on demand: - `POST /v1/cache` with `{"source": "", "sha256": ""}` ensures the blob is cached. A cache hit answers immediately with `200` JSON `{"path": "...", "status": "ready"}`. A miss answers with `200` `text/event-stream`: `data: {"status":"downloading","bytes":N,"total":N}` progress events (`total` is `null` when the server sent no Content-Length), terminated by `data: {"status":"ready","path":"..."}` or, on failure, `data: {"status":"error","message":"..."}`. A mid-stream failure is an SSE event, not an HTTP error, because the response is already committed. - `GET /v1/cache` returns `200` JSON `[{"source", "path", "sha256", "size_bytes"}, ...]` sorted by source. diff --git a/crates/promptforge-wb/README.md b/crates/promptforge-wb/README.md deleted file mode 100644 index 3d82ca5c..00000000 --- a/crates/promptforge-wb/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# promptforge-wb - -[![License](https://img.shields.io/badge/license-BSL--1.0-blue.svg)](LICENSE) - -The PromptForge Workbench desktop window. It starts the workbench server (`promptforge-wb-server`) in-process on its own thread, waits for its health endpoint to answer, and opens a native window (wry/tao, WebView2 on Windows) pointed at the chat UI. Closing the window shuts the server down cleanly. One binary, one process: the window and the server it frames ship together. - -## Quick start - -```bash -cargo run -p promptforge-wb -``` - -## Configuration - -The shell loads the same `workbench.toml` as the server (see the [promptforge-wb-server README](../promptforge-wb-server/README.md) for the field reference), but searches for it in three places, first found wins: - -1. Beside the executable -2. The current directory -3. `%USERPROFILE%\.promptforge\workbench.toml` - -On first run - when no file exists at any of these locations - the shell creates `%USERPROFILE%\.promptforge\` if needed, writes a default `workbench.toml` there, logs the path, and loads it. The generated file interpolates the gateway settings from `PROMPTFORGE_GATEWAY_URL` and `PROMPTFORGE_GATEWAY_API_KEY`; unset, they resolve to the built-in defaults (loopback gateway, no `Authorization` header). The app never exits on missing config. - -## Browser fallback - -The shell ignores `server.open_browser` - it has a window. That flag belongs to the server binary: run `promptforge-wb-server` directly with `open_browser = true` to use the workbench as a browser tab instead of a desktop window. - -## Minimum Rust Version - -Rust 1.89 or later. - -## License - -Licensed under the [Boost Software License 1.0](../../LICENSE). diff --git a/crates/promptforge-wb-server/Cargo.toml b/crates/promptforge-ws-server/Cargo.toml similarity index 84% rename from crates/promptforge-wb-server/Cargo.toml rename to crates/promptforge-ws-server/Cargo.toml index 801d50f1..2f339b21 100644 --- a/crates/promptforge-wb-server/Cargo.toml +++ b/crates/promptforge-ws-server/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "promptforge-wb-server" +name = "promptforge-ws-server" version = "0.1.0" edition.workspace = true rust-version.workspace = true @@ -7,10 +7,10 @@ license.workspace = true repository.workspace = true publish = false -description = "PromptForge Workbench HTTP server: serves the workbench API to the desktop shell" +description = "PromptForge Workshop HTTP server: serves the workshop API to the desktop shell" [[bin]] -name = "promptforge-wb-server" +name = "promptforge-ws-server" path = "src/main.rs" [dependencies] diff --git a/crates/promptforge-wb-server/README.md b/crates/promptforge-ws-server/README.md similarity index 82% rename from crates/promptforge-wb-server/README.md rename to crates/promptforge-ws-server/README.md index 847a7065..86c30081 100644 --- a/crates/promptforge-wb-server/README.md +++ b/crates/promptforge-ws-server/README.md @@ -1,12 +1,12 @@ -# promptforge-wb-server +# promptforge-ws-server [![License](https://img.shields.io/badge/license-BSL--1.0-blue.svg)](LICENSE) -The PromptForge Workbench HTTP server. It serves a local chat UI and API on loopback: an OpenAI-shaped model catalog and chat relay in front of a PromptForge gateway (with streaming over a WebSocket), a JSONL session tape recording every exchange, and a WebSocket voice endpoint that transcribes push-to-talk microphone audio on-device with whisper.cpp. The desktop shell (`promptforge-wb`) embeds it in-process; run standalone it is the browser-tab frame of the same workbench. +The PromptForge Workshop HTTP server. It serves a local chat UI and API on loopback: an OpenAI-shaped model catalog and chat relay in front of a PromptForge gateway (with streaming over a WebSocket), a JSONL session tape recording every exchange, and a WebSocket voice endpoint that transcribes push-to-talk microphone audio on-device with whisper.cpp. The desktop shell (`promptforge-ws`) embeds it in-process; run standalone it is the browser-tab frame of the same workshop. ## Quick start -Create a `workbench.toml` in the current directory (see [`workbench.example.toml`](../../workbench.example.toml) at the repository root for a commented version of every field): +Create a `workshop.toml` in the current directory (see [`workshop.example.toml`](../../workshop.example.toml) at the repository root for a commented version of every field): ```toml [gateway] @@ -17,25 +17,25 @@ api_key = "${PROMPTFORGE_GATEWAY_API_KEY}" Then run: ```bash -cargo run -p promptforge-wb-server +cargo run -p promptforge-ws-server ``` The server binds `127.0.0.1:7910` by default and serves the chat UI at `http://127.0.0.1:7910/`. Set `server.open_browser = true` to have it open your system browser once it is serving. -The desktop shell (`promptforge-wb`) is the zero-config path: it searches beside its executable, then the current directory, then `~/.promptforge/`, and on first run writes a default `workbench.toml` into `~/.promptforge/` and loads that. The server binary does not generate one - it reads `workbench.toml` from the current directory only. +The desktop shell (`promptforge-ws`) is the zero-config path: it searches beside its executable, then the current directory, then `~/.promptforge/`, and on first run writes a default `workshop.toml` into `~/.promptforge/` and loads that. The server binary does not generate one - it reads `workshop.toml` from the current directory, or `workbench.toml` there if the canonical name is missing. String values support `${VAR}` environment interpolation; `$$` is a literal `$`, and an unset variable interpolates to the empty string. ## Configuration -Every field of `workbench.toml`: +Every field of `workshop.toml`: | Field | Default | Description | | --- | --- | --- | | `gateway.base_url` | `http://127.0.0.1:8081` when empty | Base URL of the PromptForge gateway; an empty value (for example an unset `${PROMPTFORGE_GATEWAY_URL}`) falls back to the default | | `gateway.api_key` | (empty) | Bearer key for the gateway API; supports `${VAR}` interpolation; empty sends no `Authorization` header | | `tape.path` | `tape.jsonl` | Path of the JSONL session tape; one event per chat exchange | -| `server.bind` | `127.0.0.1:7910` | Address the workbench server binds to | +| `server.bind` | `127.0.0.1:7910` | Address the workshop server binds to | | `server.open_browser` | `false` | When true, the server binary opens the system browser at its address once serving; the desktop shell ignores it | | `voice.interim_model` | (empty) | Path to the GGML whisper model for streaming interim transcription; empty disables transcription until a source provides the model. A missing file with `interim_source` set is fetched through the gateway cache; a missing file with no source degrades to voice disabled (with a status-bar note), never a startup failure | | `voice.final_model` | (empty) | Path to the whisper model for the pipelined final pass; empty falls back to the interim model. A missing file with no `final_source` drops just the final pass | @@ -67,23 +67,23 @@ The chat UI is TypeScript under `ui/src/`, bundled by esbuild into `ui/dist/app. Two workflows: -1. **Just cargo:** edit the TypeScript, then `cargo build` (or `cargo run -p promptforge-wb-server`). The build script re-bundles whenever `ui/src/` or the static UI files change, and debug builds read `ui/dist/` from disk on every request. +1. **Just cargo:** edit the TypeScript, then `cargo build` (or `cargo run -p promptforge-ws-server`). The build script re-bundles whenever `ui/src/` or the static UI files change, and debug builds read `ui/dist/` from disk on every request. 2. **esbuild watch:** run `npm run watch` in `ui/` in one terminal and `cargo run` in another. Edit, save, refresh the browser - no Rust recompile for UI changes. `npm run typecheck` runs `tsc --noEmit`; esbuild strips types without checking them, so the typecheck is advisory. `npm test` runs a jsdom smoke test that imports the built `dist/app.js` and asserts the chat UI mounts (run `npm run build` first). -The chat UI itself is [murm-ui](https://github.com/levmv/murm-ui) 0.2.0, vendored in `ui/src/chat/` (MIT, see its `PROVENANCE.md`), driven by a WebSocket provider against `GET /ws` (one persistent socket, opened on load; chat frames carry an `id` the server echoes, and unsolicited status frames ride the same connection). Its styles are bundled by esbuild into `dist/app.css`; `ui/style.css` carries the workbench shell (sidebar, picker, voice UI, status bar) and overrides. +The chat UI itself is [murm-ui](https://github.com/levmv/murm-ui) 0.2.0, vendored in `ui/src/chat/` (MIT, see its `PROVENANCE.md`), driven by a WebSocket provider against `GET /ws` (one persistent socket, opened on load; chat frames carry an `id` the server echoes, and unsolicited status frames ride the same connection). Its styles are bundled by esbuild into `dist/app.css`; `ui/style.css` carries the workshop shell (sidebar, picker, voice UI, status bar) and overrides. The status bar at the bottom of the window renders the observer's `{"type":"status",...}` frames (`ui/src/status-bar.ts`): the label as the bar text, the description as the tooltip, error frames in a distinct color. Debug-severity frames are internal instrumentation and never touch the text. The right slot holds a `` bar while a frame carries progress, and an activity LED otherwise: a small circle that pulses green on gateway traffic and amber on voice activity (green wins when both coincide), lit for one pulse window per frame and faded by a CSS transition. The bar's colors, glow radii, and pulse window are CSS custom properties (`--led-green`, `--led-amber`, `--led-off`, `--led-glow-radius`, `--led-pulse-ms`, `--progress-fill`, `--progress-glow`, ...) at the top of `ui/style.css`. ## Skinning -The whole UI skins from the `:root` block at the top of `ui/style.css` - every color, spacing step, radius, font, scrollbar metric, and the status bar's LED and progress effect is a CSS custom property there. The vendored murm-ui chat panel skins from the same block through a bridge in `ui/style.css` (the `.mur-app[data-theme="dark"]` rule, with a comment mapping each `--mur-*` variable to the workbench variable it follows). +The whole UI skins from the `:root` block at the top of `ui/style.css` - every color, spacing step, radius, font, scrollbar metric, and the status bar's LED and progress effect is a CSS custom property there. The vendored murm-ui chat panel skins from the same block through a bridge in `ui/style.css` (the `.mur-app[data-theme="dark"]` rule, with a comment mapping each `--mur-*` variable to the workshop variable it follows). Two ways to reskin: 1. **Edit the block.** Change values in the `:root` block of `ui/style.css` and rebuild (`cargo build`; debug builds serve `ui/dist/` from disk). This is the path for changes you keep. -2. **Override from an additional stylesheet.** Add a `` after `/style.css` in `ui/index.html` and redeclare any variable on `:root`. Later declarations win the cascade, and because the murm-ui bridge dereferences the workbench variables at computed-value time, overriding e.g. `--bg` re-skins the chat panel too. To retune murm-ui-only knobs (`--mur-chat-form-width`, the shadows), target `.mur-app[data-theme="dark"]` in the same stylesheet. +2. **Override from an additional stylesheet.** Add a `` after `/style.css` in `ui/index.html` and redeclare any variable on `:root`. Later declarations win the cascade, and because the murm-ui bridge dereferences the workshop variables at computed-value time, overriding e.g. `--bg` re-skins the chat panel too. To retune murm-ui-only knobs (`--mur-chat-form-width`, the shadows), target `.mur-app[data-theme="dark"]` in the same stylesheet. The variables: @@ -132,7 +132,7 @@ The variables: ## Whisper models -With `voice.interim_source` / `voice.final_source` set (the generated config sets both), the workbench downloads the models through the gateway's cache API once the gateway connects and loads them from the cached paths - no manual step. Models can also be placed on disk directly and named with `voice.interim_model` / `voice.final_model`; a local file always wins over a source. The models come from the whisper.cpp GGML model collection on Hugging Face: . Production configs typically pair `ggml-large-v3-turbo.bin` (interim) with `ggml-large-v3.bin` (final). The test suite uses the tiny English model (`ggml-tiny.en.bin`) plus the `jfk.wav` speech fixture, placed in `tests/fixtures/` (gitignored); a missing fixture fails the test with the download URL in the message. +With `voice.interim_source` / `voice.final_source` set (the generated config sets both), the workshop downloads the models through the gateway's cache API once the gateway connects and loads them from the cached paths - no manual step. Models can also be placed on disk directly and named with `voice.interim_model` / `voice.final_model`; a local file always wins over a source. The models come from the whisper.cpp GGML model collection on Hugging Face: . Production configs typically pair `ggml-large-v3-turbo.bin` (interim) with `ggml-large-v3.bin` (final). The test suite uses the tiny English model (`ggml-tiny.en.bin`) plus the `jfk.wav` speech fixture, placed in `tests/fixtures/` (gitignored); a missing fixture fails the test with the download URL in the message. ## Minimum Rust Version diff --git a/crates/promptforge-wb-server/build.rs b/crates/promptforge-ws-server/build.rs similarity index 98% rename from crates/promptforge-wb-server/build.rs rename to crates/promptforge-ws-server/build.rs index 84a43a2e..6ee20af7 100644 --- a/crates/promptforge-wb-server/build.rs +++ b/crates/promptforge-ws-server/build.rs @@ -1,4 +1,4 @@ -//! Builds the workbench UI bundle before the Rust compile. +//! Builds the workshop UI bundle before the Rust compile. //! //! Runs esbuild on `ui/src/main.ts` into `ui/dist/app.js` and copies the //! static assets (`ui/index.html`, `ui/style.css`, ...) into `ui/dist/`, diff --git a/crates/promptforge-wb-server/src/app.rs b/crates/promptforge-ws-server/src/app.rs similarity index 99% rename from crates/promptforge-wb-server/src/app.rs rename to crates/promptforge-ws-server/src/app.rs index 7c2694bd..abf56695 100644 --- a/crates/promptforge-wb-server/src/app.rs +++ b/crates/promptforge-ws-server/src/app.rs @@ -1,4 +1,4 @@ -//! The axum router, handlers, and shared state for the workbench server. +//! The axum router, handlers, and shared state for the workshop server. use std::sync::Arc; use std::time::{Duration, Instant}; @@ -208,7 +208,7 @@ fn degrade( None } -/// Returns the workbench server router with every route mounted. +/// Returns the workshop server router with every route mounted. pub fn router(state: AppState) -> Router { Router::new() .route("/", get(ui_index)) @@ -232,7 +232,7 @@ async fn health() -> impl IntoResponse { ) } -/// The workbench UI assets under `ui/dist/`, written by the crate's build +/// The workshop UI assets under `ui/dist/`, written by the crate's build /// script (the esbuild bundle plus copies of the static files). Debug builds /// read the files from disk at request time, so UI edits need no Rust /// recompile; release builds embed them into the binary. @@ -456,7 +456,7 @@ fn bad_request(error: &serde_json::Error) -> Response { .into_response() } -/// Turns a gateway call outcome into the workbench's HTTP response. +/// Turns a gateway call outcome into the workshop's HTTP response. /// /// Success (any status) is relayed byte-for-byte; a transport failure /// becomes `502 Bad Gateway` with a small JSON error envelope. diff --git a/crates/promptforge-wb-server/src/catalog.rs b/crates/promptforge-ws-server/src/catalog.rs similarity index 100% rename from crates/promptforge-wb-server/src/catalog.rs rename to crates/promptforge-ws-server/src/catalog.rs diff --git a/crates/promptforge-wb-server/src/chat_ws.rs b/crates/promptforge-ws-server/src/chat_ws.rs similarity index 99% rename from crates/promptforge-wb-server/src/chat_ws.rs rename to crates/promptforge-ws-server/src/chat_ws.rs index 38f3da87..6f200e52 100644 --- a/crates/promptforge-wb-server/src/chat_ws.rs +++ b/crates/promptforge-ws-server/src/chat_ws.rs @@ -534,7 +534,7 @@ mod tests { format!("http://{addr}") } - /// Binds the workbench router against the gateway at `base_url` on a + /// Binds the workshop router against the gateway at `base_url` on a /// free loopback port and returns the `/ws` URL, the tempdir keeping /// the tape alive, and a handle on the shared state (for poking the /// status bus directly). diff --git a/crates/promptforge-wb-server/src/config.rs b/crates/promptforge-ws-server/src/config.rs similarity index 95% rename from crates/promptforge-wb-server/src/config.rs rename to crates/promptforge-ws-server/src/config.rs index 1d4777bf..2c5fea06 100644 --- a/crates/promptforge-wb-server/src/config.rs +++ b/crates/promptforge-ws-server/src/config.rs @@ -1,4 +1,4 @@ -//! `workbench.toml` loading: TOML parsing, `${VAR}` environment +//! `workshop.toml` loading: TOML parsing, `${VAR}` environment //! interpolation, and defaults. //! //! Interpolation follows the promptforge convention (gateway-config CFG-007): @@ -13,13 +13,13 @@ use std::path::{Path, PathBuf}; /// Path [`Config::load`] reads when no override is given. -pub const DEFAULT_CONFIG_PATH: &str = "workbench.toml"; +pub const DEFAULT_CONFIG_PATH: &str = "workshop.toml"; /// Gateway base URL used when `gateway.base_url` interpolates to an empty /// string, for example because `PROMPTFORGE_GATEWAY_URL` is unset. pub const DEFAULT_GATEWAY_BASE_URL: &str = "http://127.0.0.1:8081"; -/// Workbench server configuration loaded from `workbench.toml`. +/// Workshop server configuration loaded from `workshop.toml`. #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] pub struct Config { /// Connection settings for the PromptForge gateway. @@ -36,12 +36,12 @@ pub struct Config { } impl Config { - /// Loads and parses the workbench configuration from `path`. + /// Loads and parses the workshop configuration from `path`. /// /// # Errors /// Returns [`ConfigError::NotFound`] if `path` does not exist, /// [`ConfigError::Read`] if `path` exists but cannot be read, - /// [`ConfigError::Parse`] if the contents do not match the workbench + /// [`ConfigError::Parse`] if the contents do not match the workshop /// schema, [`ConfigError::UnresolvedVar`] if a `${VAR}` names a variable /// whose value is not valid Unicode, and [`ConfigError::Interpolation`] /// if a `${...}` is malformed. @@ -61,21 +61,21 @@ impl Config { Self::parse(&raw, Some(path)) } - /// Parses a workbench configuration from a TOML string. + /// Parses a workshop configuration from a TOML string. /// /// # Errors /// Returns [`ConfigError::Parse`] if `raw` is not valid TOML or does not - /// match the workbench schema, [`ConfigError::UnresolvedVar`] if a + /// match the workshop schema, [`ConfigError::UnresolvedVar`] if a /// `${VAR}` names a variable whose value is not valid Unicode, and /// [`ConfigError::Interpolation`] if a `${...}` is malformed. /// /// # Examples /// ``` - /// let config = promptforge_wb_server::Config::from_toml_str( + /// let config = promptforge_ws_server::Config::from_toml_str( /// "[gateway]\nbase_url = \"http://127.0.0.1:8081\"\napi_key = \"k\"\n", /// )?; /// assert_eq!(config.server.bind, "127.0.0.1:7910"); - /// # Ok::<(), promptforge_wb_server::ConfigError>(()) + /// # Ok::<(), promptforge_ws_server::ConfigError>(()) /// ``` pub fn from_toml_str(raw: &str) -> Result { Self::parse(raw, None) @@ -129,7 +129,7 @@ impl Default for TapeConfig { #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)] #[serde(default)] pub struct ServerConfig { - /// Address the workbench server binds to. + /// Address the workshop server binds to. pub bind: String, /// When true, the server binary opens the system browser at its address /// once it is serving. The desktop shell sets up its own window and @@ -211,7 +211,7 @@ impl VoiceConfig { } } -/// A workbench configuration load or parse failure. +/// A workshop configuration load or parse failure. #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum ConfigError { @@ -533,15 +533,14 @@ vocabulary = ["MCP", "GGUF", "Lua"] #[test] fn missing_file_names_the_expected_path() { - let err = Config::load(Path::new("definitely-missing-workbench.toml")) + let err = Config::load(Path::new("definitely-missing-workshop.toml")) .expect_err("missing file must fail"); assert!( matches!(err, ConfigError::NotFound { .. }), "expected NotFound, got {err:?}" ); assert!( - err.to_string() - .contains("definitely-missing-workbench.toml"), + err.to_string().contains("definitely-missing-workshop.toml"), "error names the path: {err}" ); } @@ -549,7 +548,7 @@ vocabulary = ["MCP", "GGUF", "Lua"] #[test] fn unreadable_existing_file_is_a_read_error() { let dir = tempfile::TempDir::new().expect("tempdir"); - let path = dir.path().join("workbench.toml"); + let path = dir.path().join("workshop.toml"); std::fs::write(&path, "[gateway]\n").expect("write fixture"); // A directory-shaped read failure: replace the file with a // directory of the same name so the read fails for a reason other @@ -566,7 +565,7 @@ vocabulary = ["MCP", "GGUF", "Lua"] #[test] fn parse_error_names_the_file() { let dir = tempfile::TempDir::new().expect("tempdir"); - let path = dir.path().join("broken-workbench.toml"); + let path = dir.path().join("broken-workshop.toml"); std::fs::write(&path, "[gateway\n").expect("write fixture"); let err = Config::load(&path).expect_err("malformed TOML must fail"); assert!( @@ -574,7 +573,7 @@ vocabulary = ["MCP", "GGUF", "Lua"] "expected Parse, got {err:?}" ); assert!( - err.to_string().contains("broken-workbench.toml"), + err.to_string().contains("broken-workshop.toml"), "error names the path: {err}" ); } @@ -582,7 +581,7 @@ vocabulary = ["MCP", "GGUF", "Lua"] #[test] fn load_reads_and_parses_a_file() { let dir = tempfile::TempDir::new().expect("tempdir"); - let path = dir.path().join("workbench.toml"); + let path = dir.path().join("workshop.toml"); std::fs::write( &path, "[gateway]\nbase_url = \"http://127.0.0.1:8081\"\napi_key = \"k\"\n", diff --git a/crates/promptforge-wb-server/src/gateway.rs b/crates/promptforge-ws-server/src/gateway.rs similarity index 99% rename from crates/promptforge-wb-server/src/gateway.rs rename to crates/promptforge-ws-server/src/gateway.rs index 2027f798..4ab7d0fe 100644 --- a/crates/promptforge-wb-server/src/gateway.rs +++ b/crates/promptforge-ws-server/src/gateway.rs @@ -1,7 +1,7 @@ //! HTTP client for the PromptForge gateway's OpenAI-compatible API. //! //! [`GatewayClient`] wraps `reqwest` with bearer authentication and returns -//! responses as raw bytes so the workbench routes can relay them to the +//! responses as raw bytes so the workshop routes can relay them to the //! caller byte-for-byte. A non-success status from the gateway is *not* an //! error here: it is part of the relayed response. Streaming chat requests //! are decoded from SSE into a [`SsePayloadStream`] of `data:` payloads. @@ -32,7 +32,7 @@ const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); /// A non-streaming chat completion request forwarded to the gateway. /// -/// This is the body accepted by the workbench's `POST /chat` and sent +/// This is the body accepted by the workshop's `POST /chat` and sent /// upstream to `POST /v1/chat/completions`. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ChatRequest { @@ -137,7 +137,7 @@ impl std::fmt::Debug for CacheResponse { /// terminal state of a cache-ensure call. /// /// The `path` a `Ready` event carries names a file on the gateway host, so -/// the cache API is only meaningful to a workbench sharing the gateway's +/// the cache API is only meaningful to a workshop sharing the gateway's /// filesystem - the standard local deployment, where both run on loopback. #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(tag = "status", rename_all = "lowercase")] diff --git a/crates/promptforge-wb-server/src/heartbeat.rs b/crates/promptforge-ws-server/src/heartbeat.rs similarity index 100% rename from crates/promptforge-wb-server/src/heartbeat.rs rename to crates/promptforge-ws-server/src/heartbeat.rs diff --git a/crates/promptforge-wb-server/src/lib.rs b/crates/promptforge-ws-server/src/lib.rs similarity index 89% rename from crates/promptforge-wb-server/src/lib.rs rename to crates/promptforge-ws-server/src/lib.rs index b35457a3..3baf4db2 100644 --- a/crates/promptforge-wb-server/src/lib.rs +++ b/crates/promptforge-ws-server/src/lib.rs @@ -1,6 +1,6 @@ -//! PromptForge Workbench HTTP server. +//! PromptForge Workshop HTTP server. //! -//! Holds the `workbench.toml` configuration, the PromptForge gateway client, +//! Holds the `workshop.toml` configuration, the PromptForge gateway client, //! the session tape, and the axum router so `src/main.rs` stays a thin shell. //! Start at [`Config::load`] for configuration, [`Tape`] for the session //! tape, and [`router`] for the HTTP API; [`spawn`] runs the whole server diff --git a/crates/promptforge-wb-server/src/main.rs b/crates/promptforge-ws-server/src/main.rs similarity index 55% rename from crates/promptforge-wb-server/src/main.rs rename to crates/promptforge-ws-server/src/main.rs index e43c5699..e7689fea 100644 --- a/crates/promptforge-wb-server/src/main.rs +++ b/crates/promptforge-ws-server/src/main.rs @@ -1,7 +1,7 @@ -//! The `promptforge-wb-server` binary: loads `workbench.toml` and serves the -//! workbench HTTP API. +//! The `promptforge-ws-server` binary: loads `workshop.toml` and serves the +//! workshop HTTP API. //! -//! Thin shell around [`promptforge_wb_server`]: load the config, spawn the +//! Thin shell around [`promptforge_ws_server`]: load the config, spawn the //! server in-process, optionally open the system browser at its address (the //! browser-tab frame, for when no desktop window is driving), and wait. @@ -9,7 +9,7 @@ use std::path::Path; use std::process::ExitCode; use anyhow::Context as _; -use promptforge_wb_server::{Config, DEFAULT_CONFIG_PATH}; +use promptforge_ws_server::{Config, DEFAULT_CONFIG_PATH}; fn main() -> ExitCode { tracing_subscriber::fmt::init(); @@ -22,11 +22,19 @@ fn main() -> ExitCode { } } +const LEGACY_CONFIG_PATH: &str = "workbench.toml"; + fn serve() -> anyhow::Result<()> { - let config = Config::load(Path::new(DEFAULT_CONFIG_PATH)) - .with_context(|| format!("load {DEFAULT_CONFIG_PATH}"))?; + let path = if Path::new(DEFAULT_CONFIG_PATH).is_file() { + DEFAULT_CONFIG_PATH + } else if Path::new(LEGACY_CONFIG_PATH).is_file() { + LEGACY_CONFIG_PATH + } else { + DEFAULT_CONFIG_PATH + }; + let config = Config::load(Path::new(path)).with_context(|| format!("load {path}"))?; let open_browser = config.server.open_browser; - let server = promptforge_wb_server::spawn(config).context("start workbench server")?; + let server = promptforge_ws_server::spawn(config).context("start workshop server")?; if open_browser { let url = server.url().to_string(); // A browser that will not open is not worth killing a serving @@ -35,5 +43,5 @@ fn serve() -> anyhow::Result<()> { tracing::warn!(%error, %url, "could not open the system browser"); } } - server.join().context("serve workbench") + server.join().context("serve workshop") } diff --git a/crates/promptforge-wb-server/src/provision.rs b/crates/promptforge-ws-server/src/provision.rs similarity index 97% rename from crates/promptforge-wb-server/src/provision.rs rename to crates/promptforge-ws-server/src/provision.rs index 5cb1d6be..d87d92c6 100644 --- a/crates/promptforge-wb-server/src/provision.rs +++ b/crates/promptforge-ws-server/src/provision.rs @@ -687,9 +687,9 @@ mod tests { provision.shutdown().await; } - /// A `/ws` client socket connected to a live workbench test server, + /// A `/ws` client socket connected to a live workshop test server, /// plus the state pieces the provision task spawns with. - struct Workbench { + struct Workshop { socket: tokio_tungstenite::WebSocketStream< tokio_tungstenite::MaybeTlsStream, >, @@ -701,9 +701,9 @@ mod tests { _tape_dir: tempfile::TempDir, } - /// Builds a workbench router with the given voice config and gateway + /// Builds a workshop router with the given voice config and gateway /// URL, binds it on a free loopback port, and connects a `/ws` client. - async fn connect_workbench(gateway_url: String, voice: VoiceConfig) -> Workbench { + async fn connect_workshop(gateway_url: String, voice: VoiceConfig) -> Workshop { let tape_dir = tempfile::TempDir::new().expect("tempdir"); let config = crate::config::Config { gateway: crate::config::GatewayConfig { @@ -723,19 +723,17 @@ mod tests { let slot = state.voice_slot(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await - .expect("bind the workbench test server"); - let addr = listener - .local_addr() - .expect("workbench test server address"); + .expect("bind the workshop test server"); + let addr = listener.local_addr().expect("workshop test server address"); tokio::spawn(async move { axum::serve(listener, crate::router(state)) .await - .expect("workbench test server serves"); + .expect("workshop test server serves"); }); let (socket, _) = tokio_tungstenite::connect_async(format!("ws://{addr}/ws")) .await .expect("connect to /ws"); - Workbench { + Workshop { socket, gateway, status, @@ -777,26 +775,26 @@ mod tests { interim_source: INTERIM_SOURCE.to_string(), ..VoiceConfig::default() }; - let mut workbench = connect_workbench(base_url, voice).await; + let mut workshop = connect_workshop(base_url, voice).await; // Park the task on an unreachable flag until the session's status // forwarder is subscribed, then flip reachable to fire the attempt. - workbench.health.publish(false); + workshop.health.publish(false); let provision = spawn( - workbench.gateway.clone(), - workbench.status.clone(), - workbench.health.clone(), - workbench.slot.clone(), - workbench.voice.clone(), + workshop.gateway.clone(), + workshop.status.clone(), + workshop.health.clone(), + workshop.slot.clone(), + workshop.voice.clone(), ); tokio::time::sleep(Duration::from_millis(50)).await; - workbench.health.publish(true); + workshop.health.publish(true); // Collect status frames until the terminal success frame. let mut frames = Vec::new(); let collect = tokio::time::timeout(Duration::from_secs(60), async { loop { - let frame = read_frame(&mut workbench.socket).await; + let frame = read_frame(&mut workshop.socket).await; if frame["type"] != "status" { continue; } diff --git a/crates/promptforge-wb-server/src/segment.rs b/crates/promptforge-ws-server/src/segment.rs similarity index 100% rename from crates/promptforge-wb-server/src/segment.rs rename to crates/promptforge-ws-server/src/segment.rs diff --git a/crates/promptforge-wb-server/src/serve.rs b/crates/promptforge-ws-server/src/serve.rs similarity index 95% rename from crates/promptforge-wb-server/src/serve.rs rename to crates/promptforge-ws-server/src/serve.rs index 99f73fe3..cb18bcc7 100644 --- a/crates/promptforge-wb-server/src/serve.rs +++ b/crates/promptforge-ws-server/src/serve.rs @@ -1,4 +1,4 @@ -//! In-process serving: the workbench server on a dedicated thread. +//! In-process serving: the workshop server on a dedicated thread. //! //! [`spawn`] builds the shared state, binds the listener, and serves on its //! own thread with its own tokio runtime, so an embedding binary (the @@ -15,7 +15,7 @@ use crate::config::Config; use crate::heartbeat; use crate::provision; -/// A running workbench server on its own thread. +/// A running workshop server on its own thread. /// /// Dropping the handle without calling [`ServerHandle::shutdown`] still /// signals the server to stop, but does not wait for it. @@ -62,7 +62,7 @@ impl ServerHandle { }; match thread.join() { Ok(result) => result, - Err(_) => Err(std::io::Error::other("workbench server thread panicked")), + Err(_) => Err(std::io::Error::other("workshop server thread panicked")), } } } @@ -75,7 +75,7 @@ impl Drop for ServerHandle { } } -/// A failure to start the in-process workbench server. +/// A failure to start the in-process workshop server. #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum SpawnError { @@ -88,11 +88,11 @@ pub enum SpawnError { /// An I/O failure: the listener bind failed, the bound address could /// not be read, or the server thread could not be spawned. #[non_exhaustive] - #[error("start workbench server")] + #[error("start workshop server")] Io(#[source] std::io::Error), } -/// Spawns the workbench server on a dedicated thread and blocks until the +/// Spawns the workshop server on a dedicated thread and blocks until the /// listener is bound. /// /// The bound listener is the readiness signal: when this returns `Ok`, the @@ -106,7 +106,7 @@ pub fn spawn(config: Config) -> Result { let (ready_tx, ready_rx) = mpsc::channel(); let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); let thread = std::thread::Builder::new() - .name("promptforge-wb-server".to_string()) + .name("promptforge-ws-server".to_string()) .spawn(move || serve_thread(config, ready_tx, shutdown_rx)) .map_err(SpawnError::Io)?; match ready_rx.recv() { @@ -122,7 +122,7 @@ pub fn spawn(config: Config) -> Result { Err(_) => { let _ = thread.join(); Err(SpawnError::Io(std::io::Error::other( - "workbench server thread exited before binding", + "workshop server thread exited before binding", ))) } } diff --git a/crates/promptforge-wb-server/src/status.rs b/crates/promptforge-ws-server/src/status.rs similarity index 100% rename from crates/promptforge-wb-server/src/status.rs rename to crates/promptforge-ws-server/src/status.rs diff --git a/crates/promptforge-wb-server/src/tape.rs b/crates/promptforge-ws-server/src/tape.rs similarity index 98% rename from crates/promptforge-wb-server/src/tape.rs rename to crates/promptforge-ws-server/src/tape.rs index b2870fdb..17cf92ce 100644 --- a/crates/promptforge-wb-server/src/tape.rs +++ b/crates/promptforge-ws-server/src/tape.rs @@ -1,7 +1,7 @@ //! The session tape: an append-only JSONL record of chat round-trips. //! //! Every completed `POST /chat` round-trip is appended to the file named by -//! `tape.path` in `workbench.toml`, one JSON object per line. The tape is +//! `tape.path` in `workshop.toml`, one JSON object per line. The tape is //! observability, not a transaction log: a failed write is logged and //! returned to the handler but never fails the user's chat request. @@ -23,7 +23,7 @@ pub struct TapeEvent { pub kind: String, /// The model the request named. pub model: String, - /// The request body as the workbench received it. + /// The request body as the workshop received it. pub request: serde_json::Value, /// The gateway response body; a plain string if it was not JSON. pub response: serde_json::Value, @@ -130,7 +130,7 @@ impl Tape { /// # Examples /// ``` /// let dir = tempfile::TempDir::new()?; - /// let tape = promptforge_wb_server::Tape::open(&dir.path().join("tape.jsonl"))?; + /// let tape = promptforge_ws_server::Tape::open(&dir.path().join("tape.jsonl"))?; /// # Ok::<(), Box>(()) /// ``` pub fn open(path: &Path) -> Result { diff --git a/crates/promptforge-wb-server/src/transcribe.rs b/crates/promptforge-ws-server/src/transcribe.rs similarity index 99% rename from crates/promptforge-wb-server/src/transcribe.rs rename to crates/promptforge-ws-server/src/transcribe.rs index 32579e05..04eb8c8d 100644 --- a/crates/promptforge-wb-server/src/transcribe.rs +++ b/crates/promptforge-ws-server/src/transcribe.rs @@ -79,7 +79,7 @@ pub(crate) fn tail(buffer: &[f32], window: usize) -> &[f32] { /// The per-server voice engine: the interim and final-pass whisper workers /// plus the interim loop's window and cadence, built once at startup from -/// `[voice]` in `workbench.toml`. +/// `[voice]` in `workshop.toml`. #[derive(Debug)] pub(crate) struct VoiceEngine { transcriber: Transcriber, diff --git a/crates/promptforge-wb-server/src/voice.rs b/crates/promptforge-ws-server/src/voice.rs similarity index 99% rename from crates/promptforge-wb-server/src/voice.rs rename to crates/promptforge-ws-server/src/voice.rs index b96ab21d..622b3cbe 100644 --- a/crates/promptforge-wb-server/src/voice.rs +++ b/crates/promptforge-ws-server/src/voice.rs @@ -502,7 +502,7 @@ mod tests { use crate::config::{Config, GatewayConfig, ServerConfig, TapeConfig, VoiceConfig}; use crate::transcribe::fixtures; - /// Binds the workbench router on a free loopback port with the given + /// Binds the workshop router on a free loopback port with the given /// voice configuration and returns the `/voice` WebSocket URL plus the /// tempdir keeping the tape alive. async fn spawn_voice_server(voice: VoiceConfig) -> (String, tempfile::TempDir) { diff --git a/crates/promptforge-wb-server/ui/build.mjs b/crates/promptforge-ws-server/ui/build.mjs similarity index 100% rename from crates/promptforge-wb-server/ui/build.mjs rename to crates/promptforge-ws-server/ui/build.mjs diff --git a/crates/promptforge-wb-server/ui/index.html b/crates/promptforge-ws-server/ui/index.html similarity index 100% rename from crates/promptforge-wb-server/ui/index.html rename to crates/promptforge-ws-server/ui/index.html diff --git a/crates/promptforge-wb-server/ui/package-lock.json b/crates/promptforge-ws-server/ui/package-lock.json similarity index 99% rename from crates/promptforge-wb-server/ui/package-lock.json rename to crates/promptforge-ws-server/ui/package-lock.json index e689ac28..eb2c6b33 100644 --- a/crates/promptforge-wb-server/ui/package-lock.json +++ b/crates/promptforge-ws-server/ui/package-lock.json @@ -1,11 +1,11 @@ { - "name": "promptforge-wb-ui", + "name": "promptforge-ws-ui", "version": "0.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "promptforge-wb-ui", + "name": "promptforge-ws-ui", "version": "0.0.0", "dependencies": { "dockview": "^8.2.0", diff --git a/crates/promptforge-wb-server/ui/package.json b/crates/promptforge-ws-server/ui/package.json similarity index 71% rename from crates/promptforge-wb-server/ui/package.json rename to crates/promptforge-ws-server/ui/package.json index 8616ce08..00e2c707 100644 --- a/crates/promptforge-wb-server/ui/package.json +++ b/crates/promptforge-ws-server/ui/package.json @@ -1,9 +1,9 @@ { - "name": "promptforge-wb-ui", + "name": "promptforge-ws-ui", "version": "0.0.0", "private": true, "type": "module", - "description": "PromptForge Workbench UI: TypeScript sources bundled by esbuild into dist/ and served by promptforge-wb-server.", + "description": "PromptForge Workshop UI: TypeScript sources bundled by esbuild into dist/ and served by promptforge-ws-server.", "scripts": { "build": "node build.mjs", "watch": "node build.mjs --watch", diff --git a/crates/promptforge-wb-server/ui/pcm-worklet.js b/crates/promptforge-ws-server/ui/pcm-worklet.js similarity index 100% rename from crates/promptforge-wb-server/ui/pcm-worklet.js rename to crates/promptforge-ws-server/ui/pcm-worklet.js diff --git a/crates/promptforge-wb-server/ui/src/chat/LICENSE b/crates/promptforge-ws-server/ui/src/chat/LICENSE similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/LICENSE rename to crates/promptforge-ws-server/ui/src/chat/LICENSE diff --git a/crates/promptforge-wb-server/ui/src/chat/PROVENANCE.md b/crates/promptforge-ws-server/ui/src/chat/PROVENANCE.md similarity index 93% rename from crates/promptforge-wb-server/ui/src/chat/PROVENANCE.md rename to crates/promptforge-ws-server/ui/src/chat/PROVENANCE.md index a7da5a63..347ab406 100644 --- a/crates/promptforge-wb-server/ui/src/chat/PROVENANCE.md +++ b/crates/promptforge-ws-server/ui/src/chat/PROVENANCE.md @@ -11,7 +11,7 @@ ## What this is The full TypeScript source of murm-ui 0.2.0 (`src/` in the upstream repo), -vendored so the workbench can adapt the chat UI to its own transport +vendored so the workshop can adapt the chat UI to its own transport (WebSocket provider, observer integration) and palette without waiting on upstream. The npm package ships only compiled `dist/`, which is why the source comes from the git repository rather than the tarball. diff --git a/crates/promptforge-wb-server/ui/src/chat/components/dropdown.ts b/crates/promptforge-ws-server/ui/src/chat/components/dropdown.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/components/dropdown.ts rename to crates/promptforge-ws-server/ui/src/chat/components/dropdown.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/components/feed-items.ts b/crates/promptforge-ws-server/ui/src/chat/components/feed-items.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/components/feed-items.ts rename to crates/promptforge-ws-server/ui/src/chat/components/feed-items.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/components/feed-node.ts b/crates/promptforge-ws-server/ui/src/chat/components/feed-node.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/components/feed-node.ts rename to crates/promptforge-ws-server/ui/src/chat/components/feed-node.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/components/feed.ts b/crates/promptforge-ws-server/ui/src/chat/components/feed.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/components/feed.ts rename to crates/promptforge-ws-server/ui/src/chat/components/feed.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/components/header.ts b/crates/promptforge-ws-server/ui/src/chat/components/header.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/components/header.ts rename to crates/promptforge-ws-server/ui/src/chat/components/header.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/components/input.ts b/crates/promptforge-ws-server/ui/src/chat/components/input.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/components/input.ts rename to crates/promptforge-ws-server/ui/src/chat/components/input.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/components/message-node.ts b/crates/promptforge-ws-server/ui/src/chat/components/message-node.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/components/message-node.ts rename to crates/promptforge-ws-server/ui/src/chat/components/message-node.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/components/sidebar.ts b/crates/promptforge-ws-server/ui/src/chat/components/sidebar.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/components/sidebar.ts rename to crates/promptforge-ws-server/ui/src/chat/components/sidebar.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/core/chat-engine.ts b/crates/promptforge-ws-server/ui/src/chat/core/chat-engine.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/core/chat-engine.ts rename to crates/promptforge-ws-server/ui/src/chat/core/chat-engine.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/core/msg-utils.ts b/crates/promptforge-ws-server/ui/src/chat/core/msg-utils.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/core/msg-utils.ts rename to crates/promptforge-ws-server/ui/src/chat/core/msg-utils.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/core/providers/openai.ts b/crates/promptforge-ws-server/ui/src/chat/core/providers/openai.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/core/providers/openai.ts rename to crates/promptforge-ws-server/ui/src/chat/core/providers/openai.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/core/session-manager.ts b/crates/promptforge-ws-server/ui/src/chat/core/session-manager.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/core/session-manager.ts rename to crates/promptforge-ws-server/ui/src/chat/core/session-manager.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/core/storage/indexed-db.ts b/crates/promptforge-ws-server/ui/src/chat/core/storage/indexed-db.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/core/storage/indexed-db.ts rename to crates/promptforge-ws-server/ui/src/chat/core/storage/indexed-db.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/core/storage/remote.ts b/crates/promptforge-ws-server/ui/src/chat/core/storage/remote.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/core/storage/remote.ts rename to crates/promptforge-ws-server/ui/src/chat/core/storage/remote.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/core/store.ts b/crates/promptforge-ws-server/ui/src/chat/core/store.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/core/store.ts rename to crates/promptforge-ws-server/ui/src/chat/core/store.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/core/stream-reducer.ts b/crates/promptforge-ws-server/ui/src/chat/core/stream-reducer.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/core/stream-reducer.ts rename to crates/promptforge-ws-server/ui/src/chat/core/stream-reducer.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/core/types.ts b/crates/promptforge-ws-server/ui/src/chat/core/types.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/core/types.ts rename to crates/promptforge-ws-server/ui/src/chat/core/types.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/.vendor-manifest.json b/crates/promptforge-ws-server/ui/src/chat/highlighter/.vendor-manifest.json similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/highlighter/.vendor-manifest.json rename to crates/promptforge-ws-server/ui/src/chat/highlighter/.vendor-manifest.json diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/THIRD_PARTY_NOTICES.md b/crates/promptforge-ws-server/ui/src/chat/highlighter/THIRD_PARTY_NOTICES.md similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/highlighter/THIRD_PARTY_NOTICES.md rename to crates/promptforge-ws-server/ui/src/chat/highlighter/THIRD_PARTY_NOTICES.md diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/chat.ts b/crates/promptforge-ws-server/ui/src/chat/highlighter/chat.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/highlighter/chat.ts rename to crates/promptforge-ws-server/ui/src/chat/highlighter/chat.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/core.ts b/crates/promptforge-ws-server/ui/src/chat/highlighter/core.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/highlighter/core.ts rename to crates/promptforge-ws-server/ui/src/chat/highlighter/core.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/index.ts b/crates/promptforge-ws-server/ui/src/chat/highlighter/index.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/highlighter/index.ts rename to crates/promptforge-ws-server/ui/src/chat/highlighter/index.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/bash.ts b/crates/promptforge-ws-server/ui/src/chat/highlighter/languages/bash.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/highlighter/languages/bash.ts rename to crates/promptforge-ws-server/ui/src/chat/highlighter/languages/bash.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/c.ts b/crates/promptforge-ws-server/ui/src/chat/highlighter/languages/c.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/highlighter/languages/c.ts rename to crates/promptforge-ws-server/ui/src/chat/highlighter/languages/c.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/clike.ts b/crates/promptforge-ws-server/ui/src/chat/highlighter/languages/clike.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/highlighter/languages/clike.ts rename to crates/promptforge-ws-server/ui/src/chat/highlighter/languages/clike.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/cpp.ts b/crates/promptforge-ws-server/ui/src/chat/highlighter/languages/cpp.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/highlighter/languages/cpp.ts rename to crates/promptforge-ws-server/ui/src/chat/highlighter/languages/cpp.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/csharp.ts b/crates/promptforge-ws-server/ui/src/chat/highlighter/languages/csharp.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/highlighter/languages/csharp.ts rename to crates/promptforge-ws-server/ui/src/chat/highlighter/languages/csharp.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/diff.ts b/crates/promptforge-ws-server/ui/src/chat/highlighter/languages/diff.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/highlighter/languages/diff.ts rename to crates/promptforge-ws-server/ui/src/chat/highlighter/languages/diff.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/dockerfile.ts b/crates/promptforge-ws-server/ui/src/chat/highlighter/languages/dockerfile.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/highlighter/languages/dockerfile.ts rename to crates/promptforge-ws-server/ui/src/chat/highlighter/languages/dockerfile.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/go.ts b/crates/promptforge-ws-server/ui/src/chat/highlighter/languages/go.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/highlighter/languages/go.ts rename to crates/promptforge-ws-server/ui/src/chat/highlighter/languages/go.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/graphql.ts b/crates/promptforge-ws-server/ui/src/chat/highlighter/languages/graphql.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/highlighter/languages/graphql.ts rename to crates/promptforge-ws-server/ui/src/chat/highlighter/languages/graphql.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/index.ts b/crates/promptforge-ws-server/ui/src/chat/highlighter/languages/index.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/highlighter/languages/index.ts rename to crates/promptforge-ws-server/ui/src/chat/highlighter/languages/index.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/java.ts b/crates/promptforge-ws-server/ui/src/chat/highlighter/languages/java.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/highlighter/languages/java.ts rename to crates/promptforge-ws-server/ui/src/chat/highlighter/languages/java.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/javascript.ts b/crates/promptforge-ws-server/ui/src/chat/highlighter/languages/javascript.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/highlighter/languages/javascript.ts rename to crates/promptforge-ws-server/ui/src/chat/highlighter/languages/javascript.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/json.ts b/crates/promptforge-ws-server/ui/src/chat/highlighter/languages/json.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/highlighter/languages/json.ts rename to crates/promptforge-ws-server/ui/src/chat/highlighter/languages/json.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/kotlin.ts b/crates/promptforge-ws-server/ui/src/chat/highlighter/languages/kotlin.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/highlighter/languages/kotlin.ts rename to crates/promptforge-ws-server/ui/src/chat/highlighter/languages/kotlin.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/markdown.ts b/crates/promptforge-ws-server/ui/src/chat/highlighter/languages/markdown.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/highlighter/languages/markdown.ts rename to crates/promptforge-ws-server/ui/src/chat/highlighter/languages/markdown.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/markup.ts b/crates/promptforge-ws-server/ui/src/chat/highlighter/languages/markup.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/highlighter/languages/markup.ts rename to crates/promptforge-ws-server/ui/src/chat/highlighter/languages/markup.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/php.ts b/crates/promptforge-ws-server/ui/src/chat/highlighter/languages/php.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/highlighter/languages/php.ts rename to crates/promptforge-ws-server/ui/src/chat/highlighter/languages/php.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/python.ts b/crates/promptforge-ws-server/ui/src/chat/highlighter/languages/python.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/highlighter/languages/python.ts rename to crates/promptforge-ws-server/ui/src/chat/highlighter/languages/python.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/ruby.ts b/crates/promptforge-ws-server/ui/src/chat/highlighter/languages/ruby.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/highlighter/languages/ruby.ts rename to crates/promptforge-ws-server/ui/src/chat/highlighter/languages/ruby.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/rust.ts b/crates/promptforge-ws-server/ui/src/chat/highlighter/languages/rust.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/highlighter/languages/rust.ts rename to crates/promptforge-ws-server/ui/src/chat/highlighter/languages/rust.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/shared.ts b/crates/promptforge-ws-server/ui/src/chat/highlighter/languages/shared.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/highlighter/languages/shared.ts rename to crates/promptforge-ws-server/ui/src/chat/highlighter/languages/shared.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/sql.ts b/crates/promptforge-ws-server/ui/src/chat/highlighter/languages/sql.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/highlighter/languages/sql.ts rename to crates/promptforge-ws-server/ui/src/chat/highlighter/languages/sql.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/swift.ts b/crates/promptforge-ws-server/ui/src/chat/highlighter/languages/swift.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/highlighter/languages/swift.ts rename to crates/promptforge-ws-server/ui/src/chat/highlighter/languages/swift.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/toml.ts b/crates/promptforge-ws-server/ui/src/chat/highlighter/languages/toml.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/highlighter/languages/toml.ts rename to crates/promptforge-ws-server/ui/src/chat/highlighter/languages/toml.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/typescript.ts b/crates/promptforge-ws-server/ui/src/chat/highlighter/languages/typescript.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/highlighter/languages/typescript.ts rename to crates/promptforge-ws-server/ui/src/chat/highlighter/languages/typescript.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/languages/yaml.ts b/crates/promptforge-ws-server/ui/src/chat/highlighter/languages/yaml.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/highlighter/languages/yaml.ts rename to crates/promptforge-ws-server/ui/src/chat/highlighter/languages/yaml.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/highlighter/theme.css b/crates/promptforge-ws-server/ui/src/chat/highlighter/theme.css similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/highlighter/theme.css rename to crates/promptforge-ws-server/ui/src/chat/highlighter/theme.css diff --git a/crates/promptforge-wb-server/ui/src/chat/index.ts b/crates/promptforge-ws-server/ui/src/chat/index.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/index.ts rename to crates/promptforge-ws-server/ui/src/chat/index.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/main.ts b/crates/promptforge-ws-server/ui/src/chat/main.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/main.ts rename to crates/promptforge-ws-server/ui/src/chat/main.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/plugins/agent-thinking/agent-thinking-plugin.ts b/crates/promptforge-ws-server/ui/src/chat/plugins/agent-thinking/agent-thinking-plugin.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/plugins/agent-thinking/agent-thinking-plugin.ts rename to crates/promptforge-ws-server/ui/src/chat/plugins/agent-thinking/agent-thinking-plugin.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/plugins/agent-thinking/agent-thinking.css b/crates/promptforge-ws-server/ui/src/chat/plugins/agent-thinking/agent-thinking.css similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/plugins/agent-thinking/agent-thinking.css rename to crates/promptforge-ws-server/ui/src/chat/plugins/agent-thinking/agent-thinking.css diff --git a/crates/promptforge-wb-server/ui/src/chat/plugins/attachment/attachment-plugin.ts b/crates/promptforge-ws-server/ui/src/chat/plugins/attachment/attachment-plugin.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/plugins/attachment/attachment-plugin.ts rename to crates/promptforge-ws-server/ui/src/chat/plugins/attachment/attachment-plugin.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/plugins/attachment/attachment.css b/crates/promptforge-ws-server/ui/src/chat/plugins/attachment/attachment.css similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/plugins/attachment/attachment.css rename to crates/promptforge-ws-server/ui/src/chat/plugins/attachment/attachment.css diff --git a/crates/promptforge-wb-server/ui/src/chat/plugins/copy/copy-plugin.ts b/crates/promptforge-ws-server/ui/src/chat/plugins/copy/copy-plugin.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/plugins/copy/copy-plugin.ts rename to crates/promptforge-ws-server/ui/src/chat/plugins/copy/copy-plugin.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/plugins/edit/edit-plugin.ts b/crates/promptforge-ws-server/ui/src/chat/plugins/edit/edit-plugin.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/plugins/edit/edit-plugin.ts rename to crates/promptforge-ws-server/ui/src/chat/plugins/edit/edit-plugin.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/plugins/edit/edit.css b/crates/promptforge-ws-server/ui/src/chat/plugins/edit/edit.css similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/plugins/edit/edit.css rename to crates/promptforge-ws-server/ui/src/chat/plugins/edit/edit.css diff --git a/crates/promptforge-wb-server/ui/src/chat/plugins/settings/settings-plugin.ts b/crates/promptforge-ws-server/ui/src/chat/plugins/settings/settings-plugin.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/plugins/settings/settings-plugin.ts rename to crates/promptforge-ws-server/ui/src/chat/plugins/settings/settings-plugin.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/plugins/settings/settings.css b/crates/promptforge-ws-server/ui/src/chat/plugins/settings/settings.css similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/plugins/settings/settings.css rename to crates/promptforge-ws-server/ui/src/chat/plugins/settings/settings.css diff --git a/crates/promptforge-wb-server/ui/src/chat/plugins/thinking/thinking-plugin.ts b/crates/promptforge-ws-server/ui/src/chat/plugins/thinking/thinking-plugin.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/plugins/thinking/thinking-plugin.ts rename to crates/promptforge-ws-server/ui/src/chat/plugins/thinking/thinking-plugin.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/plugins/thinking/thinking.css b/crates/promptforge-ws-server/ui/src/chat/plugins/thinking/thinking.css similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/plugins/thinking/thinking.css rename to crates/promptforge-ws-server/ui/src/chat/plugins/thinking/thinking.css diff --git a/crates/promptforge-wb-server/ui/src/chat/plugins/tools/tools-plugin.ts b/crates/promptforge-ws-server/ui/src/chat/plugins/tools/tools-plugin.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/plugins/tools/tools-plugin.ts rename to crates/promptforge-ws-server/ui/src/chat/plugins/tools/tools-plugin.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/plugins/tools/tools.css b/crates/promptforge-ws-server/ui/src/chat/plugins/tools/tools.css similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/plugins/tools/tools.css rename to crates/promptforge-ws-server/ui/src/chat/plugins/tools/tools.css diff --git a/crates/promptforge-wb-server/ui/src/chat/router.ts b/crates/promptforge-ws-server/ui/src/chat/router.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/router.ts rename to crates/promptforge-ws-server/ui/src/chat/router.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/styles/base.css b/crates/promptforge-ws-server/ui/src/chat/styles/base.css similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/styles/base.css rename to crates/promptforge-ws-server/ui/src/chat/styles/base.css diff --git a/crates/promptforge-wb-server/ui/src/chat/styles/css.d.ts b/crates/promptforge-ws-server/ui/src/chat/styles/css.d.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/styles/css.d.ts rename to crates/promptforge-ws-server/ui/src/chat/styles/css.d.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/styles/dropdown.css b/crates/promptforge-ws-server/ui/src/chat/styles/dropdown.css similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/styles/dropdown.css rename to crates/promptforge-ws-server/ui/src/chat/styles/dropdown.css diff --git a/crates/promptforge-wb-server/ui/src/chat/styles/feed.css b/crates/promptforge-ws-server/ui/src/chat/styles/feed.css similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/styles/feed.css rename to crates/promptforge-ws-server/ui/src/chat/styles/feed.css diff --git a/crates/promptforge-wb-server/ui/src/chat/styles/input.css b/crates/promptforge-ws-server/ui/src/chat/styles/input.css similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/styles/input.css rename to crates/promptforge-ws-server/ui/src/chat/styles/input.css diff --git a/crates/promptforge-wb-server/ui/src/chat/styles/sidebar.css b/crates/promptforge-ws-server/ui/src/chat/styles/sidebar.css similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/styles/sidebar.css rename to crates/promptforge-ws-server/ui/src/chat/styles/sidebar.css diff --git a/crates/promptforge-wb-server/ui/src/chat/utils/device.ts b/crates/promptforge-ws-server/ui/src/chat/utils/device.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/utils/device.ts rename to crates/promptforge-ws-server/ui/src/chat/utils/device.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/utils/dom.ts b/crates/promptforge-ws-server/ui/src/chat/utils/dom.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/utils/dom.ts rename to crates/promptforge-ws-server/ui/src/chat/utils/dom.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/utils/html.ts b/crates/promptforge-ws-server/ui/src/chat/utils/html.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/utils/html.ts rename to crates/promptforge-ws-server/ui/src/chat/utils/html.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/utils/icons.ts b/crates/promptforge-ws-server/ui/src/chat/utils/icons.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/utils/icons.ts rename to crates/promptforge-ws-server/ui/src/chat/utils/icons.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/utils/sse.ts b/crates/promptforge-ws-server/ui/src/chat/utils/sse.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/utils/sse.ts rename to crates/promptforge-ws-server/ui/src/chat/utils/sse.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/utils/uuid.ts b/crates/promptforge-ws-server/ui/src/chat/utils/uuid.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/utils/uuid.ts rename to crates/promptforge-ws-server/ui/src/chat/utils/uuid.ts diff --git a/crates/promptforge-wb-server/ui/src/chat/with-css.ts b/crates/promptforge-ws-server/ui/src/chat/with-css.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/chat/with-css.ts rename to crates/promptforge-ws-server/ui/src/chat/with-css.ts diff --git a/crates/promptforge-wb-server/ui/src/main.ts b/crates/promptforge-ws-server/ui/src/main.ts similarity index 92% rename from crates/promptforge-wb-server/ui/src/main.ts rename to crates/promptforge-ws-server/ui/src/main.ts index fa9c173e..e75b3352 100644 --- a/crates/promptforge-wb-server/ui/src/main.ts +++ b/crates/promptforge-ws-server/ui/src/main.ts @@ -1,5 +1,5 @@ // murm-ui's own styles, bundled by esbuild into dist/app.css. Sidebar and -// dropdown styles are skipped: the workbench disables the murm sidebar and +// dropdown styles are skipped: the workshop disables the murm sidebar and // no plugin renders dropdowns. import "./chat/styles/base.css"; import "./chat/styles/feed.css"; @@ -14,8 +14,8 @@ import { ChatUI } from "./chat/main"; import { MemoryStorage } from "./memory-storage"; import { StatusBar } from "./status-bar"; import { setupVoice, type VoiceHandle } from "./voice"; -import { WorkbenchProvider } from "./workbench-provider"; -import { type CatalogModel, WorkbenchSocket } from "./workbench-socket"; +import { WorkshopProvider } from "./workshop-provider"; +import { type CatalogModel, WorkshopSocket } from "./workshop-socket"; const pickerEl = document.getElementById("model-picker") as HTMLSelectElement; const descriptionEl = document.getElementById("model-description") as HTMLDivElement; @@ -28,12 +28,12 @@ if (!statusBarRoot) { throw new Error("DOM Error: .status-bar not found in the page."); } const statusBar = new StatusBar(statusBarRoot); -const workbenchSocket = new WorkbenchSocket(); -workbenchSocket.onStatus((frame) => statusBar.render(frame)); +const workshopSocket = new WorkshopSocket(); +workshopSocket.onStatus((frame) => statusBar.render(frame)); // A dropped socket means every in-flight status is stale; the bar returns // to its reconnecting state until the observer speaks again. -workbenchSocket.onDisconnect(() => statusBar.reset()); -workbenchSocket.connect(); +workshopSocket.onDisconnect(() => statusBar.reset()); +workshopSocket.connect(); function selectedModel(): string { return pickerEl.value; @@ -110,7 +110,7 @@ if (!chatContainer) { const chat = new ChatUI({ container: chatContainer as HTMLElement, - provider: new WorkbenchProvider(workbenchSocket), + provider: new WorkshopProvider(workshopSocket), storage: new MemoryStorage(), enableSidebar: false, routing: false, @@ -171,7 +171,7 @@ async function loadModels(): Promise { // A pushed catalog means the gateway returned after an outage; refresh the // picker in place so a boot-time "Model catalog unavailable" heals itself. -workbenchSocket.onModels(renderModels); +workshopSocket.onModels(renderModels); pickerEl.addEventListener("change", () => { showDescription(); diff --git a/crates/promptforge-wb-server/ui/src/memory-storage.ts b/crates/promptforge-ws-server/ui/src/memory-storage.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/memory-storage.ts rename to crates/promptforge-ws-server/ui/src/memory-storage.ts diff --git a/crates/promptforge-wb-server/ui/src/status-bar.ts b/crates/promptforge-ws-server/ui/src/status-bar.ts similarity index 98% rename from crates/promptforge-wb-server/ui/src/status-bar.ts rename to crates/promptforge-ws-server/ui/src/status-bar.ts index e3e5c61b..ab3799c4 100644 --- a/crates/promptforge-wb-server/ui/src/status-bar.ts +++ b/crates/promptforge-ws-server/ui/src/status-bar.ts @@ -5,7 +5,7 @@ // frames are internal instrumentation: they never touch the text or the // slot, but they do pulse the LED. -import type { StatusFrame } from "./workbench-socket"; +import type { StatusFrame } from "./workshop-socket"; type PulseActivity = "thinking" | "generating"; diff --git a/crates/promptforge-wb-server/ui/src/voice.ts b/crates/promptforge-ws-server/ui/src/voice.ts similarity index 100% rename from crates/promptforge-wb-server/ui/src/voice.ts rename to crates/promptforge-ws-server/ui/src/voice.ts diff --git a/crates/promptforge-wb-server/ui/src/workbench-provider.ts b/crates/promptforge-ws-server/ui/src/workshop-provider.ts similarity index 86% rename from crates/promptforge-wb-server/ui/src/workbench-provider.ts rename to crates/promptforge-ws-server/ui/src/workshop-provider.ts index 1e08620f..16b1f628 100644 --- a/crates/promptforge-wb-server/ui/src/workbench-provider.ts +++ b/crates/promptforge-ws-server/ui/src/workshop-provider.ts @@ -1,17 +1,17 @@ import type { ChatProvider, ChatRequest, Message, StreamEvent } from "./chat/core/types"; import { uuidv7 } from "./chat/utils/uuid"; -import type { WorkbenchSocket } from "./workbench-socket"; +import type { WorkshopSocket } from "./workshop-socket"; /** - * ChatProvider against the workbench's persistent `/ws` socket: each + * ChatProvider against the workshop's persistent `/ws` socket: each * generation is one id-tagged chat frame on the shared connection, answered * by `delta`/`done`/`error` frames carrying that id, while status frames * bypass the chat entirely. Deliberately has no `generateTitle`: titles - * cost an extra completion per chat and nothing in the workbench UI + * cost an extra completion per chat and nothing in the workshop UI * displays them. */ -export class WorkbenchProvider implements ChatProvider { - constructor(private readonly socket: WorkbenchSocket) {} +export class WorkshopProvider implements ChatProvider { + constructor(private readonly socket: WorkshopSocket) {} async streamChat(request: ChatRequest, onEvent: (event: StreamEvent) => void): Promise { const messageId = uuidv7(); diff --git a/crates/promptforge-wb-server/ui/src/workbench-socket.ts b/crates/promptforge-ws-server/ui/src/workshop-socket.ts similarity index 96% rename from crates/promptforge-wb-server/ui/src/workbench-socket.ts rename to crates/promptforge-ws-server/ui/src/workshop-socket.ts index 05f48d0c..439f547c 100644 --- a/crates/promptforge-wb-server/ui/src/workbench-socket.ts +++ b/crates/promptforge-ws-server/ui/src/workshop-socket.ts @@ -1,4 +1,4 @@ -// The persistent workbench socket: one WebSocket to /ws carries every +// The persistent workshop socket: one WebSocket to /ws carries every // downstream JSON frame - chat replies for in-flight generations and // unsolicited status updates from the server's observer. Chat requests are // multiplexed by an incrementing id the server echoes on that chat's @@ -58,7 +58,7 @@ function defaultUrl(): string { const RECONNECT_INITIAL_MS = 1000; const RECONNECT_MAX_MS = 30_000; -export class WorkbenchSocket { +export class WorkshopSocket { private socket: WebSocket | null = null; private opening: { socket: WebSocket; promise: Promise } | null = null; private nextId = 1; @@ -109,7 +109,7 @@ export class WorkbenchSocket { await this.ensureOpen(); const socket = this.socket; if (!socket || socket.readyState !== WebSocket.OPEN) { - throw new Error("the workbench socket is not open"); + throw new Error("the workshop socket is not open"); } const id = this.nextId++; await new Promise((resolve, reject) => { @@ -168,7 +168,7 @@ export class WorkbenchSocket { socket.onerror = () => { if (this.socket === socket) this.socket = null; if (this.opening === entry) this.opening = null; - reject(new Error("the workbench socket failed to open")); + reject(new Error("the workshop socket failed to open")); }; }); this.opening = entry; @@ -281,7 +281,7 @@ export class WorkbenchSocket { if (chat.started) { chat.resolve(); } else { - chat.reject(new Error("the workbench socket closed before the reply completed")); + chat.reject(new Error("the workshop socket closed before the reply completed")); } }); } diff --git a/crates/promptforge-wb-server/ui/style.css b/crates/promptforge-ws-server/ui/style.css similarity index 97% rename from crates/promptforge-wb-server/ui/style.css rename to crates/promptforge-ws-server/ui/style.css index 2ea125b8..0730d74d 100644 --- a/crates/promptforge-wb-server/ui/style.css +++ b/crates/promptforge-ws-server/ui/style.css @@ -1,7 +1,7 @@ /* ========================================================================== - PromptForge workbench skin + PromptForge workshop skin - Every visual value the workbench owns is a CSS custom property in the + Every visual value the workshop owns is a CSS custom property in the :root block below: palette, type, spacing, radius, and the status bar's progress and LED effect. Reskinning the UI means editing this one block (or overriding it from an additional stylesheet loaded after this one); @@ -10,7 +10,7 @@ stock skin instead of breaking the property. The murm-ui bridge (the .mur-app block after :root) maps the vendored - chat UI's --mur-* variables onto the workbench variables, so the chat + chat UI's --mur-* variables onto the workshop variables, so the chat panel skins from the same block. It cannot live inside :root: murm-ui declares its dark-theme variables on .mur-app[data-theme="dark"] itself, and a custom property set on the element beats anything inherited from @@ -94,7 +94,7 @@ /* -------------------------------------------------------------------------- murm-ui skinning bridge. The vendored chat UI themes itself from --mur-* variables (ui/src/chat/styles/base.css); mapping them here keeps the - whole UI skinned from the :root block above. Workbench var on the right, + whole UI skinned from the :root block above. Workshop var on the right, murm-ui var on the left: --mur-bg <- --bg chat background @@ -114,7 +114,7 @@ murm-ui's dark shadows and overlay scrims are palette-neutral black alphas and are left as shipped. Only the dark theme is mapped: the - workbench's template always sets data-theme="dark" on .mur-app. + workshop's template always sets data-theme="dark" on .mur-app. -------------------------------------------------------------------------- */ .mur-app[data-theme="dark"] { --mur-bg: var(--bg, #0d0e12); @@ -512,7 +512,7 @@ body { /* Composer overlap fix: make the form container participate in the column flex flow so the scroll area shrinks to accommodate it. The embedded - workbench mode only - murm-ui's standalone keeps absolute positioning. */ + workshop mode only - murm-ui's standalone keeps absolute positioning. */ .mur-app-embedded .mur-chat-form-container { position: relative; } diff --git a/crates/promptforge-wb-server/ui/test/smoke.mjs b/crates/promptforge-ws-server/ui/test/smoke.mjs similarity index 100% rename from crates/promptforge-wb-server/ui/test/smoke.mjs rename to crates/promptforge-ws-server/ui/test/smoke.mjs diff --git a/crates/promptforge-wb-server/ui/tsconfig.json b/crates/promptforge-ws-server/ui/tsconfig.json similarity index 100% rename from crates/promptforge-wb-server/ui/tsconfig.json rename to crates/promptforge-ws-server/ui/tsconfig.json diff --git a/crates/promptforge-wb/Cargo.toml b/crates/promptforge-ws/Cargo.toml similarity index 66% rename from crates/promptforge-wb/Cargo.toml rename to crates/promptforge-ws/Cargo.toml index 410cdd54..a6dcb1f6 100644 --- a/crates/promptforge-wb/Cargo.toml +++ b/crates/promptforge-ws/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "promptforge-wb" +name = "promptforge-ws" version = "0.1.0" edition.workspace = true rust-version.workspace = true @@ -7,21 +7,21 @@ license.workspace = true repository.workspace = true publish = false -description = "PromptForge Workbench desktop window shell" +description = "PromptForge Workshop desktop window shell" [[bin]] -name = "promptforge-wb" +name = "promptforge-ws" path = "src/main.rs" [dependencies] anyhow.workspace = true -promptforge-wb-server.workspace = true +promptforge-ws-server.workspace = true tao.workspace = true wry.workspace = true [features] default = [] -cuda = ["promptforge-wb-server/cuda"] +cuda = ["promptforge-ws-server/cuda"] [dev-dependencies] tempfile.workspace = true diff --git a/crates/promptforge-ws/README.md b/crates/promptforge-ws/README.md new file mode 100644 index 00000000..d9de6213 --- /dev/null +++ b/crates/promptforge-ws/README.md @@ -0,0 +1,33 @@ +# promptforge-ws + +[![License](https://img.shields.io/badge/license-BSL--1.0-blue.svg)](LICENSE) + +The PromptForge Workshop desktop window. It starts the workshop server (`promptforge-ws-server`) in-process on its own thread, waits for its health endpoint to answer, and opens a native window (wry/tao, WebView2 on Windows) pointed at the chat UI. Closing the window shuts the server down cleanly. One binary, one process: the window and the server it frames ship together. + +## Quick start + +```bash +cargo run -p promptforge-ws +``` + +## Configuration + +The shell loads the same `workshop.toml` as the server (see the [promptforge-ws-server README](../promptforge-ws-server/README.md) for the field reference), but searches for it in three places, first found wins. At each place, `workshop.toml` is preferred and a leftover `workbench.toml` is still accepted: + +1. Beside the executable +2. The current directory +3. `%USERPROFILE%\.promptforge\workshop.toml` (then `workbench.toml`) + +On first run - when no file exists at any of these locations - the shell creates `%USERPROFILE%\.promptforge\` if needed, writes a default `workshop.toml` there, logs the path, and loads it. The generated file interpolates the gateway settings from `PROMPTFORGE_GATEWAY_URL` and `PROMPTFORGE_GATEWAY_API_KEY`; unset, they resolve to the built-in defaults (loopback gateway, no `Authorization` header). The app never exits on missing config. + +## Browser fallback + +The shell ignores `server.open_browser` - it has a window. That flag belongs to the server binary: run `promptforge-ws-server` directly with `open_browser = true` to use the workshop as a browser tab instead of a desktop window. + +## Minimum Rust Version + +Rust 1.89 or later. + +## License + +Licensed under the [Boost Software License 1.0](../../LICENSE). diff --git a/crates/promptforge-wb/src/discover.rs b/crates/promptforge-ws/src/discover.rs similarity index 74% rename from crates/promptforge-wb/src/discover.rs rename to crates/promptforge-ws/src/discover.rs index 73ab369b..c23a9c69 100644 --- a/crates/promptforge-wb/src/discover.rs +++ b/crates/promptforge-ws/src/discover.rs @@ -1,4 +1,4 @@ -//! `workbench.toml` discovery for the desktop shell. +//! `workshop.toml` discovery for the desktop shell. //! //! Search order, first found wins: beside the executable, then the current //! directory, then `%USERPROFILE%\.promptforge\` (the user profile's @@ -10,8 +10,11 @@ use std::path::{Path, PathBuf}; use anyhow::Context as _; -/// File name searched for at each candidate location. -const CONFIG_FILE_NAME: &str = "workbench.toml"; +/// Canonical file name searched for at each candidate location. +const CONFIG_FILE_NAME: &str = "workshop.toml"; + +/// Pre-rename name still accepted when `workshop.toml` is absent at a location. +const LEGACY_CONFIG_FILE_NAME: &str = "workbench.toml"; /// The configuration written on first run, when the search finds nothing. /// @@ -19,11 +22,11 @@ const CONFIG_FILE_NAME: &str = "workbench.toml"; /// `PROMPTFORGE_GATEWAY_URL` / `PROMPTFORGE_GATEWAY_API_KEY` set is /// configured from the first launch; unset, they resolve to the built-in /// defaults. Voice provisions itself: the `*_source` URLs let the -/// workbench download the whisper models through the gateway cache once +/// workshop download the whisper models through the gateway cache once /// the gateway connects, then load them. -const DEFAULT_CONFIG_TEMPLATE: &str = r#"# PromptForge Workbench configuration +const DEFAULT_CONFIG_TEMPLATE: &str = r#"# PromptForge Workshop configuration # Generated on first run. Edit as needed. -# See: crates/promptforge-wb-server/README.md +# See: crates/promptforge-ws-server/README.md [gateway] base_url = "${PROMPTFORGE_GATEWAY_URL}" @@ -50,8 +53,9 @@ interval_ms = 500 # vocabulary = ["MCP", "GGUF", "Lua"] "#; -/// Locates `workbench.toml`, searching beside the executable first, then -/// the current directory, then the user profile's `.promptforge` directory. +/// Locates `workshop.toml` (or a leftover `workbench.toml` at the same +/// place), searching beside the executable first, then the current +/// directory, then the user profile's `.promptforge` directory. /// /// Returns `None` when no location holds a config file, allowing the caller /// to generate a default configuration instead. @@ -73,7 +77,7 @@ pub(crate) fn discover_config() -> anyhow::Result> { Ok(first_existing(&candidates)) } -/// The profile candidate: `/.promptforge/workbench.toml`. This is +/// The profile candidate: `/.promptforge/workshop.toml`. This is /// the one place that knows where the profile configuration lives, so /// first-run generation writes where discovery reads. pub(crate) fn profile_config_path(home: &Path) -> PathBuf { @@ -81,12 +85,17 @@ pub(crate) fn profile_config_path(home: &Path) -> PathBuf { } /// Builds the candidate list in search order from the three base -/// directories. +/// directories. At each location, `workshop.toml` is tried before +/// `workbench.toml`. fn candidates_from(exe_dir: &Path, cwd: &Path, home: &Path) -> Vec { + let profile = home.join(".promptforge"); vec![ exe_dir.join(CONFIG_FILE_NAME), + exe_dir.join(LEGACY_CONFIG_FILE_NAME), cwd.join(CONFIG_FILE_NAME), - profile_config_path(home), + cwd.join(LEGACY_CONFIG_FILE_NAME), + profile.join(CONFIG_FILE_NAME), + profile.join(LEGACY_CONFIG_FILE_NAME), ] } @@ -119,8 +128,11 @@ mod tests { assert_eq!( candidates, vec![ + PathBuf::from("exe-dir/workshop.toml"), PathBuf::from("exe-dir/workbench.toml"), + PathBuf::from("cwd-dir/workshop.toml"), PathBuf::from("cwd-dir/workbench.toml"), + PathBuf::from("home-dir/.promptforge/workshop.toml"), PathBuf::from("home-dir/.promptforge/workbench.toml"), ] ); @@ -133,8 +145,8 @@ mod tests { let home_dir = tempfile::TempDir::new().expect("tempdir"); let promptforge = home_dir.path().join(".promptforge"); std::fs::create_dir(&promptforge).expect("create profile dir"); - let in_cwd = cwd_dir.path().join("workbench.toml"); - let in_home = promptforge.join("workbench.toml"); + let in_cwd = cwd_dir.path().join("workshop.toml"); + let in_home = promptforge.join("workshop.toml"); std::fs::write(&in_cwd, "").expect("write fixture"); std::fs::write(&in_home, "").expect("write fixture"); @@ -145,7 +157,7 @@ mod tests { "the current directory beats the profile" ); - let in_exe = exe_dir.path().join("workbench.toml"); + let in_exe = exe_dir.path().join("workshop.toml"); std::fs::write(&in_exe, "").expect("write fixture"); assert_eq!( first_existing(&candidates).as_deref(), @@ -177,12 +189,12 @@ mod tests { fn the_generated_template_loads_with_no_env_vars_set() { let dir = tempfile::TempDir::new().expect("tempdir"); let path = generate_default(&dir.path().join(CONFIG_FILE_NAME)).expect("template writes"); - let config = promptforge_wb_server::Config::load(&path) + let config = promptforge_ws_server::Config::load(&path) .expect("the generated config loads on a bare machine"); // An empty value falls back to the default, same as the loader. let expected_url = match std::env::var("PROMPTFORGE_GATEWAY_URL") { Ok(value) if !value.is_empty() => value, - _ => promptforge_wb_server::DEFAULT_GATEWAY_BASE_URL.to_string(), + _ => promptforge_ws_server::DEFAULT_GATEWAY_BASE_URL.to_string(), }; assert_eq!(config.gateway.base_url, expected_url); assert_eq!( @@ -196,4 +208,20 @@ mod tests { assert!(config.voice.interim_source.starts_with("https://")); assert!(config.voice.final_source.starts_with("https://")); } + + #[test] + fn a_legacy_workbench_toml_is_found_when_workshop_toml_is_absent() { + let exe_dir = tempfile::TempDir::new().expect("tempdir"); + let cwd_dir = tempfile::TempDir::new().expect("tempdir"); + let home_dir = tempfile::TempDir::new().expect("tempdir"); + let promptforge = home_dir.path().join(".promptforge"); + std::fs::create_dir(&promptforge).expect("create profile dir"); + let legacy = promptforge.join(LEGACY_CONFIG_FILE_NAME); + std::fs::write(&legacy, "").expect("write fixture"); + let candidates = candidates_from(exe_dir.path(), cwd_dir.path(), home_dir.path()); + assert_eq!( + first_existing(&candidates).as_deref(), + Some(legacy.as_path()) + ); + } } diff --git a/crates/promptforge-wb/src/health.rs b/crates/promptforge-ws/src/health.rs similarity index 97% rename from crates/promptforge-wb/src/health.rs rename to crates/promptforge-ws/src/health.rs index f16aee9c..90d89208 100644 --- a/crates/promptforge-wb/src/health.rs +++ b/crates/promptforge-ws/src/health.rs @@ -33,7 +33,7 @@ pub(crate) fn wait_for_health(base_url: &str, timeout: Duration) -> anyhow::Resu Err(error) => { if Instant::now() >= deadline { return Err(error).context(format!( - "the workbench server did not answer {base_url}/health within {timeout:?}" + "the workshop server did not answer {base_url}/health within {timeout:?}" )); } std::thread::sleep(RETRY_INTERVAL); diff --git a/crates/promptforge-wb/src/main.rs b/crates/promptforge-ws/src/main.rs similarity index 76% rename from crates/promptforge-wb/src/main.rs rename to crates/promptforge-ws/src/main.rs index 6f5dfc73..aba99d9d 100644 --- a/crates/promptforge-wb/src/main.rs +++ b/crates/promptforge-ws/src/main.rs @@ -1,9 +1,9 @@ -//! The `promptforge-wb` binary: the PromptForge Workbench desktop window +//! The `promptforge-ws` binary: the PromptForge Workshop desktop window //! shell. //! -//! Loads `workbench.toml` (see [`discover`] for the search order), +//! Loads `workshop.toml` (see [`discover`] for the search order), //! generating a default one in the profile's `.promptforge` directory on -//! first run, starts the workbench server in-process on its own thread, +//! first run, starts the workshop server in-process on its own thread, //! waits for its health endpoint to answer, and opens a window pointed at //! it. Closing the window shuts the server down cleanly. @@ -16,7 +16,7 @@ use std::process::ExitCode; use std::time::Duration; use anyhow::Context as _; -use promptforge_wb_server::Config; +use promptforge_ws_server::Config; /// How long the shell waits for the in-process server's health endpoint /// before giving up. @@ -39,15 +39,15 @@ fn run() -> anyhow::Result<()> { }; let config = Config::load(&config_path).with_context(|| format!("load {}", config_path.display()))?; - let server = promptforge_wb_server::spawn(config).context("start workbench server")?; + let server = promptforge_ws_server::spawn(config).context("start workshop server")?; let url = server.url().to_string(); // The server is shut down whether the window ran, failed, or never // opened because the health probe timed out. let window_result = health::wait_for_health(&url, HEALTH_TIMEOUT) - .context("wait for the workbench server") + .context("wait for the workshop server") .and_then(|()| window::run(&url)); - let shutdown_result = server.shutdown().context("stop workbench server"); + let shutdown_result = server.shutdown().context("stop workshop server"); // A shutdown failure stacked on a window failure is reported, not lost. if let (Err(_), Err(shutdown_error)) = (&window_result, &shutdown_result) { eprintln!("{shutdown_error:?}"); @@ -56,7 +56,7 @@ fn run() -> anyhow::Result<()> { } /// First run: creates the profile's `.promptforge` directory if needed and -/// writes the default `workbench.toml` into it. +/// writes the default `workshop.toml` into it. fn generate_in_profile() -> anyhow::Result { let home = std::env::home_dir().context("locate the user profile directory")?; let path = discover::profile_config_path(&home); @@ -66,7 +66,7 @@ fn generate_in_profile() -> anyhow::Result { std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?; let path = discover::generate_default(&path).context("write the default configuration")?; eprintln!( - "no workbench.toml found; wrote default config to {}", + "no workshop.toml found; wrote default config to {}", path.display() ); Ok(path) @@ -75,7 +75,7 @@ fn generate_in_profile() -> anyhow::Result { #[cfg(test)] mod tests { #[test] - fn crate_is_named_promptforge_wb() { - assert_eq!(env!("CARGO_PKG_NAME"), "promptforge-wb"); + fn crate_is_named_promptforge_ws() { + assert_eq!(env!("CARGO_PKG_NAME"), "promptforge-ws"); } } diff --git a/crates/promptforge-wb/src/window.rs b/crates/promptforge-ws/src/window.rs similarity index 92% rename from crates/promptforge-wb/src/window.rs rename to crates/promptforge-ws/src/window.rs index ecd8b6d6..7579c16c 100644 --- a/crates/promptforge-wb/src/window.rs +++ b/crates/promptforge-ws/src/window.rs @@ -1,5 +1,5 @@ //! The desktop window: a tao event loop driving a wry webview pointed at -//! the in-process workbench server. +//! the in-process workshop server. //! //! There is no native menu: tao 0.36 moved menu support out to the `muda` //! crate, and a one-item `File > Quit` menu does not justify the extra @@ -24,7 +24,7 @@ pub(crate) fn run(url: &str) -> anyhow::Result<()> { let window = WindowBuilder::new() .with_title("PromptForge") .build(&event_loop) - .context("create the workbench window")?; + .context("create the workshop window")?; let _webview = WebViewBuilder::new() .with_url(url) .with_permission_handler(|kind| match kind { @@ -32,7 +32,7 @@ pub(crate) fn run(url: &str) -> anyhow::Result<()> { _ => PermissionResponse::Default, }) .build(&window) - .context("create the workbench webview")?; + .context("create the workshop webview")?; let mut event_loop = event_loop; event_loop.run_return(|event, _, control_flow| { diff --git a/design/design-promptforge-workbench.md b/design/design-promptforge-workshop.md similarity index 81% rename from design/design-promptforge-workbench.md rename to design/design-promptforge-workshop.md index 61b25b41..ecd9c8ab 100644 --- a/design/design-promptforge-workbench.md +++ b/design/design-promptforge-workshop.md @@ -1,16 +1,16 @@ -# The PromptForge Workbench +# The PromptForge Workshop ## Executive Summary -A developer environment for PromptForge prompts: a standalone local application - a Rust server crate with a webview shell - where a prompt is a visible, editable stack of blocks, runs are defined by binding a prompt's declared inputs to real files, every run, edit, decision, and mistake is recorded in an append-only, hash-chained event store, and any two versions of anything are compared by a model, with the human making the final call. It does not generate reports, does not edit code, and is not a general-purpose IDE. The workbench designs the prompt; it does not run it in production - finished prompts leave and live elsewhere (Cursor, cloud runs, promptforge-cli), and the workbench tracks no releases and manages no production lineage: there is one current version, the thing at HEAD, with the full immutable development lineage behind it. A built-in agentic harness, landed early at stage 3, lets a frontier model help author prompts with the entire conversation captured natively. +A developer environment for PromptForge prompts: a standalone local application - a Rust server crate with a webview shell - where a prompt is a visible, editable stack of blocks, runs are defined by binding a prompt's declared inputs to real files, every run, edit, decision, and mistake is recorded in an append-only, hash-chained event store, and any two versions of anything are compared by a model, with the human making the final call. It does not generate reports, does not edit code, and is not a general-purpose IDE. The workshop designs the prompt; it does not run it in production - finished prompts leave and live elsewhere (Cursor, cloud runs, promptforge-cli), and the workshop tracks no releases and manages no production lineage: there is one current version, the thing at HEAD, with the full immutable development lineage behind it. A built-in agentic harness, landed early at stage 3, lets a frontier model help author prompts with the entire conversation captured natively. -The thesis: human intent is the source code, and everything downstream - plans, prompts, reports - is a build artifact. Today's harnesses discard the most valuable substance in the workflow: the reasoning, the indecisions, and the mistakes that show why the right answer is right. This workbench captures all of it at infinite resolution - nothing pruned, dead branches archived, everything the model saw snapshotted at access time - and because capture is paid exactly once while extraction is cheap forever, each finished database becomes a teacher that generates tutorials at any fidelity, a process-supervised training corpus of the scarce kind every other harness throws away, and the corpus that trains the workbench's own future PromptForge-speaking authoring model. The data sits still while the extractors improve. The asset appreciates. +The thesis: human intent is the source code, and everything downstream - plans, prompts, reports - is a build artifact. Today's harnesses discard the most valuable substance in the workflow: the reasoning, the indecisions, and the mistakes that show why the right answer is right. This workshop captures all of it at infinite resolution - nothing pruned, dead branches archived, everything the model saw snapshotted at access time - and because capture is paid exactly once while extraction is cheap forever, each finished database becomes a teacher that generates tutorials at any fidelity, a process-supervised training corpus of the scarce kind every other harness throws away, and the corpus that trains the workshop's own future PromptForge-speaking authoring model. The data sits still while the extractors improve. The asset appreciates. Design notes from one creative session. Effort is priced as: small (a component exists), intermediate (something similar exists and adapts), large (a new component with no existing practice). ## Key design choices -**1. The product is a prompt development environment, not an IDE.** The alternative - a general-purpose editor with agent features - was examined and rejected: it duplicates existing editors, carries extreme execution risk, and defers the interesting problem behind a million solved ones (text editing, indexing, terminal emulation, tab completion). The workbench edits PromptForge prompts, runs them, remembers everything, and compares versions. It does not edit codebases and does not run prompts in production: finished prompts leave and live elsewhere, and the workbench tracks no releases - there is one current version, the thing at HEAD, with the full immutable development lineage behind it. The tension this creates: users must still own a general harness for general work, and the workbench must interoperate with whatever that harness is rather than replace it. +**1. The product is a prompt development environment, not an IDE.** The alternative - a general-purpose editor with agent features - was examined and rejected: it duplicates existing editors, carries extreme execution risk, and defers the interesting problem behind a million solved ones (text editing, indexing, terminal emulation, tab completion). The workshop edits PromptForge prompts, runs them, remembers everything, and compares versions. It does not edit codebases and does not run prompts in production: finished prompts leave and live elsewhere, and the workshop tracks no releases - there is one current version, the thing at HEAD, with the full immutable development lineage behind it. The tension this creates: users must still own a general harness for general work, and the workshop must interoperate with whatever that harness is rather than replace it. **2. The event store is the product; every interface is a view.** The single source of truth is an append-only, hash-chained event log - a commit log that forbids force pushes. The block editor, the run panel, the leaderboard, and the chat window are all projections of the log and can be rebuilt from it at any time. The evidence for this choice is the entire feature list: edit history, diffs, revert, run comparison, and replay are queries over the log rather than subsystems of their own. The tension: the log must be a real storage system - chunking, compression, corruption recovery, backup, and garbage collection for tombstoned payloads - because capturing everything forever requires real storage engineering. @@ -20,17 +20,17 @@ Design notes from one creative session. Effort is priced as: small (a component **5. A prompt is edited as blocks, and the prompt is authoritative state.** The UI renders the prompt's sections as a stack of blocks, each showing its prose, its Lua environment, and its offered tools; editing a block edits the section. The prompt file is directly mutable, authoritative state - not a projection of the plan. Regeneration from the plan is a supported workflow, not an invariant; drift detection watches for degradation, and a block edit that contradicts a recorded plan decision raises a conflict event rather than diverging silently. The alternative - prompt prose as a strict build artifact of the plan - lost because it makes the instrument gate the user's judgment, which the first principle forbids. -**6. Fixtures over dependencies: the store is the only dependency a prompt has.** A prompt's frontmatter declares its inputs and outputs as store paths. The workbench binds declared inputs to real files the user picks and never executes a prompt's environment dependencies. The evidence is papergate: its crate links a database client purely to fetch a paper and seed the store, while the prompt itself only reads `paper.md`. In the workbench, the user binds `paper.md` to a sample file and the database is bypassed entirely. The prompt is the core; the crate is one production binding; the workbench is the interactive binding. +**6. Fixtures over dependencies: the store is the only dependency a prompt has.** A prompt's frontmatter declares its inputs and outputs as store paths. The workshop binds declared inputs to real files the user picks and never executes a prompt's environment dependencies. The evidence is papergate: its crate links a database client purely to fetch a paper and seed the store, while the prompt itself only reads `paper.md`. In the workshop, the user binds `paper.md` to a sample file and the database is bypassed entirely. The prompt is the core; the crate is one production binding; the workshop is the interactive binding. **7. Identity is rigorous, durable, and defined before any UI.** Every noun has an explicit identity: Artifact (content-addressed payload), PromptVersion (hash of the prompt's blocks), PlanVersion (hash of the plan's decision record), Run (one invocation of a prompt version with bindings), Variant (a run sharing a fingerprint prefix, produced by re-roll), Branch (a named line of decisions), Decision (a recorded human choice against stable IDs), and Contract (the author-ratified manifest, keyed by durable block IDs, valid while the block-ID set is unchanged). The load-bearing member is the ExecutionFingerprint, split into invocation identity (which run), execution provenance (what produced it, including the capability grants in force), and cacheability policy (what is safe to reuse); without the third part, "same inputs means cache reuse" is unsound the moment tools, MCP servers, web state, or hosted models drift. Blocks and decisions carry stable identifiers independent of heading, position, prose, or serialization - renaming a section does not sever its history - and when identity is severed anyway, the harness detects the orphan and the human relinks it as a recorded decision. -**8. Two model planes, kept separate.** The runtime plane serves the models a prompt uses when it executes: local and open-weight models through the existing gateway, which provides credential isolation, a model catalog, and local GGUF provisioning. The authoring plane is the assistant that helps develop prompts: frontier models by key at first, and eventually a fine-tuned model that speaks the PromptForge language natively, trained on the corpora the workbench produces. The planes share one gateway but never share a context. +**8. Two model planes, kept separate.** The runtime plane serves the models a prompt uses when it executes: local and open-weight models through the existing gateway, which provides credential isolation, a model catalog, and local GGUF provisioning. The authoring plane is the assistant that helps develop prompts: frontier models by key at first, and eventually a fine-tuned model that speaks the PromptForge language natively, trained on the corpora the workshop produces. The planes share one gateway but never share a context. **9. Voice is an input device, and it lives in the server from day one.** Push-to-talk ships in stage 1: the webview captures the microphone, PCM frames stream up a WebSocket while interim transcripts stream down, and the server transcribes in-process on the local GPU - Whisper large-v3-turbo over a sliding window for interims, large-v3 pipelined at silence for the final text, with the session's jargon fed to the decoder as an initial prompt. The evidence for the placement: transcription is neither model plane - it is an input device, not a model a prompt or the author uses - and the session's context is transcription accuracy that no standalone dictation tool can offer. Browser Web Speech and cloud STT are rejected because audio leaves the machine. The tension: VRAM is budgeted statically against the gateway's resident models, so two move-triggers are named in advance - a prompt wanting transcription as a tool moves it to the runtime plane, and the resident set outgrowing the card moves voice to the gateway. **10. The harness is hand-written, headless, and lands early.** The authoring assistant is a tool-call loop against the gateway's OpenAI-compatible interface - a few hundred lines at its core - plus a tool suite and the recorder. The Claude Agent SDK was considered and rejected: it is TypeScript/Python only, Claude-only (which kills the two-plane design and the future fine-tune), and its loop decides what gets recorded, which is the product's core asset. The harness needs the full capability suite - shell, whole-drive files, web fetch, MCP client, subagents - because research and development interleave in one conversation. It is the dominant implementation risk and lands at stage 3 anyway, because a daily-drivable tool early is what makes everything else get used, and the stage-1 raw tape means capture never waits for the database. -**11. Plan mode is the entropy filter.** The harness aggregates human intent into a plan document before any action is taken: small design decisions accumulate across a conversation - some reversing each other - the user reviews and cleans the plan, and applying it produces one clean edit instead of the conversation's raw churn. The evidence is the semantic-blur lesson seen from the other side: decisions are discrete and survive translation while prose drifts, so the plan is preserved and the artifact regenerated; a workbench without this filter is a chat with tools. The tension: the prompt text is the weakest of the three conditioning layers - the redefined deliverable (maintaining the plan IS the job) and the structural tool gate (reads, search, and one write target) carry the load - so compliance is never left to instruction alone, and leakage is measurable as blocked-mutation events in the log. +**11. Plan mode is the entropy filter.** The harness aggregates human intent into a plan document before any action is taken: small design decisions accumulate across a conversation - some reversing each other - the user reviews and cleans the plan, and applying it produces one clean edit instead of the conversation's raw churn. The evidence is the semantic-blur lesson seen from the other side: decisions are discrete and survive translation while prose drifts, so the plan is preserved and the artifact regenerated; a workshop without this filter is a chat with tools. The tension: the prompt text is the weakest of the three conditioning layers - the redefined deliverable (maintaining the plan IS the job) and the structural tool gate (reads, search, and one write target) carry the load - so compliance is never left to instruction alone, and leakage is measurable as blocked-mutation events in the log. **12. The leaderboard replaces the tree view.** Branches are navigated by their outputs, not by topology: the screen shows artifacts with model-computed diffs and structured verdicts, not a commit graph. A verdict is not a scalar: each evaluation record holds the rubric dimensions, the critic's identity and version, deterministic checks, candidate ordering, confidence or abstention, run cost and latency, and the human's judgment, stored separately, with later calibration of the critic against the human's calls. The rationale is the generator-evaluator asymmetry: evaluation is cheaper and more reliable than generation, the critic's ceiling is good-versus-great, so the final call stays with the human. @@ -42,18 +42,18 @@ Design notes from one creative session. Effort is priced as: small (a component ## The model planes and the data economics -The workbench serves two distinct model roles, kept separate: +The workshop serves two distinct model roles, kept separate: - **Runtime plane** - the models a prompt uses when it executes. Local and open-weight models routed through the promptforge-gateway, which already exists: credential isolation, model catalog, GGUF provisioning via llama-server. Effort: small. -- **Voice is day one, and it lives in the server, not the gateway.** Push-to-talk in the chat panel: the webview captures the microphone (getUserMedia plus AudioWorklet), PCM frames stream up a WebSocket while interim transcripts stream down it, and the workbench server runs transcription in-process on the local GPU - real interims, no child process, no gateway involvement at all. Models: accuracy leads, latency follows. Whisper large-v3-turbo over a sliding window for interims (the visible forming-and-correcting behavior is inherent to window re-transcription), and Whisper large-v3 for the final text (the accuracy ceiling, strongest domain vocabulary for the user's C++ standardization dictation). The final pass is pipelined: large-v3 processes accumulated audio in the background during speech, segmented at silence by voice activity detection, each chunk conditioned on the previous transcript so context survives segmentation; on stop, only the unprocessed tail needs one conditioned call, so the wait is the tail's length, not the utterance's. One family, one runtime: whisper-rs and whisper.cpp; sherpa-onnx and Parakeet are out. Context determines transcription accuracy on homophones and domain terms: Whisper's decoder is a real language model conditioned on the full utterance (its accuracy edge), Parakeet's transducer attends weakly (its vocabulary weakness). Three exploits: vocabulary biasing via Whisper's initial prompt (feed it the session's jargon), the two-stage split confining Parakeet to interims, and an optional LLM cleanup pass on stop that sees the recent chat - the session's context becomes transcription accuracy, something no standalone dictation tool can do. The deciding principle: the gateway fronts the two model planes, and transcription is neither - it is an input device, not a model a prompt or the author uses. Crash isolation is a non-concern: if whisper has a bug, it gets fixed. If a prompt ever wants transcription as a tool, it becomes runtime-plane and moves to the gateway then. VRAM is budgeted statically, not arbitrated: the resident set is small and known (whisper pinned at about a gigabyte; the LLM sized by `gpu_layers` and KV-cache type in gateway config), and the gateway's device/lane concurrency governs the models it serves. Voice is bursty and tiny, so the workloads rarely contend in time. A second move-trigger for voice: the resident set outgrows the card, at which point voice moves to the gateway so one process sees the whole budget. Interim transcripts re-transcribe a sliding window, so the last few seconds rewrite as they resolve; stop, review, send. Browser Web Speech API and cloud STT are rejected: audio leaves the machine. Channels are per-purpose: SSE for downstream-only streams (chat tokens, events), WebSocket for the bidirectional voice channel. Effort: intermediate. +- **Voice is day one, and it lives in the server, not the gateway.** Push-to-talk in the chat panel: the webview captures the microphone (getUserMedia plus AudioWorklet), PCM frames stream up a WebSocket while interim transcripts stream down it, and the workshop server runs transcription in-process on the local GPU - real interims, no child process, no gateway involvement at all. Models: accuracy leads, latency follows. Whisper large-v3-turbo over a sliding window for interims (the visible forming-and-correcting behavior is inherent to window re-transcription), and Whisper large-v3 for the final text (the accuracy ceiling, strongest domain vocabulary for the user's C++ standardization dictation). The final pass is pipelined: large-v3 processes accumulated audio in the background during speech, segmented at silence by voice activity detection, each chunk conditioned on the previous transcript so context survives segmentation; on stop, only the unprocessed tail needs one conditioned call, so the wait is the tail's length, not the utterance's. One family, one runtime: whisper-rs and whisper.cpp; sherpa-onnx and Parakeet are out. Context determines transcription accuracy on homophones and domain terms: Whisper's decoder is a real language model conditioned on the full utterance (its accuracy edge), Parakeet's transducer attends weakly (its vocabulary weakness). Three exploits: vocabulary biasing via Whisper's initial prompt (feed it the session's jargon), the two-stage split confining Parakeet to interims, and an optional LLM cleanup pass on stop that sees the recent chat - the session's context becomes transcription accuracy, something no standalone dictation tool can do. The deciding principle: the gateway fronts the two model planes, and transcription is neither - it is an input device, not a model a prompt or the author uses. Crash isolation is a non-concern: if whisper has a bug, it gets fixed. If a prompt ever wants transcription as a tool, it becomes runtime-plane and moves to the gateway then. VRAM is budgeted statically, not arbitrated: the resident set is small and known (whisper pinned at about a gigabyte; the LLM sized by `gpu_layers` and KV-cache type in gateway config), and the gateway's device/lane concurrency governs the models it serves. Voice is bursty and tiny, so the workloads rarely contend in time. A second move-trigger for voice: the resident set outgrows the card, at which point voice moves to the gateway so one process sees the whole budget. Interim transcripts re-transcribe a sliding window, so the last few seconds rewrite as they resolve; stop, review, send. Browser Web Speech API and cloud STT are rejected: audio leaves the machine. Channels are per-purpose: SSE for downstream-only streams (chat tokens, events), WebSocket for the bidirectional voice channel. Effort: intermediate. - **Authoring plane** - the assistant that helps the user develop the prompt. Frontier models via BYOK through the gateway at first; eventually a fine-tuned model that speaks the PromptForge language natively. The model stays fully general - it can read arbitrary files, research a domain, convert a foreign prompt into PromptForge form - because research and development interleave in one conversation. -- **The chat panel is a full harness, landed early.** The authoring conversation is the most valuable data the workbench captures - the human's reasoning at the moment of each decision, which transcripts prove cannot be reconstructed afterward (the chatlight-architect log is the evidence). The research/develop split fails because the two interleave in one conversation: building the mailing-list conversation-starter prompt IS researching the mailing list. So the harness needs the full capability suite: shell, whole-drive file access, web fetch (promptforge-webfetch), MCP client, subagents, intermediate reports, plus the dozen prompt-domain tools. This is the Claude Code shape - full capability, no editor. But the harness is **known but expensive**, not novel, and it is the dominant implementation risk. It lands at stage 3 anyway: a daily-drivable tool early is what makes everything else get used, and the stage-1 raw tape means capture never waits for the database. The distinctive loop (structured prompt -> recorded execution -> variant -> comparison -> human choice) completes at stage 5; chatlight harvest bridges nothing, because capture runs from the first commit. The hard part is tool quality: the domain helps where it can (prompt files are structured, so edits are addressed by section name; the reference corpus is fixed and pre-loadable), but the general suite must be good, not merely present. Effort: intermediate-large. -- **Cost tradeoff, accepted.** In-workbench chat pays per token through the gateway (BYOK) rather than riding the Claude subscription through Claude Code. Accepted because the data is the point. If the fine-tuned PromptForge-speaking model materializes, the authoring conversation migrates to it and the API cost goes to zero; the panel built for capture becomes the panel the fine-tune lives in. +- **The chat panel is a full harness, landed early.** The authoring conversation is the most valuable data the workshop captures - the human's reasoning at the moment of each decision, which transcripts prove cannot be reconstructed afterward (the chatlight-architect log is the evidence). The research/develop split fails because the two interleave in one conversation: building the mailing-list conversation-starter prompt IS researching the mailing list. So the harness needs the full capability suite: shell, whole-drive file access, web fetch (promptforge-webfetch), MCP client, subagents, intermediate reports, plus the dozen prompt-domain tools. This is the Claude Code shape - full capability, no editor. But the harness is **known but expensive**, not novel, and it is the dominant implementation risk. It lands at stage 3 anyway: a daily-drivable tool early is what makes everything else get used, and the stage-1 raw tape means capture never waits for the database. The distinctive loop (structured prompt -> recorded execution -> variant -> comparison -> human choice) completes at stage 5; chatlight harvest bridges nothing, because capture runs from the first commit. The hard part is tool quality: the domain helps where it can (prompt files are structured, so edits are addressed by section name; the reference corpus is fixed and pre-loadable), but the general suite must be good, not merely present. Effort: intermediate-large. +- **Cost tradeoff, accepted.** In-workshop chat pays per token through the gateway (BYOK) rather than riding the Claude subscription through Claude Code. Accepted because the data is the point. If the fine-tuned PromptForge-speaking model materializes, the authoring conversation migrates to it and the API cost goes to zero; the panel built for capture becomes the panel the fine-tune lives in. - **Chat capture via harvest remains as the bridge.** For conversations that happen in the external harness anyway, chatlight's extractors harvest the transcript, linked to run records by time and tool calls. Crude, but already built. - **Routing, not replacement.** The fine-tuned PromptForge-speaking model handles well-worn paths (routine prompt editing); the frontier model handles open-ended work (foreign prompts, unfamiliar domains). Capability-adaptive surfacing applied to the authoring plane. - **The parser serves the fine-tune twice; the tokenizer is never touched.** Custom tokenization (Lua constructs as single tokens) is rejected - embedding surgery on a fine-tune, high cost, low return, since Lua is well-represented in every code model's training data. Instead: the parser aligns training data at block granularity (chat excerpt to the exact section that changed), and at inference the model's Lua fences are grammar-constrained (GBNF via llama-server) so output cannot fail to parse and `jump` targets and store paths name only things that exist. Effort: intermediate, and the gateway already serves the runtime that supports it. -- **Dogfooding.** The authoring assistant's promptforge-facing behavior is itself expressed as a PromptForge prompt where possible, developed inside the workbench, subject to the same run records and comparison as everything else. -- **Each database is a training-data generator.** The workbench records every chat -> plan -> prompt chain with full history - the corpus a PromptForge-speaking fine-tune needs, process-supervised rather than outcome-supervised. Extraction kinds: SFT pairs (chat excerpt -> plan delta, plan -> prompt), preference-pair candidates from branch choices, full process traces when models can consume them. The workbench's outputs are prompts and datasets. +- **Dogfooding.** The authoring assistant's promptforge-facing behavior is itself expressed as a PromptForge prompt where possible, developed inside the workshop, subject to the same run records and comparison as everything else. +- **Each database is a training-data generator.** The workshop records every chat -> plan -> prompt chain with full history - the corpus a PromptForge-speaking fine-tune needs, process-supervised rather than outcome-supervised. Extraction kinds: SFT pairs (chat excerpt -> plan delta, plan -> prompt), preference-pair candidates from branch choices, full process traces when models can consume them. The workshop's outputs are prompts and datasets. ## The core design @@ -61,15 +61,15 @@ The workbench serves two distinct model roles, kept separate: Three UI elements, each a renderer for something promptforge already has: -- **The block stack** - the prompt's H1/H2 sections as vertically stacked blocks. Each block shows its prose, its Lua environment, and the tools it offers (`tools.need`, `tools.add`); editing a block edits the section. Pre-loaded store files are rulebooks. Nothing here is invented; the workbench visualizes and edits what promptforge-core already executes. +- **The block stack** - the prompt's H1/H2 sections as vertically stacked blocks. Each block shows its prose, its Lua environment, and the tools it offers (`tools.need`, `tools.add`); editing a block edits the section. Pre-loaded store files are rulebooks. Nothing here is invented; the workshop visualizes and edits what promptforge-core already executes. - **The internal filesystem** - the store, visualized as icons, grouped by kind (inputs, intermediates, outputs). After a run, every file the run touched is visible, including intermediates. This is the peel-back's third layer: face, mechanism, evidence. - **The run definition** - a named invocation of a specific prompt: these input bindings, this model profile. Runs are what the run-record database remembers; bindings are part of the record, so "same prompt, different sample input" is a first-class comparison on the leaderboard. -**Fixtures over dependencies.** A prompt's frontmatter declares its inputs and outputs as store paths (`input: path: paper.md`). The workbench binds declared inputs to real files the user picks. It never executes a prompt's environment dependencies: papergate-the-crate links paperstore and paperstore-sqlite only to fetch the paper and seed the store, so in the workbench you bind `paper.md` to a sample markdown and the database is bypassed entirely. The prompt is the core; the crate is one production binding; the workbench is the interactive binding. The store is the only dependency a prompt actually has. Effort: small to intermediate. +**Fixtures over dependencies.** A prompt's frontmatter declares its inputs and outputs as store paths (`input: path: paper.md`). The workshop binds declared inputs to real files the user picks. It never executes a prompt's environment dependencies: papergate-the-crate links paperstore and paperstore-sqlite only to fetch the paper and seed the store, so in the workshop you bind `paper.md` to a sample markdown and the database is bypassed entirely. The prompt is the core; the crate is one production binding; the workshop is the interactive binding. The store is the only dependency a prompt actually has. Effort: small to intermediate. **The workspace and the supporting cast.** The workspace is a designated directory holding reference material - rulebooks, how-tos, sample inputs - that is not inside any prompt yet but must be referenceable. It is the tree browser's root, the @-mention source in chat, and the binding picker's source for fixtures. It stays plain files, editable anywhere; the event store snapshots on read, per the capture rule. The remaining window furniture is all library-grade web components, none of it the IDE: a tree browser, a plain text editor for workspace files, markdown preview (which also renders web-fetch results, so no embedded browser is needed), @-mention parsing, and a command-output display for the shell tool. An interactive terminal (xterm.js plus a PTY) is optional and intermediate; everything else is small. The IDE trap was never "has a file tree" - it is the intelligence layer (language servers, tab completion, debugging, extension APIs), and none of that is in scope. -**Lua intelligence without the LSP protocol.** The block editor gets Lua highlighting from the editor library, diagnostics from promptforge-core's parser over the existing server (fence parse errors, `jump` targets that name real sections, `store.read` paths against declared inputs), and completion from a static table of the promptforge Lua API plus file-local symbols (section names, store paths). A custom LSP is rejected: the protocol's value is interoperating with other people's editors, and this editor is ours. If the workbench ever lives inside a foreign editor, an LSP wrapper is the adapter - later, if ever. Effort: small. +**Lua intelligence without the LSP protocol.** The block editor gets Lua highlighting from the editor library, diagnostics from promptforge-core's parser over the existing server (fence parse errors, `jump` targets that name real sections, `store.read` paths against declared inputs), and completion from a static table of the promptforge Lua API plus file-local symbols (section names, store paths). A custom LSP is rejected: the protocol's value is interoperating with other people's editors, and this editor is ours. If the workshop ever lives inside a foreign editor, an LSP wrapper is the adapter - later, if ever. Effort: small. ### 2. The character sheet is concrete @@ -165,27 +165,27 @@ A model on the side, patiently watching runs and completions, coming to conclusi ### 12. Semantic blur defense becomes structure -The prompt is authoritative, directly editable state - full stop. The semantic-blur defense is a supported workflow, not an invariant: plans hold the discrete decisions, regeneration from the plan is always available, the sidecar's blur metrics watch for drift, and a block edit that contradicts a recorded plan decision raises a conflict event. The instrument advises; the user edits. Effort: small (it is how the workbench works, not a feature). +The prompt is authoritative, directly editable state - full stop. The semantic-blur defense is a supported workflow, not an invariant: plans hold the discrete decisions, regeneration from the plan is always available, the sidecar's blur metrics watch for drift, and a block edit that contradicts a recorded plan decision raises a conflict event. The instrument advises; the user edits. Effort: small (it is how the workshop works, not a feature). ### 13. Plan mode is the entropy filter -The harness has a plan mode, and it is load-bearing. Its function: aggregate human intent into a document before any action is taken. The user accumulates small design decisions across a conversation - some reversing each other - and the plan collects them; the user reviews, revises, and cleans the plan; applying it produces one clean edit instead of the conversation's raw churn. The plan is a filter for entropy, and a workbench without it is a chat with tools. +The harness has a plan mode, and it is load-bearing. Its function: aggregate human intent into a document before any action is taken. The user accumulates small design decisions across a conversation - some reversing each other - and the plan collects them; the user reviews, revises, and cleans the plan; applying it produces one clean edit instead of the conversation's raw churn. The plan is a filter for entropy, and a workshop without it is a chat with tools. This is the semantic-blur lesson seen from the other side. That lesson: the plan escapes degradation because decisions are discrete and survive translation while prose is continuous and drifts - so preserve the plan and regenerate the artifact. This section: the plan filters entropy because accumulated decisions get reviewed and cleaned before they are applied. One object, two jobs - the discrete, reviewable compression of intent - and section 12's regenerate-don't-rewrite workflow is what plan mode feeds. -Mechanically it is cheap because the pieces already exist. Plan mode is a capability profile on the character sheet - mutation tools gated off except writes to the plan document - plus a system-prompt block. The plan document lives in the event store: its evolution is recorded, PlanVersion tracks its decision record, and the apply moment is an event. The architect workflow lives here: chat to plan to prompt is the workbench's core loop, and plan mode is where the middle step happens. +Mechanically it is cheap because the pieces already exist. Plan mode is a capability profile on the character sheet - mutation tools gated off except writes to the plan document - plus a system-prompt block. The plan document lives in the event store: its evolution is recorded, PlanVersion tracks its decision record, and the apply moment is an event. The architect workflow lives here: chat to plan to prompt is the workshop's core loop, and plan mode is where the middle step happens. The conditioning that makes plan mode work is three layers, and the prompt is the weakest. First, the deliverable is redefined: the model's job is maintaining the plan, so a user instruction triggers integration, not execution - recording is the job, not a suppressed impulse. Second, the tool gate removes capability: in plan mode the harness offers reads, search, and one write target, the plan document, so compliance is structural rather than requested. Third, the prompt handles only the residue: state the invariant, teach the integration behavior with examples, define what "apply" means. Conditioning failures are measurable: an attempted blocked mutation in plan mode is an event in the log, so leakage is counted and compared across prompt versions rather than tuned on vibes. -The system prompt does not start from a blank page. The architect tool is a battle-tested plan-mode prompt - accumulate intent, one question per turn, reconcile into the plan, seal and hand off - and the workbench's plan mode starts as its distillation. From there it improves through the product's own loop: the plan-mode prompt is itself a PromptForge prompt, versioned and branched, with candidates compared on the leaderboard and the sidecar watching which produces cleaner plans. The hard part of plan mode is the accumulation mechanics and the tool gating, which the design already has; the prompt text is the soft part, and iterating on soft parts is what the workbench exists for. Effort: small to intermediate. +The system prompt does not start from a blank page. The architect tool is a battle-tested plan-mode prompt - accumulate intent, one question per turn, reconcile into the plan, seal and hand off - and the workshop's plan mode starts as its distillation. From there it improves through the product's own loop: the plan-mode prompt is itself a PromptForge prompt, versioned and branched, with candidates compared on the leaderboard and the sidecar watching which produces cleaner plans. The hard part of plan mode is the accumulation mechanics and the tool gating, which the design already has; the prompt text is the soft part, and iterating on soft parts is what the workshop exists for. Effort: small to intermediate. ### 14. The fan-out lesson is the design rationale for the whole comparison layer -AI generates the typical and judges the specific; evaluation is cheaper and more reliable than generation; the critic's ceiling is good-vs-great, so the final call stays with the human. Every comparison surface in the workbench seats the model as critic and the human as judge. For code-shaped outputs the critic weakens (architecture judgment is good-vs-great territory), which is one more reason multi-file code mutation is out of scope. Effort: not a component; a stance. +AI generates the typical and judges the specific; evaluation is cheaper and more reliable than generation; the critic's ceiling is good-vs-great, so the final call stays with the human. Every comparison surface in the workshop seats the model as critic and the human as judge. For code-shaped outputs the critic weakens (architecture judgment is good-vs-great territory), which is one more reason multi-file code mutation is out of scope. Effort: not a component; a stance. ## Principles -- **The instrument advises; the user decides.** Nothing in the workbench may force a restricted or modal choice about *judgment*. But no-modal is a UX rule, not a security model: git is a recovery mechanism, not a boundary - it does nothing against exfiltration, secrets in logs, MCP side effects, or destructive commands outside the repo. The harness gets capability-scoped permissions (filesystem scopes, network allowlists, tool permissions) declared per character sheet, enforced in the tool layer, with provenance on every action and every enforcement decision logged. The user experience stays approval-free; the ceiling on what an agent can touch is infrastructure, not interruption. +- **The instrument advises; the user decides.** Nothing in the workshop may force a restricted or modal choice about *judgment*. But no-modal is a UX rule, not a security model: git is a recovery mechanism, not a boundary - it does nothing against exfiltration, secrets in logs, MCP side effects, or destructive commands outside the repo. The harness gets capability-scoped permissions (filesystem scopes, network allowlists, tool permissions) declared per character sheet, enforced in the tool layer, with provenance on every action and every enforcement decision logged. The user experience stays approval-free; the ceiling on what an agent can touch is infrastructure, not interruption. - **Human inputs are the source code.** Everything downstream - plans, prompts, reports - is a build artifact. Version the source; regenerate the artifacts. - **Programs live in code, not prompts.** The chatlight lesson: extractors baked into prompts waste output tokens and risk paraphrase under context pressure. Executable machinery ships as code the agent calls. - **Discrete survives translation; continuous does not.** Decisions, manifests, and IDs are discrete. Prose is continuous. Route everything that must survive through the discrete channel. @@ -198,7 +198,7 @@ AI generates the typical and judges the specific; evaluation is cheaper and more flowchart TD SHELL[Desktop shell: wry window] --> SRV[Server crate: harness, promptforge-core, event store, gateway client] BROWSER[Browser tab] --> SRV - EXT[External harness e.g. Claude Code] --> MCP[Workbench MCP interface] + EXT[External harness e.g. Claude Code] --> MCP[Workshop MCP interface] MCP --> SRV SRV --> DB[(Append-only event store)] SRV --> GW[promptforge-gateway] @@ -209,7 +209,7 @@ flowchart TD - **Two crates plus thin frames.** The server crate holds all the smarts and serves the web UI over localhost HTTP; the shell crate embeds a native webview window (wry/tao; WebView2 on Windows, already installed) so the app runs standalone with no browser and no Cursor. Development happens standalone with a `cargo run` iteration loop. The Cursor plugin is optional and probably never: it adds no capability, only relocates the window; if ever wanted, it is a webview pointed at the same server. Frames multiply; the core never changes. - **The harness is hand-written and headless.** A tool-call loop against the gateway's OpenAI-compatible interface, plus the tool set, plus the recorder; the UI is views over the recorded stream. The Claude Agent SDK was considered and rejected: TypeScript/Python only (a sidecar runtime in a Rust product), Claude-only (kills the two-plane design and the future fine-tune), and its loop decides what gets recorded, which is the product's core asset. The loop is a few hundred lines; the SDK's feature list (compaction, hooks, subagent lifecycle, session resume) serves as the checklist for the polish. Tool calling is a model capability: the gateway catalog's `tool_dialect`/`tools_mode` fields let the harness check before offering tools. -- **File polish is views over the recorder, not new subsystems.** The harness mutates files through one tool, so the UI learns of changes from the event stream it already subscribes to. Filesystem watching exists for exactly one job: detecting out-of-workbench edits to managed prompt files and appending them as import events. Edit history is a query over the append-only database; diffs reuse the leaderboard's comparison component; revert is restoring a pinned artifact by hash. The IDE makes these features hard because its ground truth is the filesystem; here the recorder is the ground truth and the filesystem is the side effect. The editor stack (LSP, Tab, highlighting, terminal emulation) remains out of scope. Effort: intermediate, mostly shared components. +- **File polish is views over the recorder, not new subsystems.** The harness mutates files through one tool, so the UI learns of changes from the event stream it already subscribes to. Filesystem watching exists for exactly one job: detecting out-of-workshop edits to managed prompt files and appending them as import events. Edit history is a query over the append-only database; diffs reuse the leaderboard's comparison component; revert is restoring a pinned artifact by hash. The IDE makes these features hard because its ground truth is the filesystem; here the recorder is the ground truth and the filesystem is the side effect. The editor stack (LSP, Tab, highlighting, terminal emulation) remains out of scope. Effort: intermediate, mostly shared components. - promptforge-core: the execution engine, unchanged in role. The seam is the store: making it persistent and content-addressed is where engine meets database. - promptforge-gateway: serves both model planes. - promptforge-mcp-server: interop, so the prompts under development remain callable from other harnesses. diff --git a/design/design-promptforge-wb-1.md b/design/design-promptforge-ws-1.md similarity index 90% rename from design/design-promptforge-wb-1.md rename to design/design-promptforge-ws-1.md index 96ff9775..c00abf3d 100644 --- a/design/design-promptforge-wb-1.md +++ b/design/design-promptforge-ws-1.md @@ -1,59 +1,59 @@ -# Design Choices: promptforge-wb stage 1 +# Design Choices: promptforge-ws stage 1 -Running log of design choices for the PromptForge Workbench stage 1 build. Each entry states the choice, the evidence behind it, and the cost it carries. +Running log of design choices for the PromptForge Workshop stage 1 build. Each entry states the choice, the evidence behind it, and the cost it carries. -## 1. Two crates, promptforge-wb-server and promptforge-wb, as workspace members +## 1. Two crates, promptforge-ws-server and promptforge-ws, as workspace members -- Choice: the workbench is split into an HTTP server binary (`promptforge-wb-server`) and a desktop window shell binary (`promptforge-wb`), both globbed into the promptforge workspace as `crates/*` members. -- Evidence: the workspace already ships one binary per concern (gateway, mcp-server, cli), and the workbench needs a local HTTP API the shell and a browser can both reach, which is two processes with different dependency stacks. +- Choice: the workshop is split into an HTTP server binary (`promptforge-ws-server`) and a desktop window shell binary (`promptforge-ws`), both globbed into the promptforge workspace as `crates/*` members. +- Evidence: the workspace already ships one binary per concern (gateway, mcp-server, cli), and the workshop needs a local HTTP API the shell and a browser can both reach, which is two processes with different dependency stacks. - Cost: two binaries to build, run, and eventually package together; the shell cannot call server internals directly and must go through the HTTP API. ## 2. axum for the HTTP server -- Choice: the workbench server is built on axum 0.8 with tower for testing. +- Choice: the workshop server is built on axum 0.8 with tower for testing. - Evidence: axum is the ecosystem default HTTP server in the rust rulebook, is already a workspace dependency used by promptforge-gateway, and its Router is directly testable with tower's `ServiceExt::oneshot` without binding a port. -- Cost: pulls the tokio/tower stack into the workbench; handlers are async even where the work is synchronous. +- Cost: pulls the tokio/tower stack into the workshop; handlers are async even where the work is synchronous. ## 3. Default bind 127.0.0.1:7910 - Choice: the server binds to 127.0.0.1:7910 when no override is given. -- Evidence: the workbench is a local companion to the desktop shell, so loopback-only is the safe default; 7910 is an unassigned high port that does not collide with common development servers. +- Evidence: the workshop is a local companion to the desktop shell, so loopback-only is the safe default; 7910 is an unassigned high port that does not collide with common development servers. - Cost: the port is fixed rather than discovered, so a collision fails the bind instead of picking another port; remote access requires an explicit future override. ## 4. wry/tao deferred to the shell step -- Choice: `promptforge-wb` is an empty binary skeleton; the wry/tao window and its dependency tree are not added yet. +- Choice: `promptforge-ws` is an empty binary skeleton; the wry/tao window and its dependency tree are not added yet. - Evidence: wry/tao pull in large platform GUI stacks (WebView2, gtk) that dominate build time, and nothing in the scaffolding step needs a window; keeping them out lets the workspace build fast while the server takes shape. - Cost: the shell cannot be run end to end until the shell step lands, and the wry/tao integration risk is concentrated there instead of spread out. -## 5. Fresh config module in promptforge-wb-server, not promptforge-gateway-config reuse +## 5. Fresh config module in promptforge-ws-server, not promptforge-gateway-config reuse -- Choice: `workbench.toml` loading lives in a small `config` module inside promptforge-wb-server; the promptforge-gateway-config crate is not a dependency. -- Evidence: gateway-config's `Config` models the gateway's own server (models, endpoints, dominions, profiles, recursive includes), a different shape from the workbench's client-side view (`gateway.base_url`, `gateway.api_key`, `tape.path`, `server.bind`), and its `${VAR}` interpolation is `pub(crate)`, so reuse would require widening that crate's public surface for a consumer with no overlap in schema. The workbench mirrors its convention instead: parse TOML to a `toml::Value`, interpolate string leaves only (CFG-007 semantics), then deserialize, with `$$` as a literal `$` and unresolved variables as errors. +- Choice: `workshop.toml` loading lives in a small `config` module inside promptforge-ws-server; the promptforge-gateway-config crate is not a dependency. +- Evidence: gateway-config's `Config` models the gateway's own server (models, endpoints, dominions, profiles, recursive includes), a different shape from the workshop's client-side view (`gateway.base_url`, `gateway.api_key`, `tape.path`, `server.bind`), and its `${VAR}` interpolation is `pub(crate)`, so reuse would require widening that crate's public surface for a consumer with no overlap in schema. The workshop mirrors its convention instead: parse TOML to a `toml::Value`, interpolate string leaves only (CFG-007 semantics), then deserialize, with `$$` as a literal `$` and unresolved variables as errors. - Cost: about 80 lines of interpolation logic duplicated in spirit; the two crates now evolve the same convention in parallel and could drift. ## 6. Verbatim relay via raw bytes, statuses passed through -- Choice: the gateway client returns status plus raw body bytes, and the routes relay them with content-type `application/json`; a non-success gateway status is relayed, not mapped to a workbench error. +- Choice: the gateway client returns status plus raw body bytes, and the routes relay them with content-type `application/json`; a non-success gateway status is relayed, not mapped to a workshop error. - Evidence: the step requires byte-for-byte relay, and re-serializing a `serde_json::Value` cannot guarantee key order (serde_json's default map sorts keys), so raw bytes are the only honest pass-through; relaying the gateway's own error envelopes keeps failure semantics in one place. -- Cost: the workbench cannot inspect or transform gateway responses at this layer, and the content-type is asserted rather than copied from the gateway response. +- Cost: the workshop cannot inspect or transform gateway responses at this layer, and the content-type is asserted rather than copied from the gateway response. -## 7. Route shapes: GET /v1/models mirrors the gateway, POST /chat is workbench-local +## 7. Route shapes: GET /v1/models mirrors the gateway, POST /chat is workshop-local - Choice: the models proxy keeps the gateway's own path `/v1/models`; chat is `POST /chat` accepting `{"model", "messages"}` as a typed `ChatRequest` whose messages are unchecked `serde_json::Value` items; handlers reach the gateway client through axum `State`. -- Evidence: mirroring `/v1/models` lets OpenAI-aware clients point at the workbench unchanged, while `/chat` is named for the workbench UI and typing only model plus messages keeps the stage-2 contract minimal; axum state is the idiomatic way to share one connection-pooled client across handlers. +- Evidence: mirroring `/v1/models` lets OpenAI-aware clients point at the workshop unchanged, while `/chat` is named for the workshop UI and typing only model plus messages keeps the stage-2 contract minimal; axum state is the idiomatic way to share one connection-pooled client across handlers. - Cost: two naming conventions on one server, and extra fields in a chat request are silently dropped rather than forwarded. ## 8. Error design: one thiserror enum per module, boxed sources - Choice: `ConfigError` (Read / Parse / UnresolvedVar / Interpolation) and `GatewayError` (Build / Transport / ReadBody), both `#[non_exhaustive]`, with dependency errors boxed as `Box` behind `#[source]`; handlers collapse any `GatewayError` to `502 Bad Gateway` with a JSON envelope. -- Evidence: matches the rust rulebook's one-enum-per-unit-of-fallibility and the gateway crate's own convention of boxing reqwest errors so dependency types stay out of the public API; a missing `workbench.toml` surfaces as `Read` naming the expected path. +- Evidence: matches the rust rulebook's one-enum-per-unit-of-fallibility and the gateway crate's own convention of boxing reqwest errors so dependency types stay out of the public API; a missing `workshop.toml` surfaces as `Read` naming the expected path. - Cost: boxing erases the concrete source type for any caller that wanted to downcast, and the 502 mapping loses the distinction between connect, transport, and body-read failures at the HTTP boundary. ## 9. Tape event schema: six fields with full request and response JSON - Choice: each tape line is one JSON object: `ts` (RFC 3339 UTC string), `kind` (`"chat"`), `model`, `request` (the request body exactly as received), `response` (the gateway body parsed to JSON, or a plain string if it was not JSON), `latency_ms` (u64, measured with `Instant` around the gateway call only). Timestamp rendering follows the promptforge-core `now_rfc3339_checked` convention: the `time` crate with a checked format error, never an empty string. -- Evidence: the step fixes the field list; storing full `Value`s keeps the tape replayable without the workbench understanding message shapes; recording the body as received (not the re-serialized `ChatRequest`) preserves extra client fields the relay drops; `Instant` around `chat_completion` keeps tape and relay overhead out of the number. +- Evidence: the step fixes the field list; storing full `Value`s keeps the tape replayable without the workshop understanding message shapes; recording the body as received (not the re-serialized `ChatRequest`) preserves extra client fields the relay drops; `Instant` around `chat_completion` keeps tape and relay overhead out of the number. - Cost: prompts and completions land on disk verbatim, so the tape file is sensitive data; a non-JSON gateway body is stored as a string rather than dropped; transport failures produce no event, since there is no response to record. ## 10. Tape writer: std mutex around a boxed writer, not a channel @@ -66,7 +66,7 @@ Running log of design choices for the PromptForge Workbench stage 1 build. Each - Choice: `Tape::record` returns `Result<(), TapeError>`; the chat handler logs a failure with `tracing::error!` and still relays the gateway response, while `AppState::new` refuses to build if the tape cannot be opened; `main` initializes `tracing-subscriber` so the log line actually lands somewhere. - Evidence: the step requires write errors to be visible yet never fail the user's chat - the `Result` makes the error visible to the caller (the handler) and the tracing log makes it visible to the operator, matching the gateway crate's own `%error` logging style; an unopenable tape at startup means every event would be lost, a configuration error worth failing fast over rather than serving a silently dead tape. -- Cost: a runtime write failure has no user-facing signal, only the log; if the disk fails mid-session the workbench serves untaped chats until restart. +- Cost: a runtime write failure has no user-facing signal, only the log; if the disk fails mid-session the workshop serves untaped chats until restart. ## 12. SSE parsing: hand-rolled incremental decoder, no eventsource-stream @@ -88,20 +88,20 @@ Running log of design choices for the PromptForge Workbench stage 1 build. Each ## 15. Mid-stream error taping -- Choice: a transport error mid-stream ends the workbench's SSE response after the last good event - no fabricated terminal frame - and tapes one event whose `response` is `{"error": , "content": }`. +- Choice: a transport error mid-stream ends the workshop's SSE response after the last good event - no fabricated terminal frame - and tapes one event whose `response` is `{"error": , "content": }`. - Evidence: the step requires an error note and never a silent gap; the 200 status and SSE headers are already committed when a mid-stream failure surfaces, so the only honest client signal is a truncated stream without `[DONE]`, while the tape carries the failure for the operator. - Cost: the client must infer failure from the missing `[DONE]` rather than an explicit error frame, and the taped message is reqwest's transport wording, not a gateway error envelope. ## 16. UI assets embedded with include_str!, not served from a directory -- Choice: `index.html`, `app.js`, `style.css`, and the vendored `markdown-it.min.js` live in `crates/promptforge-wb-server/ui/` and are compiled into the binary with `include_str!`; routes `GET /`, `/app.js`, `/style.css`, and `/markdown-it.min.js` serve the embedded strings with explicit content types. -- Evidence: the workbench is a local companion app where single-binary deployment matters - the server can be launched from any working directory without locating its asset tree, and there is no runtime path resolution to fail; the UI is four small files, so the dev-iteration cost of a recompile per edit is seconds, and `include_str!` gives the compiler a rebuild dependency on the assets for free. +- Choice: `index.html`, `app.js`, `style.css`, and the vendored `markdown-it.min.js` live in `crates/promptforge-ws-server/ui/` and are compiled into the binary with `include_str!`; routes `GET /`, `/app.js`, `/style.css`, and `/markdown-it.min.js` serve the embedded strings with explicit content types. +- Evidence: the workshop is a local companion app where single-binary deployment matters - the server can be launched from any working directory without locating its asset tree, and there is no runtime path resolution to fail; the UI is four small files, so the dev-iteration cost of a recompile per edit is seconds, and `include_str!` gives the compiler a rebuild dependency on the assets for free. - Cost: editing the UI requires a recompile rather than a browser refresh against a live directory, and the binary grows by the asset size (about 130 KB, dominated by markdown-it); cache headers are not set, so the browser revalidates every load, which is fine on loopback. ## 17. markdown-it vendored into ui/, no CDN -- Choice: markdown-it 14.1.0 (minified, MIT) is checked into `crates/promptforge-wb-server/ui/markdown-it.min.js` and served as a static asset; assistant bubbles re-render the assembled markdown through it on every delta. -- Evidence: the step forbids CDN dependencies, and the workbench is a local tool that must work offline and must not depend on a third-party host's availability or integrity; markdown-it is the reference CommonMark renderer for the browser and rendering the full accumulated string per delta is cheap at chat-message sizes, so no incremental-parse machinery is needed. +- Choice: markdown-it 14.1.0 (minified, MIT) is checked into `crates/promptforge-ws-server/ui/markdown-it.min.js` and served as a static asset; assistant bubbles re-render the assembled markdown through it on every delta. +- Evidence: the step forbids CDN dependencies, and the workshop is a local tool that must work offline and must not depend on a third-party host's availability or integrity; markdown-it is the reference CommonMark renderer for the browser and rendering the full accumulated string per delta is cheap at chat-message sizes, so no incremental-parse machinery is needed. - Cost: the vendored copy is a manual upgrade point - security or bug fixes land only when someone re-downloads the file; re-rendering the whole message per delta is O(n^2) in message length, acceptable for chat replies but a known ceiling. ## 18. fetch plus ReadableStream for the POST SSE stream, not EventSource @@ -155,18 +155,18 @@ Running log of design choices for the PromptForge Workbench stage 1 build. Each ## 26. Voice endpoint size and rate posture: axum defaults accepted, stated for the record - Choice: `/voice` sets no limits of its own; axum's default `WebSocketConfig` caps a message at 64 MiB and a frame at 16 MiB, and those caps are the whole posture. The frame counter is a per-connection u64; there is no per-session duration cap, no rate limit, and no authentication on the route. -- Evidence: the server binds loopback by default (design entry 3) and the workbench is a single-user local tool, so the only client is the page the server itself served; the handler never buffers payloads - each binary message is measured by length and dropped - so memory in use is bounded by one capped message, and a hostile process already on the loopback interface has simpler ways to harm the machine than this endpoint. +- Evidence: the server binds loopback by default (design entry 3) and the workshop is a single-user local tool, so the only client is the page the server itself served; the handler never buffers payloads - each binary message is measured by length and dropped - so memory in use is bounded by one capped message, and a hostile process already on the loopback interface has simpler ways to harm the machine than this endpoint. - Cost: overriding `server.bind` to a non-loopback address exposes an unauthenticated endpoint that accepts arbitrary binary streams, and any such deployment must revisit this entry; a take of unbounded length accumulates only a counter, but the inbound stream itself is unthrottled below the 64 MiB message cap. ## 27. whisper-rs 0.16 for on-device interim transcription -- Choice: transcription runs through `whisper-rs = "0.16"` (current on crates.io as of this step; 0.15.1 was the prior release), the safe Rust bindings over whisper.cpp. Models are GGML/GGUF files loaded from paths named in `workbench.toml`. +- Choice: transcription runs through `whisper-rs = "0.16"` (current on crates.io as of this step; 0.15.1 was the prior release), the safe Rust bindings over whisper.cpp. Models are GGML/GGUF files loaded from paths named in `workshop.toml`. - Evidence: whisper.cpp is the reference local speech-to-text runtime, and whisper-rs is its maintained safe binding - the crate's own `unsafe` stays behind its API, so the workspace's `unsafe_code = "forbid"` holds for our code. The build needs cmake plus libclang on the machine (whisper-rs-sys compiles whisper.cpp and runs bindgen at build time); both were installed out of band and `LIBCLANG_PATH` is set to the LLVM install's `bin` directory. - Cost: a C++ toolchain dependency (cmake, MSVC, libclang) on every machine that builds the server, a few minutes of first-build time, and a ~75 MiB model file per voice profile; whisper-rs's release cadence trails whisper.cpp, so upstream fixes arrive on the binding's schedule. ## 28. Voice config: `[voice]` with per-role model paths, 5 s window, 800 ms cadence -- Choice: `workbench.toml` gains an optional `[voice]` section: `interim_model` (path to the streaming model), `final_model` (path, parsed now and loaded by the next step's final pass), `window_seconds` (default 5), and `interval_ms` (default 800). An empty `interim_model` disables transcription entirely; the endpoint then keeps its capture-and-count behavior and replies with empty transcripts. +- Choice: `workshop.toml` gains an optional `[voice]` section: `interim_model` (path to the streaming model), `final_model` (path, parsed now and loaded by the next step's final pass), `window_seconds` (default 5), and `interval_ms` (default 800). An empty `interim_model` disables transcription entirely; the endpoint then keeps its capture-and-count behavior and replies with empty transcripts. - Evidence: 5 s is the window whisper.cpp's own streaming examples converge on - long enough for the tiny model to form a sentence, short enough that a pass finishes well under the interval on a desktop CPU; 800 ms gives the user visible rewrites roughly once a second without queueing passes back-to-back (a tiny-model pass over 5 s of audio takes a few hundred milliseconds on 4 threads). Two model paths let the cheap model serve the hot loop while a bigger one serves the once-per-take final pass. - Cost: two more tunables with no auto-tuning, and a server with `[voice]` configured fails startup when the model path is bad (deliberate, matching the tape's fail-fast posture in design entry 11); `final_model` is dead config until the next step lands. (Revised by entry 79: a bad model path now degrades to disabled voice with a status-bar explanation, or defers to the provisioning task when a source URL is configured - the fail-fast posture did not survive the download-on-first-run UX.) @@ -190,7 +190,7 @@ Running log of design choices for the PromptForge Workbench stage 1 build. Each ## 32. Test model and speech fixture, downloaded out of band and gitignored -- Choice: tests use `ggml-tiny.en.bin` (whisper tiny, English-only, ~74 MiB) from `https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.en.bin`, and the speech fixture is whisper.cpp's own `samples/jfk.wav` (16 kHz mono s16, 11 s, "ask not what your country can do for you") from `https://github.com/ggerganov/whisper.cpp/raw/master/samples/jfk.wav`. Both live in `crates/promptforge-wb-server/tests/fixtures/`, which is gitignored; a missing model fails the test with the download URL in the message. The expected word is "country", spoken twice in the clip. +- Choice: tests use `ggml-tiny.en.bin` (whisper tiny, English-only, ~74 MiB) from `https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.en.bin`, and the speech fixture is whisper.cpp's own `samples/jfk.wav` (16 kHz mono s16, 11 s, "ask not what your country can do for you") from `https://github.com/ggerganov/whisper.cpp/raw/master/samples/jfk.wav`. Both live in `crates/promptforge-ws-server/tests/fixtures/`, which is gitignored; a missing model fails the test with the download URL in the message. The expected word is "country", spoken twice in the clip. - Evidence: tiny is the smallest model that transcribes the jfk clip reliably, and the English-only variant is smaller and faster than the multilingual one for the same accuracy on English speech; the jfk clip is the canonical whisper.cpp smoke-test input - real speech with a known transcript, already 16 kHz mono, no synthesis required, and a presidential address (public domain). Gitignoring 74 MiB of binaries keeps the repo lean; the URLs above are the re-fetch instructions. - Cost: a fresh clone cannot run the three transcription tests until someone downloads the fixtures (the failure message says how); tiny is noticeably weaker than the models a user would configure, so the tests prove the pipeline, not the transcription quality; hound joins the dev-dependencies to parse the WAV. @@ -238,15 +238,15 @@ Running log of design choices for the PromptForge Workbench stage 1 build. Each ## 40. In-process server on a dedicated thread, not a child process -- Choice: `promptforge-wb` starts the server by calling the new library entry point `promptforge_wb_server::spawn(config)`, which builds the state, binds the listener, and serves on a dedicated `std::thread` with its own multi-threaded tokio runtime. `spawn` blocks until the listener is bound - the bind is the readiness signal - and returns a `ServerHandle` carrying the bound base URL, a graceful-shutdown oneshot, and the thread handle. The shell then polls `GET /health` until it answers 200 before opening the window. Spawning the server binary as a child process was the alternative and was rejected. +- Choice: `promptforge-ws` starts the server by calling the new library entry point `promptforge_ws_server::spawn(config)`, which builds the state, binds the listener, and serves on a dedicated `std::thread` with its own multi-threaded tokio runtime. `spawn` blocks until the listener is bound - the bind is the readiness signal - and returns a `ServerHandle` carrying the bound base URL, a graceful-shutdown oneshot, and the thread handle. The shell then polls `GET /health` until it answers 200 before opening the window. Spawning the server binary as a child process was the alternative and was rejected. - Evidence: in-process keeps one binary to ship and one process to supervise, and the bound address comes back as a value rather than being scraped from a child's stdout; the rulebook's one-runtime-per-process rule is satisfied by giving the server thread its own runtime instead of sharing one with the GUI thread, which must stay free for tao's event loop (tao requires the main thread). The health poll on top of the bind signal proves end-to-end serving, not just a bound socket, and it is what the step asked for. Graceful shutdown rides axum's `with_graceful_shutdown`, so window close drains the server instead of killing it mid-request. - Cost: a server panic takes the whole process down with the window (a child process would isolate it), and the shell links the server's full dependency tree (whisper included) instead of leaving it in the server binary. ## 41. Config search order: beside the executable, then the current directory, then the profile -- Choice: the shell looks for `workbench.toml` beside the executable first, then in the current directory, then in `%USERPROFILE%\.promptforge\workbench.toml`; first found wins, and a total miss is an error naming every path tried in order. +- Choice: the shell looks for `workshop.toml` beside the executable first, then in the current directory, then in `%USERPROFILE%\.promptforge\workshop.toml`; first found wins, and a total miss is an error naming every path tried in order. - Evidence: beside-the-executable first makes a packaged layout (config shipped next to the binary) win over anything ambient, which is the least surprising behavior for an installed app; the current directory second preserves the server binary's existing launch-from-a-project-directory convention; the profile directory last is the per-user fallback. Naming every tried path in the error turns a silent wrong-config pickup into a one-glance diagnosis. The search is factored as a pure `candidates_from(exe_dir, cwd, home)` plus `first_existing`, so the order is pinned by tempdir tests rather than by reading the code. -- Cost: three locations means three places a stale config can shadow the intended one - mitigated only by the error message, which the happy path never prints; the server binary keeps its single-path behavior (`workbench.toml` in the current directory), so the two binaries resolve config differently. +- Cost: three locations means three places a stale config can shadow the intended one - mitigated only by the error message, which the happy path never prints; the server binary keeps its single-path behavior (`workshop.toml` in the current directory), so the two binaries resolve config differently. ## 42. No native menu; the close button is the quit gesture @@ -256,7 +256,7 @@ Running log of design choices for the PromptForge Workbench stage 1 build. Each ## 43. Browser fallback: `server.open_browser` opens the system browser from the server binary -- Choice: `workbench.toml` gains an optional `server.open_browser` (default false). The server binary reads it after `spawn` returns and, when true, opens the bound URL with the `open` crate (5.4.2); a failure to open is logged with `tracing::warn` and serving continues. The shell ignores the flag - it has a window. +- Choice: `workshop.toml` gains an optional `server.open_browser` (default false). The server binary reads it after `spawn` returns and, when true, opens the bound URL with the `open` crate (5.4.2); a failure to open is logged with `tracing::warn` and serving continues. The shell ignores the flag - it has a window. - Evidence: the step fixes the semantics (when true and no window is available, the server binary opens the browser - the browser-tab frame); the server binary is by definition the windowless frame, so the flag lives in its main. `open` is the ecosystem-default crate for launching a URL in the system browser; the std alternative (`cmd /c start` on Windows, `open` on macOS, `xdg-open` on Linux) is shell-quoting-sensitive and would be our code to maintain. Waiting until after `spawn`'s readiness signal means the browser never opens on a dead address. Log-and-continue matches the tape's failure posture (design entry 11): a browser that will not open is an environment problem, not a reason to kill a serving server whose address is in the log. - Cost: one more dependency (small, no transitive tree of note); `open` spawns the platform handler and reports only launch failure, so a browser that opens the wrong profile or blocks localhost is invisible to us. @@ -268,20 +268,20 @@ Running log of design choices for the PromptForge Workbench stage 1 build. Each ## 45. Documentation: per-crate READMEs, example config at the repo root -- Choice: each workbench crate gets a README matching the existing crate convention (pitch, quick start, MSRV, BSL-1.0 license); the server's adds a full `workbench.toml` field table, the route table, and the out-of-band whisper model download note, while the shell's covers the three-path config search order and the `server.open_browser` browser fallback. The commented example config lives at the repository root as `workbench.example.toml`, not inside the crate. The workspace README's crate table lists both crates with `not published` in the crates.io column, since both are `publish = false`. +- Choice: each workshop crate gets a README matching the existing crate convention (pitch, quick start, MSRV, BSL-1.0 license); the server's adds a full `workshop.toml` field table, the route table, and the out-of-band whisper model download note, while the shell's covers the three-path config search order and the `server.open_browser` browser fallback. The commented example config lives at the repository root as `workshop.example.toml`, not inside the crate. The workspace README's crate table lists both crates with `not published` in the crates.io column, since both are `publish = false`. - Evidence: the root is where the workspace README's quick start already points users for `gateway.toml`, so the root is the more discoverable home for the example; the crates.io badge column has no honest badge for an unpublished crate, and `not published` says so plainly rather than linking a badge that 404s; keeping the field table in the server README (the crate that parses the config) puts the reference next to the code it describes, with the shell README linking to it instead of duplicating it. - Cost: the field table is prose, not compiled - a new config field can ship without a README update, and nothing but review catches the drift. -## 46. Optional workbench.toml - environment-variable-only config path +## 46. Optional workshop.toml - environment-variable-only config path -- Choice: `workbench.toml` is now optional. When no TOML is found (by discovery or in the current directory), `Config::from_env()` builds a valid `Config` entirely from environment variables with sensible defaults. The only required variable is `PROMPTFORGE_GATEWAY_API_KEY`; all others default to the same values the TOML path uses. `discover_config()` returns `Option` instead of erroring on a miss. +- Choice: `workshop.toml` is now optional. When no TOML is found (by discovery or in the current directory), `Config::from_env()` builds a valid `Config` entirely from environment variables with sensible defaults. The only required variable is `PROMPTFORGE_GATEWAY_API_KEY`; all others default to the same values the TOML path uses. `discover_config()` returns `Option` instead of erroring on a miss. - Evidence: container and CI deployments pass secrets through environment variables, not config files; requiring a TOML file forces either a volume mount or a build-time bake step that exposes secrets in layers. The env-var names follow the `PROMPTFORGE_` prefix convention already used for `${PROMPTFORGE_GATEWAY_API_KEY}` interpolation inside the TOML. Existing TOML behavior is unchanged - the TOML remains the primary source when present. - Cost: two config paths to maintain and test; a user who sets both a TOML and env vars might be surprised that the TOML wins unconditionally; error messages differ between the two paths (parse errors vs missing-var errors). ## 47. First-run config generation replaces the env-only path (supersedes 46) -- Choice: `Config::from_env` and its lookup-based test seam are deleted, one step after they shipped. When the shell's three-path search finds no `workbench.toml`, it creates `~/.promptforge/` if needed, writes a built-in default template there, logs the path, and loads it; the app never exits on missing config. `ConfigError` gains a typed `NotFound { path }` variant, split out of `Read` by `ErrorKind::NotFound`, so a caller can tell "no file" apart from "unreadable file" - and the `MissingEnvVar` / `InvalidEnvVar` variants die with the env-only path. -- Evidence: entry 46's env-only path existed to make config optional, but it left a first-run user staring at a missing-variable error and gave the generated-config plan two config paths to keep honest; generating a real file is strictly more discoverable - the user gets a commented artifact to edit, and the TOML path becomes the only path. The template's gateway fields are `${PROMPTFORGE_GATEWAY_URL}` / `${PROMPTFORGE_GATEWAY_API_KEY}` interpolations, so a machine with the variables set is configured from the first launch, keeping entry 46's container/CI benefit without a second code path. The server binary is unchanged: it still reads `workbench.toml` from the current directory only. +- Choice: `Config::from_env` and its lookup-based test seam are deleted, one step after they shipped. When the shell's three-path search finds no `workshop.toml`, it creates `~/.promptforge/` if needed, writes a built-in default template there, logs the path, and loads it; the app never exits on missing config. `ConfigError` gains a typed `NotFound { path }` variant, split out of `Read` by `ErrorKind::NotFound`, so a caller can tell "no file" apart from "unreadable file" - and the `MissingEnvVar` / `InvalidEnvVar` variants die with the env-only path. +- Evidence: entry 46's env-only path existed to make config optional, but it left a first-run user staring at a missing-variable error and gave the generated-config plan two config paths to keep honest; generating a real file is strictly more discoverable - the user gets a commented artifact to edit, and the TOML path becomes the only path. The template's gateway fields are `${PROMPTFORGE_GATEWAY_URL}` / `${PROMPTFORGE_GATEWAY_API_KEY}` interpolations, so a machine with the variables set is configured from the first launch, keeping entry 46's container/CI benefit without a second code path. The server binary is unchanged: it still reads `workshop.toml` from the current directory only. - Cost: the generated file is a second copy of the defaults to keep in sync with the schema (the template is pinned by a test that loads it, which catches drift that breaks parsing but not drift in comments); a user who deletes the profile file to "reset" gets a fresh template that silently discards nothing but their own edits. ## 48. Voice model source URLs are config fields (Amendment A) @@ -346,7 +346,7 @@ Running log of design choices for the PromptForge Workbench stage 1 build. Each ## 58. The UI build is a Node.js requirement driven by build.rs -- Choice: the workbench UI moved from hand-written JS served by `include_str!` to TypeScript under `ui/src/`, bundled by esbuild into `ui/dist/` (gitignored) and served through `rust-embed` (`#[folder = "ui/dist/"]`: disk reads in debug, embedded in release). The server crate's `build.rs` runs the bundle on every cargo build that re-runs it - preferring `ui/node_modules/.bin/esbuild` (via `cmd /c` on Windows, whose npm shims are `.cmd` files) and falling back to `npx --yes esbuild` with a `cargo::warning` - then copies the static files (`index.html`, `style.css`, `pcm-worklet.js`, `markdown-it.min.js`) into `ui/dist/` itself. A failed esbuild run panics the build with the install instructions. `ui/package.json` carries `dockview` and `marked` as runtime dependencies and `esbuild` + `typescript` as dev dependencies; `npm run build` / `npm run watch` / `npm run typecheck` wrap the same steps for the fast iteration loop. +- Choice: the workshop UI moved from hand-written JS served by `include_str!` to TypeScript under `ui/src/`, bundled by esbuild into `ui/dist/` (gitignored) and served through `rust-embed` (`#[folder = "ui/dist/"]`: disk reads in debug, embedded in release). The server crate's `build.rs` runs the bundle on every cargo build that re-runs it - preferring `ui/node_modules/.bin/esbuild` (via `cmd /c` on Windows, whose npm shims are `.cmd` files) and falling back to `npx --yes esbuild` with a `cargo::warning` - then copies the static files (`index.html`, `style.css`, `pcm-worklet.js`, `markdown-it.min.js`) into `ui/dist/` itself. A failed esbuild run panics the build with the install instructions. `ui/package.json` carries `dockview` and `marked` as runtime dependencies and `esbuild` + `typescript` as dev dependencies; `npm run build` / `npm run watch` / `npm run typecheck` wrap the same steps for the fast iteration loop. - Evidence: the plan's UI foundation decision fixes esbuild + rust-embed and demands that a plain `cargo build` always produces fresh JS, which only a build script can guarantee; putting the copy step in `build.rs` (rather than shelling out to `npm run build`) keeps the cargo path independent of npm's script runner and shaves npm's startup off every rebuild. The `npx` fallback keeps a fresh checkout buildable before `npm install`, at the cost of a registry fetch. esbuild 0.28.2 mis-parses a nested inline type literal inside an `as` cast (`(x as { choices?: Array<{ delta?: { content?: unknown }> } })` - "Unexpected >"), so casts of that shape are hoisted to named interfaces; esbuild only strips types, and `tsc --noEmit` remains the real checker. - Cost: Node.js becomes a hard build requirement for the server crate where plain cargo used to suffice (mitigated by the panic message and the README's UI development section); `build.rs` writes into the source tree (`ui/dist/`), against the usual OUT_DIR rule, because rust-embed's debug mode reads a fixed manifest-relative path - the directory is gitignored and fully regenerated; and the static-file list lives in two places (`build.rs` and `ui/build.mjs`), cross-referenced by comments. @@ -358,9 +358,9 @@ Running log of design choices for the PromptForge Workbench stage 1 build. Each ## 60. Visible changes from the murm-ui migration, accepted until the step-5 skinning pass -- Choice: the chat surface is now murm-ui's DOM and dark theme (`.mur-app[data-theme="dark"]`) instead of the hand-rolled bubbles, and several visible differences are accepted for now: the markdown renderer is `marked` (murm-ui's renderer) instead of markdown-it, so `markdown-it.min.js` and its route are deleted; the composer is murm-ui's rounded floating form with an icon send/stop button; the send button enables on any text and submission with no model selected is blocked silently (`isSubmitBlocked`), the net effect of the old disabled button; a model-catalog failure now shows in the sidebar's description line instead of an in-chat error bubble; and murm-ui's styles reach the page as `app.css`, bundled by esbuild from CSS imports in `main.ts`, while `style.css` remains a separate static file for the workbench shell. The mic button, interim transcript line, and voice status are injected into murm-ui's composer through its plugin seam (`onInputMount`), so the voice protocol and interaction are unchanged. A jsdom smoke test (`npm test`) imports the built bundle and asserts the chat mounts with the mic button present. -- Evidence: the plan defers all palette work to step 5, so matching the old look exactly now would be throwaway CSS; what must not change is behavior - the `/chat` and `/voice` protocols, push-to-talk, and model picking all survive, which the smoke test plus the unchanged Rust test suite pin. Bundling third-party CSS through esbuild (rather than serving more static files) keeps one request per stylesheet concern and lets step 5 override via `style.css` load order. The `OpenAIProvider` shipped with murm-ui was not used: it hardcodes title generation (an extra completion per chat) and `stream_options.include_usage`, neither of which the workbench wants, so a 60-line `WorkbenchProvider` against `POST /chat` keeps the wire behavior identical to the old UI. -- Cost: two stylesheets and two variable systems (`--mur-*` and the workbench `--bg`/`--text` set) coexist until step 5 consolidates them; the old UI's in-chat catalog error was more prominent than the sidebar line; and `marked` renders a few markdown edge cases differently from markdown-it, which no test pins. +- Choice: the chat surface is now murm-ui's DOM and dark theme (`.mur-app[data-theme="dark"]`) instead of the hand-rolled bubbles, and several visible differences are accepted for now: the markdown renderer is `marked` (murm-ui's renderer) instead of markdown-it, so `markdown-it.min.js` and its route are deleted; the composer is murm-ui's rounded floating form with an icon send/stop button; the send button enables on any text and submission with no model selected is blocked silently (`isSubmitBlocked`), the net effect of the old disabled button; a model-catalog failure now shows in the sidebar's description line instead of an in-chat error bubble; and murm-ui's styles reach the page as `app.css`, bundled by esbuild from CSS imports in `main.ts`, while `style.css` remains a separate static file for the workshop shell. The mic button, interim transcript line, and voice status are injected into murm-ui's composer through its plugin seam (`onInputMount`), so the voice protocol and interaction are unchanged. A jsdom smoke test (`npm test`) imports the built bundle and asserts the chat mounts with the mic button present. +- Evidence: the plan defers all palette work to step 5, so matching the old look exactly now would be throwaway CSS; what must not change is behavior - the `/chat` and `/voice` protocols, push-to-talk, and model picking all survive, which the smoke test plus the unchanged Rust test suite pin. Bundling third-party CSS through esbuild (rather than serving more static files) keeps one request per stylesheet concern and lets step 5 override via `style.css` load order. The `OpenAIProvider` shipped with murm-ui was not used: it hardcodes title generation (an extra completion per chat) and `stream_options.include_usage`, neither of which the workshop wants, so a 60-line `WorkshopProvider` against `POST /chat` keeps the wire behavior identical to the old UI. +- Cost: two stylesheets and two variable systems (`--mur-*` and the workshop `--bg`/`--text` set) coexist until step 5 consolidates them; the old UI's in-chat catalog error was more prominent than the sidebar line; and `marked` renders a few markdown edge cases differently from markdown-it, which no test pins. ## 61. dockview wraps the chat in one tab-hidden panel; status bar placeholder below @@ -376,15 +376,15 @@ Running log of design choices for the PromptForge Workbench stage 1 build. Each ## 63. One WebSocket per chat generation, not one persistent socket -- Choice: `WorkbenchProvider.streamChat` opens a fresh `GET /ws` socket per call, sends the single chat frame, consumes `delta`/`done`/`error` frames, and closes. No request ids, no multiplexing, no reconnect logic. Abort (the engine's stop button) closes the socket, which is what makes the server drop the gateway stream; a socket that closes without a terminal frame finishes whatever streamed (mirroring an SSE body that ends early) or rejects when nothing arrived. The jsdom smoke test now stubs `globalThis.WebSocket` with a scripted socket and treats any `POST /chat` fetch as a failure, pinning the SSE path's removal from the provider. +- Choice: `WorkshopProvider.streamChat` opens a fresh `GET /ws` socket per call, sends the single chat frame, consumes `delta`/`done`/`error` frames, and closes. No request ids, no multiplexing, no reconnect logic. Abort (the engine's stop button) closes the socket, which is what makes the server drop the gateway stream; a socket that closes without a terminal frame finishes whatever streamed (mirroring an SSE body that ends early) or rejects when nothing arrived. The jsdom smoke test now stubs `globalThis.WebSocket` with a scripted socket and treats any `POST /chat` fetch as a failure, pinning the SSE path's removal from the provider. - Evidence: murm-ui's `ChatProvider` contract is per-call (`streamChat` owns one generation and its `AbortSignal`), so a socket per call is the zero-state mapping: no id correlation, no interleaving, no stale-socket handling across generations. The alternative - one persistent socket with request ids - exists only to serve the observer's status frames, which do not land until step 3's later commits; that step's own text ("one connection for all JSON downstream") is the point to introduce the persistent socket, when there is a second frame family to justify it. Loopback handshake cost is sub-millisecond against the multi-hundred-millisecond gateway stream it precedes. - Cost: a TCP-plus-WebSocket handshake per message (immaterial on loopback, but real); the status-frame commits must rework the provider onto the shared persistent socket rather than building on this one; and a generation cannot outlive its socket, so a future "background completion" feature would need the persistent model anyway. ## 64. POST /chat is buffered only; stream:true is a 400 - Choice: with the browser chat leg on `/ws`, the SSE relay is deleted from `app.rs` (`chat_stream`, `wants_stream`, the `Sse`/`Event` imports, the `stream::unfold` relay, and the four SSE tests, whose coverage moved to the `chat_ws` socket tests two commits earlier). `POST /chat` stays as the buffered relay and now rejects `"stream": true` with 400 and a `stream_unsupported` envelope code naming `/ws`, instead of silently answering SSE. `delta_content` and `StreamTape` moved into `chat_ws.rs`, their only remaining consumer; `value_from_bytes` and `tape_round_trip` stay `pub(crate)` in `app.rs`, shared by both transports. -- Evidence: the plan's step-3 map calls for exactly this removal and rejection. Rejecting rather than ignoring `stream: true` matters because an OpenAI client pointed at the workbench would otherwise hang waiting on a buffered body it believes is a stream; a 400 with a named code fails fast and says where streaming went. Keeping the buffered route at all preserves the byte-for-byte relay contract (entry 6) for non-streaming callers and gives the smoke tests a simple oneshot target. -- Cost: two chat surfaces remain (`/chat` buffered, `/ws` streaming), so a caller must pick correctly - the 400 message is the signpost; and any external script that relied on the workbench's SSE stream breaks loudly rather than softly, which is the intent but is still a break. +- Evidence: the plan's step-3 map calls for exactly this removal and rejection. Rejecting rather than ignoring `stream: true` matters because an OpenAI client pointed at the workshop would otherwise hang waiting on a buffered body it believes is a stream; a 400 with a named code fails fast and says where streaming went. Keeping the buffered route at all preserves the byte-for-byte relay contract (entry 6) for non-streaming callers and gives the smoke tests a simple oneshot target. +- Cost: two chat surfaces remain (`/chat` buffered, `/ws` streaming), so a caller must pick correctly - the 400 message is the signpost; and any external script that relied on the workshop's SSE stream breaks loudly rather than softly, which is the intent but is still a break. ## 65. One persistent /ws socket with id-tagged chat multiplexing (supersedes 63) @@ -400,7 +400,7 @@ Running log of design choices for the PromptForge Workbench stage 1 build. Each ## 67. Observer instrumentation map; abort recycles the socket -- Choice: the plan's stub labels are wired into the subsystems. Startup reports "Connecting to gateway" -> "Loading whisper model" (only when voice is configured) -> "Ready" from `AppState::new`. Chat over `/ws` reports "Submitting request..." -> "Streaming response..." -> idle, with a per-delta `Activity::Gateway` pulse at `Debug` severity; buffered `POST /chat` inserts "Waiting for response..." between submit and idle; `GET /v1/models` reports "Loading models..." -> idle. Voice reports "Listening..." on start, a `Debug` "Transcribing..." per interim pass, "Finalizing transcript..." on stop, and idle after the reply. Errors map to "Gateway error: {status}" (a declined or non-success gateway answer), "Connection lost" (transport and mid-stream failures), and "Transcription failed" (the voice final pass; interim-pass failures stay `Debug` since the next tick retries). Mic-frame activity pulses are throttled to 4 Hz. The UI moved to one persistent socket (`workbench-socket.ts`): connect on load, id-tagged chat frames, status frames routed to a handler set that is a no-op until step 4 renders them. Abort detaches the chat and recycles the socket, preserving entry 63's abort-drops-the-stream semantics; the reconnect costs the status feed a sub-millisecond gap on loopback. "Loading configuration" is not emitted: the bus is born inside `AppState::new`, after the config has already loaded, and the step-7 startup rework can inject it earlier if the label is wanted. +- Choice: the plan's stub labels are wired into the subsystems. Startup reports "Connecting to gateway" -> "Loading whisper model" (only when voice is configured) -> "Ready" from `AppState::new`. Chat over `/ws` reports "Submitting request..." -> "Streaming response..." -> idle, with a per-delta `Activity::Gateway` pulse at `Debug` severity; buffered `POST /chat` inserts "Waiting for response..." between submit and idle; `GET /v1/models` reports "Loading models..." -> idle. Voice reports "Listening..." on start, a `Debug` "Transcribing..." per interim pass, "Finalizing transcript..." on stop, and idle after the reply. Errors map to "Gateway error: {status}" (a declined or non-success gateway answer), "Connection lost" (transport and mid-stream failures), and "Transcription failed" (the voice final pass; interim-pass failures stay `Debug` since the next tick retries). Mic-frame activity pulses are throttled to 4 Hz. The UI moved to one persistent socket (`workshop-socket.ts`): connect on load, id-tagged chat frames, status frames routed to a handler set that is a no-op until step 4 renders them. Abort detaches the chat and recycles the socket, preserving entry 63's abort-drops-the-stream semantics; the reconnect costs the status feed a sub-millisecond gap on loopback. "Loading configuration" is not emitted: the bus is born inside `AppState::new`, after the config has already loaded, and the step-7 startup rework can inject it earlier if the label is wanted. - Evidence: per-chunk and per-frame pulses are `Debug` so the UI ignores their text while step 4's activity LED can still read the activity field; the throttle exists because the audio worklet posts ~125 chunks a second, which is noise to a status bar, not signal. Abort-recycle rather than a cancel frame: the server processes chat frames sequentially per socket, so a detached-but-running generation would otherwise block the next submission until it finished; closing the socket is the existing, already-tested signal that makes the server drop the gateway stream (entry 62's `client disconnected mid-stream` tape path). - Cost: sequential server processing means a chat submitted while another streams still waits; "Ready" doubles as the idle label, so the bar cannot distinguish "just booted" from "just finished"; and the startup emissions fire before any client connects, observed by no one until steps 6-7 give them a live audience. @@ -418,9 +418,9 @@ Running log of design choices for the PromptForge Workbench stage 1 build. Each ## 70. One :root variable block skins everything; the murm-ui bridge cannot live in :root -- Choice: every workbench-owned visual value in `style.css` moved into a single grouped and commented `:root` block: the palette (gaining `--bg-sidebar`, `--bg-composer`, and `--danger-text`), the type stacks (`--font-mono` renamed to the plan's `--code-font`), a 4/6/8/12/16px spacing scale plus `--radius`, and the status bar, progress, and LED groups - now also covering the values that were hardcoded in rules: the LED's 60ms fade-in (`--led-fade-in-ms`), white gradient core (`--led-core`), and lens shades, and the mic pulse ring, which now derives from `--danger` via `color-mix`. Every `var()` use carries a fallback. The shell classes moved to BEM (`.sidebar__brand`, `.sidebar__picker(-label)`, `.sidebar__model-description(--error)`, `.voice-interim(--visible)`, `.voice-status(--visible/--error)`, `.voice-mic(--recording)`), with the TS and the smoke test updated to match. murm-ui's dark `--mur-*` variables are mapped onto the workbench variables in a `.mur-app[data-theme="dark"]` bridge block (with a comment table), and murm's three hardcoded `font-family: monospace` rules are overridden onto `--code-font`. Two deliberate visual deltas: the chat panel adopts the workbench palette (the harmonization entry 60 deferred to this pass), and error text moves from `--danger` to `--danger-text` (`#cf7f88`, the existing status-bar error color). +- Choice: every workshop-owned visual value in `style.css` moved into a single grouped and commented `:root` block: the palette (gaining `--bg-sidebar`, `--bg-composer`, and `--danger-text`), the type stacks (`--font-mono` renamed to the plan's `--code-font`), a 4/6/8/12/16px spacing scale plus `--radius`, and the status bar, progress, and LED groups - now also covering the values that were hardcoded in rules: the LED's 60ms fade-in (`--led-fade-in-ms`), white gradient core (`--led-core`), and lens shades, and the mic pulse ring, which now derives from `--danger` via `color-mix`. Every `var()` use carries a fallback. The shell classes moved to BEM (`.sidebar__brand`, `.sidebar__picker(-label)`, `.sidebar__model-description(--error)`, `.voice-interim(--visible)`, `.voice-status(--visible/--error)`, `.voice-mic(--recording)`), with the TS and the smoke test updated to match. murm-ui's dark `--mur-*` variables are mapped onto the workshop variables in a `.mur-app[data-theme="dark"]` bridge block (with a comment table), and murm's three hardcoded `font-family: monospace` rules are overridden onto `--code-font`. Two deliberate visual deltas: the chat panel adopts the workshop palette (the harmonization entry 60 deferred to this pass), and error text moves from `--danger` to `--danger-text` (`#cf7f88`, the existing status-bar error color). - Evidence: the plan fixes the minimum variable list and the one-file skinning goal. The bridge cannot literally sit in the `:root` block as the step text suggested: murm-ui declares its dark variables on the `.mur-app[data-theme="dark"]` element itself, and a custom property declared on the element beats any value inherited from `:root` no matter how `:root` is styled - the bridge must repeat murm's selector and win on load order (`style.css` after the bundled `app.css`). `@layer` was considered and rejected for the same reason in reverse: layered author styles lose to unlayered ones, and murm's are unlayered. `--danger` as text on `--bg` measured 4.33:1, under the html-css rulebook's 4.5:1 floor; `#cf7f88` measures 6.5:1. The shell's own rendering is otherwise byte-identical - the consolidation moved values, it did not change them. -- Cost: the chat panel's colors change by design; two variable vocabularies remain physically (murm keeps its `--mur-*` names, only their values are bound); the fallbacks duplicate every default at each use site; and murm-only knobs with no workbench counterpart (`--mur-chat-form-width`, the shadow set) must still be retuned through the bridge block or murm's own selectors. +- Cost: the chat panel's colors change by design; two variable vocabularies remain physically (murm keeps its `--mur-*` names, only their values are bound); the fallbacks duplicate every default at each use site; and murm-only knobs with no workshop counterpart (`--mur-chat-form-width`, the shadow set) must still be retuned through the bridge block or murm's own selectors. ## 71. The scrollbar is a global webkit skin with a standard-property fallback @@ -430,7 +430,7 @@ Running log of design choices for the PromptForge Workbench stage 1 build. Each ## 72. Skinning is documented as a variable table plus two override paths -- Choice: the crate README gains a "Skinning" section: the full variable table with defaults and what each paints, and two override paths - edit the `:root` block in `ui/style.css` (changes you keep), or link an additional stylesheet after `/style.css` and redeclare variables on `:root`. The section documents that workbench-variable overrides flow through to the chat panel, because the murm-ui bridge dereferences them at computed-value time, and that murm-only knobs are overridden on `.mur-app[data-theme="dark"]`. +- Choice: the crate README gains a "Skinning" section: the full variable table with defaults and what each paints, and two override paths - edit the `:root` block in `ui/style.css` (changes you keep), or link an additional stylesheet after `/style.css` and redeclare variables on `:root`. The section documents that workshop-variable overrides flow through to the chat panel, because the murm-ui bridge dereferences them at computed-value time, and that murm-only knobs are overridden on `.mur-app[data-theme="dark"]`. - Evidence: the plan's step-5 decomposition ends with "document the variable list and skinning instructions in README; decision log entry". The follow-through property of the bridge is what makes the second path viable without touching murm selectors, so it is stated explicitly rather than left as cascade folklore. - Cost: the table duplicates the `:root` block's defaults and must be maintained alongside it; the README already cross-references `style.css` from the UI development section, so drift is one grep away. @@ -442,13 +442,13 @@ Running log of design choices for the PromptForge Workbench stage 1 build. Each ## 74. Catalog push on reconnect: a second broadcast bus, folded into the heartbeat task -- Choice: when the heartbeat observes an unreachable-to-connected transition, it re-fetches `GET /v1/models` and publishes the gateway's `data` array verbatim to a new `CatalogBus` - a tokio broadcast channel of `CatalogPush` (ring capacity 4) on `AppState`, mirroring the `StatusBus`. Each `/ws` session's forwarder task now `select!`s over both buses and sends the push as a `{"type":"models","models":[...]}` frame. The initial connect (never-known-down, or the first probe succeeding) pushes nothing. A refresh that fails, is declined, or carries no `data` array is logged with `tracing::warn` and skipped - never pushed. The refresh runs inside the heartbeat task (guarded by the same stop signal), not as a second background task. The UI handles the frame in `workbench-socket.ts` (an `onModels` handler set beside `onStatus`) and `main.ts` rebuilds the picker from it, preserving the selection when it survives and clearing the boot-time error styling; the picker-rebuild logic is the shared `renderModels` the boot fetch also uses. +- Choice: when the heartbeat observes an unreachable-to-connected transition, it re-fetches `GET /v1/models` and publishes the gateway's `data` array verbatim to a new `CatalogBus` - a tokio broadcast channel of `CatalogPush` (ring capacity 4) on `AppState`, mirroring the `StatusBus`. Each `/ws` session's forwarder task now `select!`s over both buses and sends the push as a `{"type":"models","models":[...]}` frame. The initial connect (never-known-down, or the first probe succeeding) pushes nothing. A refresh that fails, is declined, or carries no `data` array is logged with `tracing::warn` and skipped - never pushed. The refresh runs inside the heartbeat task (guarded by the same stop signal), not as a second background task. The UI handles the frame in `workshop-socket.ts` (an `onModels` handler set beside `onStatus`) and `main.ts` rebuilds the picker from it, preserving the selection when it survives and clearing the boot-time error styling; the picker-rebuild logic is the shared `renderModels` the boot fetch also uses. - Evidence: the plan offers "the broadcast bus or a dedicated frame" and asks the choice to be noted. A dedicated bus won over widening `StatusBarUpdate` with a models variant because status updates and catalog pushes are different domains with different consumers (the status bar versus the picker), and the per-session forwarder already multiplexes them onto one socket with a `select!` - the wire is shared, the buses are not. Ring capacity 4 because pushes are one-per-reconnect and each is a complete snapshot, so lagging costs nothing. No push on the initial connect because a fresh UI fetches `/v1/models` itself on boot (entry 60's `loadModels`); pushing then would duplicate the fetch and could clear a picker the user already opened. Skipping a bad refresh rather than pushing it keeps a flapping gateway from emptying a usable picker. Folding the refresh into the heartbeat task rather than spawning a second task means one shutdown handle and one ordering guarantee (the "Connected" status always precedes the push); the cost is the heartbeat knowing about the catalog, accepted because the refresh is exactly "work the reconnect transition triggers". Selection preservation keeps a reconnect from yanking the user's chosen model when it still exists upstream. - Cost: two broadcast buses per session where one event stream would do (the forwarder's `select!` makes the wiring explicit instead); a session that connects after the reconnect push but before its own boot fetch completes relies on the fetch, not the push (the push is not replayed to late subscribers - a watch would replay, but would also push a stale catalog to sessions that never asked); and the pushed entries are the gateway's `data` verbatim, so the UI's `CatalogModel` shape (`id`, `description`) is trusted, not validated. ## 75. Known-down short-circuit: error frame on /ws, 502 envelope on HTTP, nothing taped -- Choice: the gateway-dependent surfaces check the heartbeat's `GatewayHealth` flag before attempting upstream. A `/ws` chat frame received while the flag is down is answered immediately with `{"type":"error","message":"Gateway unreachable"}` (the request `id` echoed as usual) and no tape event is written; `GET /v1/models` and buffered `POST /chat` answer 502 with `{"error":{"message":"Gateway unreachable","code":"gateway_unreachable"}}`. The WS check sits in `handle_frame` after parsing (a malformed frame still earns its own 400-style error reply regardless of gateway state), the HTTP checks sit after body validation for the same reason. `POST /chat` was not named in the plan's commit text but is the same chat subsystem, so it short-circuits too. The 502 code and envelope match the existing transport-failure relay (`gateway_unreachable`), with the difference that the short-circuit's message is the fixed user-visible string rather than a `std::error::Error` rendering. No new UI work: `WorkbenchSocket.streamChat` already rejects on error frames (murm-ui surfaces the message), and the status bar already shows the heartbeat's "Gateway unreachable" label. The optimistic initial flag (entry 73) is what keeps a pre-probe request behaving identically to pre-heartbeat code - it flows upstream and fails or succeeds on its own merits. Tests publish to the flag directly (`GatewayHealth::publish` is `pub(crate)`; the heartbeat is the only production writer) rather than waiting out a real heartbeat, keeping the degraded paths deterministic. +- Choice: the gateway-dependent surfaces check the heartbeat's `GatewayHealth` flag before attempting upstream. A `/ws` chat frame received while the flag is down is answered immediately with `{"type":"error","message":"Gateway unreachable"}` (the request `id` echoed as usual) and no tape event is written; `GET /v1/models` and buffered `POST /chat` answer 502 with `{"error":{"message":"Gateway unreachable","code":"gateway_unreachable"}}`. The WS check sits in `handle_frame` after parsing (a malformed frame still earns its own 400-style error reply regardless of gateway state), the HTTP checks sit after body validation for the same reason. `POST /chat` was not named in the plan's commit text but is the same chat subsystem, so it short-circuits too. The 502 code and envelope match the existing transport-failure relay (`gateway_unreachable`), with the difference that the short-circuit's message is the fixed user-visible string rather than a `std::error::Error` rendering. No new UI work: `WorkshopSocket.streamChat` already rejects on error frames (murm-ui surfaces the message), and the status bar already shows the heartbeat's "Gateway unreachable" label. The optimistic initial flag (entry 73) is what keeps a pre-probe request behaving identically to pre-heartbeat code - it flows upstream and fails or succeeds on its own merits. Tests publish to the flag directly (`GatewayHealth::publish` is `pub(crate)`; the heartbeat is the only production writer) rather than waiting out a real heartbeat, keeping the degraded paths deterministic. - Evidence: the plan fixes the error-frame message and the no-upstream-attempt requirement, and asks for "a clean 502/503 with a user-visible message, not a panic" on the catalog route. 502 over 503 keeps one status and one envelope code for every unreachable-gateway answer the server produces, so a client has exactly one shape to handle. Not taping a short-circuited chat follows the tape's contract of recording exchanges (entry 6): no request reached the gateway, no tokens were billed, there is no exchange to record - unlike the mid-stream and declined cases, which tape because the gateway participated. The "server boots with a never-reachable gateway" half of the plan needed no code: `AppState::new` never contacts the gateway, and the serve test pins boot + `/health` + `/` against a dead gateway through the real `spawn` path with the heartbeat running. - Cost: a chat that arrives in the milliseconds between the gateway dying and the next probe still attempts upstream and fails with a transport error (the flag is only as fresh as the last probe - acceptable at a 5 s interval on loopback); the flag is binary, so a gateway that answers `/health` but 500s every completion reads as "reachable" and chats fail the slow way; and the WS short-circuit skips the "Submitting request..." status the instrumented path would emit, so the bar stays on the heartbeat's label - which is the correct picture, but means the two paths' status sequences differ subtly. @@ -520,6 +520,6 @@ Running log of design choices for the PromptForge Workbench stage 1 build. Each ## 87. Form-container position override for embedded mode -- Choice: `.mur-app-embedded .mur-chat-form-container { position: relative }` puts the form in the flex column flow so the scroll area shrinks as the composer grows; `.mur-app-embedded .mur-chat-history { padding-bottom: 1rem }` replaces the 7rem bottom padding (no longer needed since the form is not floating). The override applies only to the embedded workbench mode, leaving murm-ui's standalone absolute positioning intact. +- Choice: `.mur-app-embedded .mur-chat-form-container { position: relative }` puts the form in the flex column flow so the scroll area shrinks as the composer grows; `.mur-app-embedded .mur-chat-history { padding-bottom: 1rem }` replaces the 7rem bottom padding (no longer needed since the form is not floating). The override applies only to the embedded workshop mode, leaving murm-ui's standalone absolute positioning intact. - Evidence: the base.css form-container is `position: absolute; bottom: 0` which overlays the scroll area. The chat-history's 7rem bottom padding reserves space for a single-line composer, but multiline voice transcripts overflow it and cover messages. With `position: relative`, the form joins the flex flow and `flex: 1; min-height: 0` on `.mur-chat-scroll-area` (already in base.css) makes the scroll area shrink to accommodate growth. The send button hover also gets a consistent accent glow via `.mur-action-btn:hover:not(:disabled)`, and the recording mic hover shows a brighter danger glow (wider box-shadow at 70% opacity vs 55%) distinct from the non-hovered recording state. - Cost: the form no longer floats over the last few messages, so the user sees slightly less chat history at the bottom edge; accepted because the alternative (overlap) is worse. The gradient fade on the form-container becomes inert (it fades into the background it now sits on), harmless visually. diff --git a/workbench.example.toml b/workshop.example.toml similarity index 88% rename from workbench.example.toml rename to workshop.example.toml index f7b366e7..f9aaead2 100644 --- a/workbench.example.toml +++ b/workshop.example.toml @@ -1,12 +1,12 @@ -# PromptForge Workbench example configuration. +# PromptForge Workshop example configuration. # -# The promptforge-wb desktop shell generates ~/.promptforge/workbench.toml +# The promptforge-ws desktop shell generates ~/.promptforge/workshop.toml # from a built-in template on first run, so no setup is needed there. This -# file is the commented reference: copy it to workbench.toml and edit when +# file is the commented reference: copy it to workshop.toml and edit when # you want a configuration the template does not produce - beside the # shell's executable or in the current directory to shadow the profile -# copy, or in the current directory for the promptforge-wb-server binary, -# which reads workbench.toml from there only. +# copy, or in the current directory for the promptforge-ws-server binary, +# which reads workshop.toml from there (or workbench.toml if that is absent). # # String values support ${VAR} environment interpolation; $$ is a literal # $, and an unset variable interpolates to the empty string. @@ -24,7 +24,7 @@ api_key = "${PROMPTFORGE_GATEWAY_API_KEY}" path = "tape.jsonl" [server] -# Address the workbench server binds to. +# Address the workshop server binds to. # Default: "127.0.0.1:7910" bind = "127.0.0.1:7910" # When true, the server binary opens the system browser at its address once From 4bdec5919bf1b38c33fcb66aa0baa173f33db090 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Tue, 25 Aug 2026 18:15:42 -0700 Subject: [PATCH 002/124] Add the What PromptForge Is design document Add design/what-promptforge-is.md, a 398-line positioning and design paper for the whole system. Its thesis: human intent is the source code, and everything downstream (plans, prompts, reports) is a build artifact to be regenerated rather than rewritten. The paper walks the four layers - the promptforge-core engine that executes Markdown/Lua prompt pipelines, the credential-holding gateway, the toolchain (CLI, MCP server, tool picker, webfetch, dev runner), and the Workbench desktop app - and grounds each design choice in the history that produced it: the Staker, a sixteen-step stakeholder-analysis pipeline run inside a chat harness, and the 120-rule field manual distilled from 89 transcripts. Two measured effects carry the argument. Semantic blur: model rewrites of prompt files grow them without adding capability (one rulebook grew 145.8 percent), so plans should be preserved and artifacts regenerated. The fan-out asymmetry: models evaluate more reliably than they generate, so the human sits in the judge's seat. The Workbench sections describe an append-only hash-chained event store as the product, an identity model (ExecutionFingerprint, PromptVersion, stable block IDs), plan mode as an entropy filter, a leaderboard replacing the tree view, on-device voice, and a sidecar critic, then explicit non-goals, a six-stage build order, and open questions. - The document calls the desktop app the Workbench throughout; its footer dates the text 2026-08-24 and credits Kimi K3 (Cursor agent) as the drafting model. - Quantitative claims cite internal reports (the semantic-blur measurement, an August infrastructure analysis), and the softer numbers carry inline confidence tags; the 6-to-10x wall-clock speedup, for example, is marked estimate, medium confidence. - Documentation only; no code changes. --- design/what-promptforge-is.md | 398 ++++++++++++++++++++++++++++++++++ 1 file changed, 398 insertions(+) create mode 100644 design/what-promptforge-is.md diff --git a/design/what-promptforge-is.md b/design/what-promptforge-is.md new file mode 100644 index 00000000..1cd45ebb --- /dev/null +++ b/design/what-promptforge-is.md @@ -0,0 +1,398 @@ +# PromptForge: Human Intent Is the Source Code + +An execution engine and workbench for prompt pipelines, built on the principle that everything downstream of human judgment is a build artifact. + +Vinnie Falco, August 2026 + +## Executive Summary + +PromptForge is an execution engine that turns Markdown files into executable AI prompt pipelines, built around a Rust library that parses and runs them against any OpenAI-compatible endpoint. The Workbench is a standalone local application, now in development, that wraps the engine in an environment where every run, edit, decision, and mistake is recorded. One product, one premise: human intent is the source code, and everything downstream of it is a build artifact. + +The product exists to build pipelines that survive context pressure and to build them quickly. A pipeline written as a PromptForge prompt executes with deterministic control flow, isolated sections, and a concurrency limit set in configuration rather than in a model's memory. The same pipeline that once required a chat harness's main context to act as scheduler, tracking dozens of sub-agent dispatches in prose, now runs as a program. Watch mode reruns on save, so the edit-run loop is limited by thinking rather than setup. + +The methodology underneath is a field manual of 120 rules, distilled from 89 working transcripts, that governed how I built prompt pipelines by hand. The engine compiles the structural half of that manual into the runtime itself. Fresh virtual machines per section, a run-scoped store, and engine-controlled fan-out enforce rules that once depended on a model remembering them under the very conditions that make models forget. A rule compiled into structure cannot silently degrade. + +The Workbench exists because the conversation is where human judgment happens, and every harness I have used discards it. The reasoning, the indecisions, and the mistakes that explain why the right answer is right are lost when the session ends, and they cannot be reconstructed afterward. The Workbench captures all of it into an append-only, hash-chained event store, paid once at capture time and cheap to extract forever. The data sits still while the extractors improve. The asset appreciates. + +## 1. What PromptForge is + +### 1.1 The thesis: human intent is the source code + +Prompts have become the control surface of serious AI work, and the industry still treats them as disposable text typed into a chat window. The default artifact of a working session is a transcript that disappears when the session ends. The prompt inside it is never versioned, cannot be rerun, and is quickly forgotten. The more serious the work, the stranger this arrangement becomes, because the prompt is where the engineering actually happens. + +PromptForge answers that condition with one organizing principle, stated in the design record: human intent is the source code, and everything downstream - plans, prompts, reports - is a build artifact. The corollary follows the way it follows in any build system: version the source; regenerate the artifacts. A prompt file is compiled output. It is produced from intent, it is regenerated when intent changes, and it is never the thing that matters most. + +The build-artifact framing is a discipline before it is a mechanism. Source is what a human meant; artifacts are what a machine made of it. Plans, prompts, and reports all sit downstream of the intent, so they can be regenerated whenever the intent improves. Regeneration costs a run rather than a reconstruction. What cannot be regenerated is the intent itself, which is why the system treats capturing it as the operation worth engineering for. + +The thesis inverts the usual hierarchy of tooling. In most tooling the generated artifact is the product and the conversation that produced it is exhaust. Here the human's judgment is the scarce resource, and the system exists to keep that judgment in the loop and on the record. Models advise and compare; humans decide. Every design decision in the product, from the engine's isolation model to the Workbench's event store, traces back to that ordering. + +A prompt is paid for on every use: every added token competes for the model's attention. A bloated prompt is not a style problem; it is a recurring cost on every run, and it dilutes the human signal the prompt exists to carry. Treating prompts as source-adjacent artifacts, engineered and versioned and regenerated, is what the economics already demanded. + +The premise underneath is human-first, and it is stated without apology anywhere in the design. Human judgment is the scarce resource in any AI workflow; model output is abundant and grows cheaper by the quarter. Software that respects that ordering exists to capture and serve the judgment, never to replace it. The isolation, the determinism, and the record keeping in this design are what that premise looks like taken seriously as engineering. + +The two halves of the product match the two halves of the problem. The engine answers reliability: how a pipeline survives the conditions that break chat-harness orchestration. The Workbench answers memory: how the judgment that built the pipeline outlives the session that contained it. Neither half is optional to the thesis. An artifact you cannot rerun and a process you cannot recall are both losses of the source. + +The product stands on two claims. The first is that the engine makes pipelines reliable: deterministic control flow, structural concurrency, and isolation that keeps context pressure from accumulating across a run. The second is that the Workbench makes the process that built them remembered: a record of every run, edit, and decision that appreciates rather than disappears. If intent is the source, everything else can be regenerated, and nothing that matters should be thrown away. + +### 1.2 The system at a glance: engine, gateway, toolchain, workbench + +The system has four layers, and the engine is the center. PromptForge turns Markdown files into executable AI prompt pipelines: a prompt is a Markdown document with YAML frontmatter, one H1, H2 sections, embedded Lua blocks, and prose blocks. promptforge-core is the Rust library that parses those files and executes them against any OpenAI-compatible endpoint. The format is deliberately boring: a prompt reads like a document and executes like a program, so the artifact a human reviews and the artifact the engine runs are the same file. The execution model is where the discipline becomes structure. + +The gateway is the one process that talks to model backends. promptforge-gateway serves an OpenAI-compatible HTTP API, holds every credential, routes chat completions to configured backends, manages a model catalog, runs a built-in web search tool, and spawns local llama-server processes for GGUF models. Callers ask for capabilities by name; the gateway substitutes the backend's own model string and restores the caller's name on the response. Nothing else in the system ever touches a key. For local models it provisions a pinned llama-server binary and spawns one child process per model, so an owned node joins the same catalog as a frontier API. + +The toolchain makes prompts runnable and callable from wherever the work happens. promptforge-cli runs a prompt in a single process. promptforge-mcp-server makes prompts callable from agentic harnesses such as Cursor over the Model Context Protocol. promptforge-tool-picker resolves natural-language capability descriptions to tools with a compiled-in embedding model. promptforge-webfetch fetches URLs behind a four-layer SSRF boundary. promptforge-dev is the watch-mode edit-run-inspect loop. Each is a small crate with one job; each works alone, and all of them compose through the gateway. + +The Workbench is the fourth layer and the reason the record exists. It is a standalone local application in development: a Rust server crate with a webview shell where a prompt is a visible, editable stack of blocks and every run, edit, decision, and mistake is recorded in an append-only event store, on the premise that the record is the product and every interface is a view over it. The workspace holds more than 250 Rust source files across these crates. The Workbench is where the record becomes an environment. Figure 1 shows the four layers and how they connect: the Workbench and the toolchain sit on the engine, the engine talks only to the gateway, and the gateway fronts every model backend, local or frontier. + +```mermaid +flowchart TD + WB["Workbench: server crate plus webview shell"] --> CORE[promptforge-core] + WB --> DB[(Append-only event store)] + MCP[promptforge-mcp-server] --> CORE + CLI["promptforge-cli and promptforge-dev"] --> CORE + CORE --> GW[promptforge-gateway] + GW --> LOCAL[Local GGUF models] + GW --> FRONTIER[Frontier APIs] +``` + +Figure 1. The PromptForge system. The engine executes prompts; the gateway fronts every model backend; the toolchain makes prompts runnable and callable; the Workbench records everything. + +## 2. Where it came from + +The product has the shape it has because I built serious pipelines the hard way first. The background is compressed on purpose, and it is only four things: what the apprenticeship looked like, what it broke on, what I measured, and what I recognized. The product is the point; the history only explains why the product looks like this. + +### 2.1 The apprenticeship: serious pipelines inside a chat harness + +The tools were single self-contained Markdown files executed by a chat harness's agent. The harness was Cursor, the AI code editor: its agent dispatches sub-agents, and its main context held the orchestration. A working session looked like this: the main context read a plan, dispatched sub-agents to execute steps, read their outputs from files, and wired the files together. The main context was the scheduler, the memory, and the assembly line. + +The pattern was genuinely clever, and for a long time it held real pipelines together. Sub-agents were dispatched by reference: the dispatch prompt contained only the tool file's path and a tag name, and the sub-agent grepped the tag and followed the enclosed block. This prevented the orchestrator from paraphrasing engineered instructions under context pressure. Scratch files were the data bus between steps, and assembly was mechanical shell concatenation. The main context read structured output from files, never from sub-agent return values, which kept raw web content out of the main window. + +The supporting rules held the pattern together. Every factual claim was confirmed against a second independent source, the two-source rule. Every sub-agent that touched the web recorded its sources in a Source Log, one entry per URL, deduplicated by the main context. A sub-agent that could not verify a fact omitted it rather than inventing it. Each of these rules was a sentence a model had to remember, and each was load-bearing. The two-source rule was the only thing standing between a pipeline and a confident fabrication. The Source Log was the only thing that made a hundred web fetches auditable afterward. These were prose rules, enforced by discipline, and they worked as long as the discipline held. + +The canonical dispatch template, taken verbatim from the Staker, shows both the ingenuity and the cost: + +> Grep the tag {tag} in tools-public/tools/staker.md and follow it verbatim. Values: {only the mechanical run values this task needs}. Inputs: {paths}. Output: {path}. Return one status line. + +Every guarantee in the pattern depends on the orchestrator's attention holding. The orchestrator is a model, its attention is a finite budget, and scheduling spends that budget. The Staker was the largest pipeline I built this way, and it broke in instructive places. + +### 2.2 The Staker: a sixteen-step pipeline as stress test + +The Staker is a stakeholder-analysis pipeline, built for governance analysis of bodies like WG21, the ISO C++ standards committee. Given an organization, it surveys the public record, identifies who holds power and who captures benefit, runs a battery of diagnostic tests drawn from institutional theory, challenges its own findings adversarially, hunts for dark stakeholders - actors who benefit invisibly - and writes an Assessment: themed dossiers, a stakeholder register, key judgments with confidence tags. Like every tool in the set it ships as a single self-contained file with its own persona; the Staker's framing is a vampire-hunter theme, which is where the name comes from. + +The scale comes first, because the scale is what made it a stress test. The pipeline runs sixteen numbered steps, twenty-two once sub-steps are counted. It runs a diagnostic battery of 53 baked-in tests across eight clusters, plus up to ten domain-specific rules discovered at runtime. A full run dispatches roughly 40 to 60 sub-agents (estimate, medium confidence). The pipeline is a ten-way parallel survey, stakeholder identification, parallel stakeholder research, the diagnostic battery in nine batches, stakeholder assessment, relationship mapping, an adversarial challenge pass, dark-stakeholder detection, directional research, coupling analysis, allocation, a packet builder, parallel writers, and two audit passes. + +The Staker proved the pattern could do real work, and did it at scale. It also exposed the pattern's limits, and the failures were three. The orchestrator re-interprets what it understands: an instruction read is an instruction rewritten, and the rewrite drifts. Quantified constraints drop under context pressure: the concurrency rule was prose - run at most 4 sub-agents at once - and a model must remember to obey it on the fortieth dispatch. And every sub-agent re-read the whole tool file to find its own instructions, paying the full prefill cost of the file for a tag's worth of content. None of these failures is a bug in the ordinary sense. Each is the predictable behavior of a capable model asked to hold an operational discipline in working memory while also doing the work. + +The size is where the symptom shows. At the July measurement the Staker stood at 138,184 characters and 1,404 lines, against a median tool size of 31,049 characters across the 23 tools in the same directory: more than four times the median and 40 percent larger than the next-biggest tool. It has grown since, to 139,587 characters as of August 24. Much of that length is orchestration procedure, rules for dispatching sub-agents and concatenating files, rather than the stakeholder analysis the tool exists to perform. The field manual was written to hold this together. + +### 2.3 The field manual: one hundred twenty rules from eighty-nine transcripts + +The manual is how-to-falco.md, "How to Build AI Tools and Prompts," dated 2026-08-07. It was distilled from 89 chat transcripts via an evidence packet and verified against the source material. It holds 120 numbered rules in seven groups: writing instructions models follow; tool prompt architecture; plan shaping and data flow; subagent discipline and orchestration; pipeline design and composition; document shaping and prose craft; testing, debugging, and iterating. The groups span the whole craft, from the sentence level - how an instruction is phrased so a model cannot misread it - to the pipeline level, where data flow and composition decide whether a run coheres at all. It is the distilled practice of the apprenticeship, written down so the next pipeline would not relearn it. + +The manual's introduction has two binding ideas. Keep the main context clean by dispatching all work to subagents. Write every instruction so only one reading is possible. Everything else in the 120 rules is an application of those two. The manual closes on the line that matters most: the human is the final compressor. + +Then came the turn. A manual binds only while the model remembers it. Context pressure is exactly the condition under which the rules are needed most and remembered least. The Staker's failures were not failures of the manual; they were the ceiling of prose discipline itself. That ceiling is why the engine exists. + +### 2.4 Two measured effects: semantic blur and the fan-out asymmetry + +Two measurements from the apprenticeship later became design decisions in the engine and the Workbench. The first is semantic blur, from "Semantic Blur: Why Rewriting a Prompt File Degrades It," dated 2026-07-26. Three prompt files under version control were rewritten by a model over three weeks. All three grew with no new capability: the Architect from 49,831 to 59,128 characters (plus 18.7 percent), the vibe-coding how-to from 44,345 to 63,665 (plus 43.6 percent), the prompt rulebook from 10,612 to 26,089 (plus 145.8 percent). The rulebook began as 1,737 words of bullet-form rules and reached 4,325 words. + +The growth concentrated in commits labeled "refactor": one commit added 83.1 percent to the rulebook in a single pass, and another added 31.2 percent to the vibe-coding how-to. The mechanism is regression toward the mean. A model asked to rewrite a file regenerates every token from its prior distribution, which favors completeness, justification, and formal register: the properties of average technical writing and the wrong properties for a compressed instruction set. The first version is sharp because it is conditioned on human intent, which sits at the tail of the model's distribution. Each rewrite conditions on the model's own prior output, nearer the center, so the human signal decays pass over pass. The prior's pull is not malicious; it is the model doing what it was trained to do, which is produce what good documents look like on average. A compressed instruction set is not an average document, and that mismatch is the whole injury. + +The cleanest instance is a substitution in the vibe-coding how-to. The first version justified a rule with a measurement: + +> a model asked to review its own work in the context that produced it does worse than not reviewing at all, moving GPT-4 on GSM8K from 95.5% to 91.5% after one round and 89.0% after two + +The sixth version states a general claim instead: + +> a model reviewing its own work in the context that produced it does worse than not reviewing at all, and models favor their own output when they can see it + +The checkable number left; the unverifiable assertion stayed. The Staker is named in that report as the terminal case: still recognizable, every surface encrusted with detail added one pass at a time. The fix follows from the mechanism: the plan holds discrete decisions, which survive translation, while prose is continuous and drifts. Preserve the plan, regenerate the artifact, never rewrite the file. The irony, stated in the report itself, is that the Architect - a plan-mode tool that accumulates design intent through dialogue into a reviewed plan document - already prescribed this for its own outputs and was itself degraded by the practice it forbids. The lesson's stated confidence is high for the measured corpus, medium that it generalizes. Figure 2 draws the two loops side by side: the rewrite loop, where the generated file re-enters the model every pass and degrades toward the average, and the regenerate loop, where new information enters the plan and each file is a fresh single pass that holds its sharpness. + +```mermaid +flowchart TD + subgraph blur ["The rewrite loop, degrades each pass"] + A1[Human intent] --> B1[Generate file v1] + B1 --> C1[File v1] + C1 --> D1["Rewrite: whole file back through the model"] + D1 --> E1["File v2, larger and blander"] + E1 --> D1 + end + subgraph clean ["The regenerate loop, holds sharpness"] + A2[Human intent] --> P2["Plan: discrete decisions"] + P2 --> B2[Generate file] + B2 --> C2[File] + NewInfo[New information] --> P2 + P2 --> B2 + end +``` + +Figure 2. Two revision loops. In the rewrite loop the generated file re-enters the model every pass and drifts toward the average. In the regenerate loop, new information enters the plan and the file is regenerated in a fresh single pass constrained by preserved decisions. + +The second measurement is the fan-out asymmetry, from "The Fan-Out Problem: Why AI Is a Critic, Not an Author." Generation samples from a learned distribution concentrated on the typical; evaluation is a local ranking computation over a candidate, which is cheaper and more reliable. The best answer sits at the tail of the distribution by definition of being atypical, and the generator has no operator that points toward the tail. Raising temperature spreads mass around the typical; it does not push toward the peak. + +Best-of-N partially escapes: expected quality grows logarithmically in the number of samples, and the ceiling is whichever is weaker - how far from the mean the generator can reach, or whether the evaluator recognizes the tail as best. The critic reliably separates competent from incompetent. It is less reliable separating good from great, because great is rare in its training data, so pure AI-on-AI loops plateau at polished but safe. The lesson's final claim, stated at high confidence: + +> An LLM generator samples from a distribution centered on the typical. The optimum lives at the tail of that prior. The generator has no internal operator that points toward the tail, so single-shot generation returns the median of the prior conditioned on `S`, never the peak. Evaluation is ranking, which is cheaper than search, so the evaluator is stronger than the generator. All peak-quality AI output is therefore the product of a directed loop in which an evaluator steers a generator, and the whole system is bounded above by the evaluator's own calibration. A human with taste in the evaluator seat raises that ceiling; a human with taste in the generator seat, using the AI as a filter, raises it further. (high confidence) + +Each effect has one lesson. Semantic blur: preserve the plan and regenerate the artifact, because discrete decisions survive translation and prose does not. The fan-out asymmetry: seat the model as critic and the human as judge, because evaluation is cheaper than generation and the critic's ceiling is good-versus-great. The first lesson points to plan mode; the second points to the leaderboard, and both become Workbench machinery. + +### 2.5 The recognition: the chat is the asset + +The design record is direct about this: today's harnesses discard the most valuable substance in the workflow - the reasoning, the indecisions, and the mistakes that show why the right answer is right. The conversation is where the judgment happens. Every harness I worked in threw it away at the end of the session. The artifacts survive; the reasoning that produced them does not, and it is the reasoning that makes the artifacts improvable. + +The evidence that the reasoning cannot be reconstructed afterward is the Chatlight transcripts. Chatlight is a transcript-harvesting system I built to extract chat sessions from the harness and preserve them as durable files that can be searched, mined, and replayed - the bridge between conversations that disappear and a record that persists. The transcripts include a 1,201-line architect session log harvested from the chat harness. Reading it back, the reasoning is all there and nowhere else: absent from the artifacts the session produced, absent from my memory, impossible to reconstruct from the diff. + +The design record is blunt about the economics of all this: capture is paid exactly once while extraction is cheap forever. The data sits still while the extractors improve. The asset appreciates. A recorded session from a year ago is more valuable today than the day it was captured, because the tools for mining it are better and the session itself is unreproducible. A captured session is the rare asset that gains value by sitting still, because every improvement in extraction tooling retroactively improves every session already in the store. + +Skillgate is the working example of those economics. It ingests Chatlight's captured sessions and characterizes the operator's skill by cleverly compressing their prompts and model replies. The compression turns a pile of transcripts into a verdict about the person who produced them. Capture was paid once, when the sessions were saved; a tool like Skillgate mines the record forever. The sessions themselves do not change after the day they were captured; only the tooling that reads them does. + +The apprenticeship produced the engine first, because pipelines kept breaking under context pressure. The recognition produced the Workbench, because the process that built the pipelines was worth more than the pipelines. The engine came first; the Workbench is where the recognition becomes a product. + +## 3. The engine: prompts as executable programs + +### 3.1 A prompt is a program: Markdown, Lua, and sections + +A PromptForge prompt is a Markdown document with YAML frontmatter, one H1, H2 sections, embedded Lua blocks, and prose blocks. The frontmatter declares the prompt's name, description, and a promptforge version key, and unknown frontmatter fields are rejected rather than ignored. One frontmatter key, max_tool_iterations, caps model round-trips per section and defaults to 24. The file is the program; the engine is promptforge-core, the Rust library that parses and executes it. + +A run executes the file in a fixed order. The H1 region, the preamble, runs once before any section: it declares tools and models, sets variables, and can short-circuit the whole run by returning a value. Then the H2 sections walk top to bottom in fall-through order. A section whose Lua returns nothing falls through to the next. Within a section, the last prose block runs a full tool-call loop against the model; earlier prose blocks run single-shot. The distinction between the two prose-block kinds is practical. A single-shot block formats one request and takes one answer. The tool-call loop lets the section's model call its scoped tools, read the results, and continue until it produces a final answer or exhausts the max_tool_iterations budget. + +Control flow is explicit and written in Lua. jump(target) transfers control to another section and clears the conversation. execute(target, input) runs a section as a subroutine with a fresh virtual machine and a fresh conversation, nesting up to 8 levels deep. The two transfer forms differ in what survives. jump abandons the current conversation entirely, which is the right move when a phase is finished. execute preserves the caller and returns the subroutine's answer as reply, which is the right move when the work is one step in a larger argument. The orchestration that the apprenticeship wrote in prose - dispatch this, then that, then assemble - is ordinary control flow here, and it cannot be paraphrased because it is never read aloud. + +Each section's VM provides a fixed environment: args, the run input; sys, sealed runtime metadata; var, a JSON-only data bridge that persists across sections; store, the virtual filesystem; tools; log; and reply, the previous section's answer. Prose blocks support template substitution through double-brace paths into args, reply, var, sys, item, and section-local globals. The template paths are the section's window onto the run. A prose block can interpolate the run input, the previous section's answer, any value in var, and the sealed runtime metadata. The prompt the model sees is assembled from structured state rather than copied forward from an earlier message. The shape of the environment is the shape of the discipline: state moves through named, structured channels rather than through a growing conversation. + +The sandbox is deliberately small, because a prompt is code, and code should never surprise the person who wrote it. The Lua environment provides only the string, table, and math libraries, and dangerous globals are removed. A runaway block is aborted after roughly 10 million instructions, and the per-VM memory ceiling defaults to 64 MiB. The store is a run-scoped virtual filesystem shared across sections, with write, read, append, str_replace, glob, exists, and delete. Paths are validated: forward slashes only, no traversal, no Windows device names, a 1024-byte limit. str_replace refuses ambiguous matches, because a silent wrong edit is worse than a loud refusal. The store plays the role scratch files played in the apprenticeship, with the validation the shell never provided. State between steps moves through named paths with checked writes, and the assembly that used to be mechanical concatenation is a read from the bus. + +Models and tools are declared by capability, not by name. Models are declared by capability description and resolved semantically against the gateway's catalog at runtime, with hard constraints - thinking, context, temperature, max_tokens - filtering before resolution. models.default sets the baseline, models.use overrides per section, and models.infer runs a single tool-free inference round from Lua. Tools are declared by capability description and resolved by the picker; a declared tool is invisible to the model until scoped with tools.always or tools.add, and tools.add_local declares a tool backed by a Lua function inside the section. Untrusted tool output is wrapped in a CSPRNG nonce envelope before reaching the model, so fetched content cannot inject instructions. Capability-based declaration pays off beyond safety. A prompt that names capabilities rather than vendor model strings survives a backend change as a configuration edit rather than a rewrite. The same prompt file runs against a frontier API or a local GGUF model without modification. + +Two design choices deserve their reasons, because both are the semantic-blur lesson applied to pipeline state. var is JSON-only because discrete data survives translation while prose drifts: state that must survive the run travels the discrete channel. And every section gets a fresh VM and a fresh conversation, so context pressure never accumulates across a run. Isolation is the property everything else builds on: the fortieth section executes with the same attention budget as the first. This is the direct answer to the apprenticeship's central failure. The guarantees no longer depend on an orchestrator's attention holding, because there is no orchestrator. There is control flow, and control flow does not get tired. + +### 3.2 Deterministic fan-out and the store + +The apprenticeship's concurrency rule - run at most 4 sub-agents at once - was a sentence of prose a model had to remember. fanout(worker, collection) is the same idea made structural. It maps a worker section over a collection in parallel, and the machinery that prose used to request is now guaranteed by the runtime. + +Each arm of the fan-out gets its own isolated execution environment: a fresh Lua VM, the current member as item, a 1-based sys.index, and a unique run-global sys.id. The harness assigns the index; the model decides only what requires judgment. The division of labor is the apprenticeship's dispatch template dissolved into semantics: what used to be a quoted instruction a sub-agent had to follow is now a property of the execution arm the section runs in. Each arm starts with a fresh clone of the caller's var, and arm writes never reach the caller. Isolation is not requested in a dispatch template; it is the shape of the execution. + +Results return in collection order, not finish order, and each result has .text, .ok, .item, and .exhausted. Collection-order return keeps downstream assembly deterministic: the caller zips results against the original collection without tracking which arm finished when. The result fields separate the answer from the outcome, so a caller can tell an arm that produced weak output from an arm that never finished. All arms share the run's store, and the store catches the races: two arms writing the same path is a hard error, a write-write race, while store.append from concurrent arms stays legal. Failure semantics are explicit. An arm that exhausts its tool-loop budget soft-degrades into an incomplete stub rather than failing the fanout. A fatal error in any arm aborts its siblings, and cancellation propagates cooperatively. An empty collection is an error, because no work is likely a bug. The soft-degrade rule exists because a long fan-out over real data eventually meets a section that cannot finish. An incomplete stub preserves the other arms' work and lets the caller decide whether the gap matters, rather than discarding a run over one exhausted budget. + +The limit is a number in configuration. Default concurrency is 8 parallel arms, set by RunLimits.max_fanout_concurrency. Operationally this is the whole point: on owned hardware the operator raises a number, and nobody re-trains a model's memory. The constraint that prose could not hold under context pressure in the apprenticeship is now a value the engine enforces on every arm of every run. And when the hardware is owned, raising the number is free: the constraint moves from the model's memory to the operator's configuration, which is where operational limits belong. + +### 3.3 The gateway: one process holds the keys + +The gateway is the trust boundary of the system, and its design premise is that one process can hold every key safely. promptforge-gateway serves an OpenAI-compatible HTTP API. Chat completions, embeddings, and rerank are routed by model kind. The caller asks for a capability name, for example "reasoning-large"; the gateway substitutes the backend's own model string and restores the caller's name on the response. Model resolution is one exact string lookup, and a miss is a 404: no prefix matching, no alias chain, no default model. + +Credentials sit in the gateway and nowhere else. The Secret type redacts in both Debug and Display, so a key cannot leak through a log line or a panic message. Environment-variable interpolation in configuration is explicit, and an unresolved variable fails the load. There is no implicit pickup of ambient API keys. A missing secret is therefore a startup error with a name rather than a silent fallback to whatever happened to be in the environment. Any request field the gateway has never heard of passes through to the backend untouched, so new vendor parameters need no gateway release. + +Concurrency is a named resource. A dominion is a named pool of compute with one concurrency limit and one bounded queue, shared by everything bound to it. Its fields are max_concurrency, max_queue (default 100), policy (queue or reject), and fair_scheduling (round-robin by client header, default on). An endpoint without a dominion is unlimited. Fair scheduling is on by default, round-robin by client header, so one chatty client cannot starve the others bound to the same pool. Local dominions have a VRAM budget; every bound local model declares a footprint estimate, and an over-booked budget fails validation at boot rather than surfacing as an out-of-memory error mid-run. + +Local models are provisioned rather than assumed. The gateway provisions a pinned llama-server binary - Vulkan on Windows and Linux, Metal on macOS - downloads each GGUF model file with optional SHA-256 verification, and spawns one child process per model. A dead child is respawned once on transport failure. Tool-calling dialect is detected from the child's live properties, with a sidecar file as fallback, and ambiguous evidence is a hard fail, never a silent default. The provisioning story matters because local models are where owned hardware enters the system. A downloaded, verified, pinned runtime is the difference between a node the operator controls and a node the operator hopes about. + +The operational surface is built for running unattended. Profiles are named TOML files beside the boot file, merged through include chains - depth-first, cycles rejected, maximum depth 16 - and a profile can select a subset of the catalog with a models allowlist. Profiles switch at runtime through an admin route, and the switch is all-or-nothing by construction: the new configuration is built and validated first, live state is touched only after it passes, and a failed switch leaves the previous state intact. A bad profile edit cannot take down a running gateway. Streaming is a typed relay: every upstream chunk is parsed, the model name rewritten, and re-serialized, and a client that disconnects mid-stream cancels the upstream stream and frees the queue slot in the same unwind. Nothing is buffered raw and nothing is forwarded unparsed, which is what lets the gateway rewrite model names and enforce its accounting on every chunk. Authentication is a constant-time bearer comparison on every request; the health route is unauthenticated. + +Two more mechanisms round out the boundary. The built-in web search proxies the Brave Search API with the gateway's own credential, strips tracking parameters, diversifies results by host, and marks output untrusted. For backends without native tool calling, an emulated dialect translates the tools array into a plain-language system guide and parses fenced tool calls back out, with warn-and-continue recovery that surfaces a gateway_warning field rather than failing silently. Holding every key is only defensible when the process that holds them is built like a boundary, and this one is: redacted secrets, fail-closed configuration, bounded queues, constant-time authentication, and fetched content marked untrusted at the edge. This is what it takes for one process to hold the keys. + +### 3.4 The toolchain: MCP server, tool picker, webfetch, dev runner + +Four crates complete the system. Each of them answers what it does and why it exists in one breath. promptforge-cli is the single-process runner: `promptforge run [input]`, an in-memory store, no configuration. The result goes to stdout and errors to stderr, nothing mixed. Gateway credentials come from two environment variables, never from command-line arguments, so secrets stay out of process lists and shell history. Exit codes are 0 for success, 1 for operational failure, 2 for usage error, and 130 for cancelled. It is the smallest useful surface: no daemon, no state, no configuration file, and nothing between the prompt and the result. + +promptforge-mcp-server puts a prompt catalog behind four fixed MCP tools - list_prompts, run_prompt, check_run, need_prompt - so the tool list never changes and a prompt saved ten seconds ago is callable with no reconnect. Runs that outlive the 240-second reply deadline continue in the background and are collected by run_id. Admission is bounded, with a default of 4 concurrent runs and a 30-second admission timeout. Live reload watches the prompt files and republishes the catalog atomically after a 500-millisecond debounce. Transports are streamable HTTP with bearer auth, or stdio for a harness-spawned child. The fixed tool list is the interop trick: a harness connects once and sees a stable surface while the catalog of prompts changes underneath it. + +promptforge-tool-picker is a sentence-embedding resolver that turns "read a file from disk" into the tool that does it, with no LLM call and no network. The embedding model, BAAI/bge-small-en-v1.5 at 384 dimensions, is compiled into the library, fetched at build time from a pinned commit and verified against hardcoded SHA-256 digests. Resolution returns one of four outcomes - Bind, Duplicate, Ambiguous, or Absent - and Absent is a successful answer, not an error. The defaults are a similarity floor of 0.825, a margin of 0.05, a duplicate threshold of 0.98, a solo floor of 0.5, and top-k of 3. Because the resolver is local and deterministic, binding a prompt's tools costs no model call and no latency budget worth measuring. + +promptforge-webfetch fetches a URL and returns markdown the model can cite, behind a four-layer SSRF boundary: URL admission, guarded DNS resolution at every hop, redirect re-validation, and no ambient identity - no cookies, no proxy, no credentials. HTML goes through readability extraction; structured text returns verbatim; PDFs and binaries are refused with a message naming the content type. Caps are an 8 MiB body and 40,000 characters returned, both configurable within ceilings, and query strings and fragments are redacted from diagnostics so a token in a URL never reaches a log. The boundary treats fetched content as hostile, which is why the no-ambient-identity layer exists: the fetcher has nothing a hostile page could use. + +promptforge-dev is the edit-run-inspect loop. Point it at a prompt file and a running gateway; it executes, dumps the store to a directory beside the prompt for inspection, and with --watch reruns on every save after a 300-millisecond debounce. --capture-raw persists the verbatim request and response JSON of every model turn, written with owner-only permissions and atomic renames. A failed run still dumps its partial store, which is what debugging needs. The loop is the daily surface of the whole system: edit the file, save, watch the run, inspect the store. The distance between a thought and its test is one keystroke. That is the shape of the whole toolchain: each tool works alone, and all of them compose through the gateway. + +## 4. The methodology: the manual compiled into structure + +### 4.1 Rule by rule: what dissolved into structure + +The field manual compiled into structure, and the mapping is the evidence for that claim, rule by rule. Each entry in it pairs a rule the operator once had to enforce with the engine property that now enforces it. Thirteen rules, thirteen mechanisms, and not one of them requires the model to remember anything. + +- Rule 29, deterministic orchestration for fan-out: fanout() is engine-controlled and each arm gets its index from the harness; the model decides only what requires judgment. +- Rule 30, clear the context on every section transition: each section starts with a fresh VM and a fresh conversation. Structural rather than requested. +- Rule 50, dispatch subagent tasks by reference: each section is its own instructions. There is no 140-kilobyte tool file to grep and no orchestrator paraphrasing it. +- Rule 51, keep the orchestrator ignorant of tool purpose: the orchestrator is Lua control flow. It cannot reinterpret what it does not read. +- Rule 52, keep the main context clean: the main state is var and the store; sections run isolated. +- Rule 54, cap what each subagent returns: arms write to the store and return a status; the caller reads only what it asks for. +- Rule 55, one deliverable per subagent: one fanout arm is one section producing one output. +- Rule 56, isolate each subagent's input: an arm receives its item and a cloned var, nothing else. +- Rule 57, fan out one subagent per independent item: fanout(worker, collection). +- Rule 70, files as the unit of state between steps: the store is the bus. +- Rule 84, writes that naturally replace: store.write overwrites. +- Rule 26, gate on information readiness rather than rigid phases: Lua conditionals around execute and fanout replace numbered-step prose. +- Rule 71, assign each step the cheapest model tier: models.bind per section with capability descriptions, resolved against the catalog. + +These rules no longer depend on being remembered. A rule that cannot be forgotten cannot silently degrade, and that is the difference between a methodology and a runtime. + +### 4.2 What remains human discipline + +The honest boundary is that the engine absorbed the structural rules and the judgment rules remain human. The groups that stay with the operator are writing instructions models follow (rules 1-15), plan shaping (rules 32-48), document craft (rules 88-103), and testing and iteration (rules 104-120). No runtime can enforce whether an instruction admits only one reading; that is a craft judgment, made by a person, at authoring time. + +The manual's own closing line has the division of labor: the human is the final compressor. The same stance matches the fan-out lesson: AI generates the typical and judges the specific; evaluation is cheaper and more reliable than generation; the critic's ceiling is good-versus-great, which is why the human holds the standard. + +The split is not a concession; it is a correct allocation of attention. The structural rules were the ones a runtime could enforce, and enforcing them mechanically frees the operator from checking them by hand. The judgment rules are the ones worth a human's time, and they are now the only places the human's time is spent. The engine took the rules that were structure waiting to happen. The rules that remain are the ones where the human's judgment is the content. + +That hand-off is the Workbench's reason to exist. The rules that remain human - shaping plans, crafting prose, judging output - are the ones the Workbench is built to support. The engine's purpose and the Workbench's environment both grow out of that split. + +## 5. What it is for + +### 5.1 Pipelines that survive context pressure + +Bring the Staker back, this time as a program rather than a prose procedure. The shape is unchanged: sixteen steps, the diagnostic battery in nine parallel batches, parallel writers, adversarial challenge passes. What changes is what holds it together. The same pipeline maps onto sections, execute, and fanout without new machinery, because the machinery the apprenticeship simulated in prose is the machinery the engine actually has. + +The ten-way parallel survey is one fanout over a worker section. The diagnostic battery's nine batches are fanout over test clusters, with the runtime-discovered rules joining the same shape. The adversarial challenge pass and the two audit passes are sections reached through execute, each in a fresh conversation, so the challenger is not the author. The packet builder and the parallel writers read their inputs from the store and write their outputs back to it. + +When the pipeline is a program, the control flow is deterministic. The concurrency limit is a configuration number rather than a sentence a model must remember. Every intermediate is a store artifact that can be inspected after the fact, and a failed run dumps its partial state for debugging. The ten-way parallel survey, the nine battery batches, and the audit passes all run under those guarantees, each with its own fresh context and its own attention budget. + +The operational difference is that the engine does not get tired. It does not paraphrase the instructions, because the instructions are the program. It does not forget the concurrency cap on the fortieth dispatch, because the scheduler enforces the cap instead of recalling it. The three failures of the apprenticeship - reinterpretation, dropped constraints, redundant re-reading - are all closed by the same move: what was prose discipline is now execution semantics. + +Determinism buys three concrete properties. Identical inputs produce comparable runs, so two runs can be diffed and the difference means something. A failure reproduces instead of vanishing into a session that no longer exists. And a run can be re-entered from its store artifacts instead of restarted from the top. The pipeline stops being a performance and becomes an artifact. That last property changes how failures get fixed. A vanished failure is folklore; a reproduced failure is a test case. A pipeline that reproduces its failures is a pipeline that can be improved on purpose. + +### 5.2 The scheduler is the bottleneck + +In August 2026 I ran an infrastructure analysis to answer a concrete question: what would it take to serve a frontier open-weight model to a development team from owned hardware, and would that hardware make pipelines like the Staker faster. The answer I found inverts the obvious expectation. The hardware was nearly idle. The scheduler was the constraint, and the constraint was prose. + +A single modern 8-GPU inference node serving a frontier open-weight model - Kimi K3, 2.8 trillion total parameters, 104 billion active, 1.56 terabytes of MXFP4 weights - can serve on the order of a hundred concurrent requests. Under LLM orchestration, at most 4 sub-agents are in flight at any moment, because the orchestrator is a model tracking dispatches in prose. The node sits at roughly 3 percent utilization, not for lack of compute but for lack of parallelism the scheduler can express. Three percent is the signature of a scheduler that cannot say "run these fifty things" without spending its own attention on each one. The workload had parallelism to spare; the orchestration could not hold it. + +The Staker's dependency chain forces roughly 22 to 25 sequential rounds of sub-agent execution. At 1 to 4 minutes per round, that is a floor of about 45 to 90 minutes per run, before any human interaction. Meanwhile every orchestrated sub-agent re-reads the tool file: about 40,000 tokens of prefill per dispatch, times roughly 50 dispatches, is about 2 million tokens of redundant prefill per run. In PromptForge each section holds only its own instructions, on the order of 2,000 tokens: about twenty times less prefill per arm. Two million tokens of redundant prefill is not wasted money alone. It is wasted serial time on every dispatch, paid before the sub-agent reads a single token of its actual task. On an inference server with automatic prefix caching, the common prefix across fanout arms is cached once, so arms after the first skip nearly all of their shared prefill. + +Combining higher fanout concurrency, the prefill reduction, and deterministic scheduling yields an estimated 6 to 10 times faster wall-clock run on a first execution, and more on re-runs (estimate, medium confidence; workload-dependent). In the same analysis, a fully parallelized PromptForge-executed run lands in the 5-to-8-minute range against the 45-to-90-minute orchestrated baseline. The rack was not the bottleneck; the scheduler was. The practical consequence: the same hardware that crawled through a Staker run in over an hour has the headroom to run it in minutes, once the scheduler stops being a model and starts being a runtime. + +### 5.3 The benefits, enumerated + +Each benefit reduces to its mechanism. + +- Reliability under context pressure: the rules are structure, not discipline. +- Iteration speed: watch mode reruns on save, so a prompt's edit-run loop is limited by thinking rather than by setup. +- Model independence: prompts name capabilities, not vendor model strings, so backends change in one configuration file. +- Credential hygiene: one process holds every key, with constant-time authentication and redacted secrets. +- Interoperability: any MCP harness can call any prompt, and the tool list never changes as prompts are added. +- The record as a byproduct: with the Workbench, every run, edit, and decision is captured without being asked for. + +The last benefit is the pivot. The record is not a feature you remember to use; it is a byproduct of running. Every other benefit on this list could be adopted piecemeal; this one cannot, because a record you must remember to keep is a record with gaps. That property is the whole reason the Workbench exists; it is the environment built around the record. + +## 6. The Workbench: an environment built around the record + +### 6.1 The event store is the product; every interface is a view + +The Workbench's foundation claim, the one everything else stands on: the record is the product, and every interface is a view over it. The single source of truth is an append-only, hash-chained event log, in the design record's phrase a commit log that forbids force pushes. The block editor, the run panel, the leaderboard, and the chat window are all projections of the log and can be rebuilt from it at any time. That single decision collapses what would otherwise be a dozen features into one discipline: if the log is complete and the projections are honest, then history, comparison, and audit are all the same query. + +Two disciplines make the log trustworthy, and both are enforced at the moment of capture. The first: history is immutable while content is destructible. Events are never rewritten, but payloads are separately stored and individually revocable, because secrets get pasted and private blobs land by accident. Redaction tombstones the payload - the event records that the blob existed and when it was destroyed - while the chain stays intact. The second: everything the model sees is captured at the boundary. Every file read, web search response, and fetched page is content-addressed into the store at the moment the model sees it, because a pointer is not evidence. Content addressing deduplicates, so storage cost is proportional to what was observed rather than how often. The boundary rule exists because paths and URLs point at mutable state: the page changes, the file is edited, and only the content-addressed snapshot proves what the model actually saw when it decided. + +Three objects are kept distinct, and the distinction is what makes the record honest. The displayed chat is what the user sees. The composed context is what the model actually saw: system prompt, tool schemas, blocks, history. The log is what the database holds, verbatim. Other products blur the first two and lose the third. Here the log holds the composed context verbatim, and the chat is a view over the human-relevant subset. + +The same discipline extends to the uncomfortable cases, which are where a record proves itself. Compaction is a recorded event rather than a deletion: the recorder sits at the transport layer, so what the summarizer kept and dropped is auditable, and post-compaction degradation becomes a diffable event with a named culprit. Side effects are transactional in the record: every operation with effects is logged with explicit states and an idempotency key where the operation allows one. A crash leaves an honest unknown, never a false success. These disciplines share one premise: the record is only worth keeping if it can be trusted. Trust here is a property of what the recorder refuses to do - it refuses to rewrite history, refuses to drop the boundary, and refuses to claim success it did not see. + +### 6.2 Identity before UI + +This is the least glamorous part of the design, and it is the most load-bearing. The identity model exists so that comparison, caching, and history survive renaming, reordering, and rewriting. Every noun has an explicit identity: Artifact, a content-addressed payload; PromptVersion, the hash of the prompt's blocks; PlanVersion, the hash of the plan's decision record; Run, one invocation of a prompt version with bindings; Variant, a run sharing a fingerprint prefix, produced by re-roll; Branch, a named line of decisions; Decision, a recorded human choice against stable IDs; and Contract, the author-ratified manifest, keyed by durable block IDs. Each noun has its work in the model. Artifact makes payloads comparable by content. PromptVersion and PlanVersion make source and plan diffable by hash. Run and Variant make executions addressable. Branch and Decision make the human's path through the work first-class. Contract pins expectations to identities rather than to text. + +The load-bearing member is the ExecutionFingerprint, and it has three parts. Invocation identity says which run this is. Execution provenance records what produced it, including the capability grants in force. Cacheability policy declares what is safe to reuse. The third part is the insight: "same inputs means cache reuse" is unsound the moment tools, servers, web state, or hosted models drift, and the fingerprint says so. Any run touching external state is non-cacheable by default unless the policy explicitly says otherwise. + +Blocks and decisions have stable identifiers independent of heading, position, or prose. A heading is display metadata, so renaming a section does not sever its history. When identity is severed anyway - an external edit restructures a file, a pasted copy arrives with no provenance - the harness detects the orphan and the human relinks it as a recorded decision. Even the failure mode is designed: severed identity is detected, surfaced, and repaired by a person, with the repair itself entering the record. Identity is infrastructure, and it is defined before any interface is built over it. + +### 6.3 Two model planes, one gateway + +The Workbench serves two model roles, kept separate. The runtime plane serves the models a prompt uses when it executes: local and open-weight models through the existing gateway. The authoring plane is the assistant that helps develop prompts: frontier models by key at first, and eventually a fine-tuned model that speaks the PromptForge language natively. The separation is the point of the design. A prompt's execution models and the author's assistant have different jobs, different trust postures, and different futures. Sharing one gateway gives both planes the same credential hygiene without sharing a single token of context. + +The payoff is self-reinforcing. The Workbench records every chat-to-plan-to-prompt chain with full history, and that record is the training corpus a PromptForge-speaking fine-tune needs. The extraction kinds are already named: SFT pairs, chat excerpt to plan delta and plan to prompt; preference-pair candidates from branch choices; and full process traces. The product generates its own future authoring model as a byproduct of normal use. This corpus contains what scraped data never will: the reasoning at the moment of each decision, the branch choices, the critics' verdicts, and the human's calls, all aligned to the artifacts they produced. + +The parser serves the fine-tune twice. At training time it aligns data at block granularity, mapping a chat excerpt to the exact section that changed. At inference the model's Lua fences are grammar-constrained, so output cannot fail to parse. Custom tokenization was considered and rejected: Lua is well-represented in every code model's training data, so the embedding surgery would gain little. + +The guardrail is plain: capture is not eligibility. Material recorded for provenance is never implicitly promoted into a training dataset. Eligibility is a separate, explicit, per-item gate at extraction time. The record being complete is why the gate must be explicit. + +### 6.4 The window: blocks, store, and run definitions + +The window is concrete: three elements, and each is a renderer for something the engine already has. The block stack renders the prompt's sections as vertically stacked blocks, each showing its prose, its Lua environment, and its offered tools. Editing a block edits the section. Nothing is invented; the Workbench visualizes what promptforge-core already executes. + +The internal filesystem is the store made visible, grouped by kind: inputs, intermediates, outputs. After a run, every file the run touched is visible, including intermediates. The run definition is a named invocation of a specific prompt: these input bindings, this model profile. Bindings are part of the record, so "same prompt, different sample input" is a first-class comparison. + +Fixtures replace dependencies. A prompt's frontmatter declares its inputs and outputs as store paths, and the Workbench binds declared inputs to real files the user picks; it never executes a prompt's environment dependencies. The evidence is papergate, a production PromptForge prompt that analyzes a standards paper: its companion crate links a database client purely to fetch the paper and seed the store, while the prompt itself only reads paper.md. In the Workbench, the user binds paper.md to a sample file and the database is bypassed entirely. The store is the only dependency a prompt actually has. The consequence for daily work is that testing a prompt against a new input is a binding operation rather than an environment operation. No database is provisioned, no credentials are staged, and no production system is touched. The fixture is a file, and the record holds which file it was. + +The character sheet completes the picture: an agent's personality is algorithmic. It is a prompt file plus its store manifest plus its tool set plus its model bindings, with an avatar and a one-line charter. Rulebooks are flavor packs, pre-loaded into the store. Swapping the persona is swapping the manifest: the rulebooks in the store change, the charter changes, and the machinery underneath does not. The romance of the persona is a configuration detail, and saying so is the point. + +### 6.5 Plan mode is the entropy filter + +Prose drifts, as the semantic-blur measurement showed, and plan mode is the defense. It aggregates human intent into a plan document before any action is taken. Small design decisions accumulate across a conversation, some reversing each other; the user reviews and cleans the plan; applying it produces one clean edit instead of the conversation's raw churn. A workbench without this filter is a chat with tools. + +The conditioning is three layers, and the prompt is deliberately the weakest. First, the deliverable is redefined: maintaining the plan is the job, so a user instruction triggers integration, not execution. Second, the tool gate removes capability: in plan mode the harness offers reads, search, and one write target, the plan document, so compliance is structural rather than requested. Third, the prompt handles only the residue: state the invariant, teach the integration behavior, define what apply means. The ordering of the layers is where the design is honest about models. Instruction alone fails under context pressure, so instruction is given only the residue. Capability is removed before behavior is requested, and the job itself is redefined before capability is even considered. Each layer assumes the one beneath it might leak. + +The result is measurable. An attempted blocked mutation in plan mode is an event in the log, so leakage is counted and compared across prompt versions rather than tuned on vibes. And the plan-mode prompt does not start from a blank page: it starts as a distillation of the Architect, a battle-tested plan-mode prompt, and improves through the product's own loop. It is itself a PromptForge prompt, versioned and branched, with candidates compared on the leaderboard. + +### 6.6 The leaderboard replaces the tree view + +The fan-out asymmetry is the design rationale for the whole comparison layer. Re-roll produces sibling variants under identical inputs: best-of-N sampling made a first-class gesture, with the model as critic ranking the variants. Branches are navigated by their outputs rather than by topology: the screen shows a leaderboard of artifacts with model-computed diffs and structured verdicts rather than a commit graph. The gesture matters as much as the mechanism. Re-roll is something the operator does on purpose, at the moment they judge the generator has not reached. The variants land side by side under identical inputs, so the comparison is clean. Evaluation is cheaper than generation, so the interface puts the model's effort into ranking. + +A verdict is not a scalar, and the Workbench treats each one as a document. Each evaluation record holds the rubric dimensions, the critic's identity and version, the deterministic checks - manifest diffs, blur metrics, counts - candidate ordering, confidence or abstention, run cost and latency, and the human's judgment, stored separately. Keeping the critic's verdict and the human's judgment apart is what allows the critic to be calibrated against the human over time. Every field in that record exists to make the critic auditable. Identity and version let a verdict be attributed. The deterministic checks separate the measurable from the judged. Confidence or abstention labels a weak verdict as one. Cost and latency keep quality from ever being cited without its cost. + +Timeline semantics are git's, and they are proven. Revisiting an earlier step always forks; a current pointer marks where you are; forks that produce byte-identical output rejoin automatically, so only real divergence persists. The rejoin rule keeps the branch space honest: a fork that changes nothing collapses back, so what the operator sees as divergence is always divergence that matters. Structured decisions replay cleanly on re-run; free-form edits are ported visibly with human review, never replayed silently. + +The ceiling is plain, and the design treats it as a feature. The critic tops out at good-versus-great, so the final call stays with the human. Every comparison surface in the Workbench seats the model as critic and the human as judge, and the interface is honest about which seat is which. + +### 6.7 Voice is an input device + +Voice ships in stage 1, and the placement signals the product's priorities: capture is the point, and speech is the fastest input the operator has. The technical shape is compact. The webview captures the microphone; PCM frames stream up a WebSocket while interim transcripts stream down. The server transcribes in-process on the local GPU: Whisper large-v3-turbo over a sliding window for interims, Whisper large-v3 pipelined at silence for the final text. Each variant gets the job it fits. The turbo model re-transcribes a sliding window, so interims form and correct as speech continues. The full model, pipelined at silence, is the accuracy ceiling for the text that actually lands. + +The session's jargon is fed to the decoder as an initial prompt, and an optional cleanup pass sees the recent chat. The session's context becomes transcription accuracy that no standalone dictation tool can offer, which is the entire reason voice belongs in the server rather than in a separate utility. + +The rejected options show the principle: browser Web Speech and cloud speech-to-text are both out because audio leaves the machine. Transcription is neither model plane; it is an input device. VRAM is budgeted statically, with Whisper pinned at about a gigabyte, and two move-triggers are named in advance: a prompt wanting transcription as a tool moves it to the runtime plane, and the resident set outgrowing the card moves voice to the gateway. Both triggers are named before either can fire, a habit throughout the design record: the migration path is decided while the decision is cheap. + +### 6.8 The sidecar and the flywheel + +The long game is a model on the side, patiently watching runs and coming to conclusions that inform the user. The sidecar reads the event store and populates the leaderboard's verdicts. Skillgate - a tool that applies a ruthless analytical rubric to captured sessions and produces verdicts - is the working proof that a prompted model with a strong rubric needs no fine-tuning. Fine-tuning carries the burden of proof against that prompted baseline. That ordering saves a project: the expensive path must prove it beats the cheap one, and the cheap one is already running. + +Per-step regression detection has a written specification. In the semantic-blur report, "worse" is already operational: character growth per revision, qualifier density, specific-to-general substitutions. The design record's summary is the whole requirement: the sidecar needs a diff and three counters, not taste. The sidecar advises with memory and never gates, and its per-step verdicts become leaderboard data, so its own accuracy is measured over time. It annotates the record rather than blocking the work, because a gate the operator learns to route around is worth less than an annotation the operator learns to trust. + +The preference-data byproduct comes with a hedge. Every branch edit creates two continuations from the same prefix, and the user's choice is a candidate preference signal rather than a clean label: users choose for hidden constraints, cost, or convenience. The database captures the context around each choice, and nothing is auto-labeled at capture time. Later extractors decide which comparisons are valid training pairs. + +The advantage is plain: no existing tool captures even the raw material, because no existing tool keeps the tree. The Workbench's record is not a better version of what others store; it is a category of data that does not exist anywhere else. + +## 7. Discussion + +### 7.1 What the Workbench refuses to be + +The refusals are load-bearing, and each has its reason. A general-purpose IDE is dead: it duplicates existing editors, carries extreme execution risk, and defers the interesting problem behind a million solved ones. Cursor and Zed remain the harnesses of record for general work, and the Workbench interoperates with whatever harness the user owns rather than replacing it. The refusal is also an allocation of risk: every solved problem re-solved is schedule spent not solving the unsolved one, and the unsolved problem is the record. + +A production runtime is out of scope. The Workbench designs the prompt; finished prompts leave and run elsewhere - Cursor, cloud runs, promptforge-cli. There is one current version, the thing at HEAD, with the full immutable development lineage behind it. The Workbench tracks no releases and manages no production lineage, because that is someone else's job and doing it would dilute this one. + +Multi-file code mutation is out of scope, and the reason comes straight from the fan-out lesson. Tests give ground truth for "works," but architecture judgment is good-versus-great territory, where the model critic weakens. Semantic codebase indexing and retrieval died with the IDE scope. Branch merge is a non-goal: branch, compare, keep; cherry-pick a turn at most. Read the list again and a pattern shows. Each refused scope is either a solved problem, someone else's problem, or a problem where the model's judgment cannot be trusted. What remains is the territory where the product's premise actually operates. + +### 7.2 The build order: a usable tool before the novel core + +The build order is six stages, and the sequencing is what keeps the risk cheap. A usable tool lands before the novel core, and capture runs from the first commit on the raw tape, so the data accumulates while the database is still being built. + +Stage 1 is the window: the server crate, the shell crate, a chat panel with streaming conversation through the gateway, push-to-talk voice, and a raw append-only tape recording every request and response from the first commit. The tape is crude by design: plain JSONL, no ontology, nearly free. It means the database that arrives at stage 4 inherits months of real capture rather than starting empty. Stage 2 is plan mode: the plan generator, with the three-layer conditioning and the prompt distilled from the Architect. After this stage the product matches the one Cursor feature the user refuses to live without, and records everything it does. Stage 3 is the harness: the full tool-call loop and the general tool suite - files, shell, web fetch, MCP client, subagents - plus the block editor. The product is daily-drivable after this stage, and this is the dominant implementation risk, landed early deliberately. + +Stage 4 is the database: the event store, the identity model, the envelope, content-addressed payloads, redaction, with the tape migrating in, gated by a synthetic-trace stress test. Stage 5 is the comparison loop: run definitions, re-roll, the leaderboard, lazy invalidation. The distinctive product loop completes here: structured prompt, recorded execution, variant, comparison, human choice. Stage 6 is the intelligence layer: the sidecar populating verdicts and the timeline query tools - list_branches, read_state_at, diff_nodes, why_died - so the authoring assistant reasons about what-ifs from evidence instead of priors. + +The design record keeps the novel separate from the known-but-expensive, and the distinction orders the risk. The run-record database is the invention; nothing like it exists. The harness is known practice and expensive craftsmanship, and it is the dominant implementation risk. The UI is known practice throughout. The novel component lands fourth, behind three stages of usable tool, because capture never waits for it. The harness lands at stage 3, deliberately early, because a daily-drivable tool is what makes everything else get used. Discovering the dominant risk late would be discovering it expensively. + +### 7.3 Open questions and honest risks + +Four open questions remain, each with a line between what is settled and what is not. The web framework choice for the UI core: stage 1 is settled - no framework, vanilla DOM plus markdown-it - and the choice is re-evaluated at stage 4 when the block editor lands. The decision is deferred because the information that decides it does not exist yet, and the stage-1 choice keeps the deferral cheap. The exact harness tool list is open, including whether git gets a dedicated tool with structured returns or rides the shell tool. + +The per-prompt-type evaluation rubrics are not settled. The evaluation record's shape is settled - rubric dimensions, critic identity, deterministic checks, ordering, confidence, cost, the human's judgment stored separately - but the yardsticks themselves are not. The product can record a verdict with full provenance before anyone can say precisely what the verdict should measure. + +The fourth is the composer's prefix policy: stable-to-volatile ordering, cache breakpoints, compaction timing. It is flagged in the design record as real and unearned, a concern with money attached and no policy yet behind it. Naming it openly follows the same habit as the rest of the design record: a flagged unknown is a work item, while an unflagged one is a future surprise. And the dominant implementation risk is still the harness. Its shape is proven - full capability, no editor - and the cost is craftsmanship rather than invention. + +## 8. The human is the final compressor + +The field manual's closing line is the thesis: the human is the final compressor. A hundred and twenty rules distilled from eighty-nine transcripts, and the last one is this: the compression that matters is the one a person does. + +The fan-out lesson is the same boundary from the model's side. The generator knows the middle. The evaluator knows the region the training covered. The peak is outside both, and a human with taste in the evaluator seat raises the ceiling. The engine took the rules that were structure; the Workbench records the judgment that was not. Every subsystem in the product serves that division. The engine's isolation exists so the machine's part is done with full attention. The gateway's boundary exists so trust has one address. The event store exists so the human's part is never lost. The leaderboard exists so comparison stays cheap and the final call stays human. + +The design record's closing thesis is the product in one sentence: human intent is source; prompts and outputs are derived artifacts; history is retained; models advise and compare; humans decide. PromptForge exists to keep the human's judgment in the loop and on the record. + +## References + +- The PromptForge Workbench (design record), 2026-08-24, Kimi K3 (Cursor agent). +- PromptForge User Guide, assembled from per-crate documentation. +- How to Build AI Tools and Prompts (how-to-falco.md), 2026-08-07, Claude Opus 4.6 (Cursor agent); distilled from 89 chat transcripts. +- Semantic Blur: Why Rewriting a Prompt File Degrades It, 2026-07-26, Claude Opus 4.8 (Cursor agent). +- The Fan-Out Problem: Why AI Is a Critic, Not an Author. +- Rulebook: Structuring Reports, 2026-07-22, Opus 4.8 (Cursor agent). +- Rulebook: Revising Model Prose to Human Standard, 2026-07-12, Claude Fable 5 (Cursor agent). +- Chatlight session transcripts, including the architect session log (1,201 lines). + +*2026-08-24 - Kimi K3 (Cursor agent)* From 6b33dbe0057d49764facecb75e108953b302ddbe Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Wed, 26 Aug 2026 11:16:44 -0700 Subject: [PATCH 003/124] Remove the voice-status line below the chat composer The voice plugin kept a .voice-status div under the composer that grew from zero height through a max-height transition every time it had something to say, shifting the composer up and letting it drop back eight seconds later. Recording state was already carried twice, by the red mic button and the status bar's REC badge, and the routine confirmations ("Recording...", "Transcript ready", "Recording discarded") repeated what the user could already see. The div, its CSS, and the showVoiceStatus helper with its auto-hide timer are gone; setupVoice no longer receives a status element. The messages worth keeping are genuine failures: microphone permission denied, voice capture unsupported by the browser, a dropped voice connection, capture failure, plus the no-speech-detected note. These now paint the bottom status bar through a new StatusBar.showLocal(label, severity), which writes the bar text and error styling directly. - showLocal is deliberately transient with no timer of its own: the next status frame from the server's observer overwrites it. The old 8-second auto-hide timer was one of the pending timers that forced the smoke test to exit explicitly rather than wait them out. - Two severities shifted in the move: no-speech-detected is now plain info, while an unrecognized frame from the voice socket is reported as an error instead of neutral text. - The smoke test now asserts that no .voice-status element exists after the voice plugin mounts, so the removal cannot silently regress. --- crates/promptforge-ws-server/ui/src/main.ts | 18 +++--------- .../ui/src/status-bar.ts | 7 +++++ crates/promptforge-ws-server/ui/src/voice.ts | 29 +++++-------------- crates/promptforge-ws-server/ui/style.css | 19 ------------ .../promptforge-ws-server/ui/test/smoke.mjs | 7 +++-- 5 files changed, 23 insertions(+), 57 deletions(-) diff --git a/crates/promptforge-ws-server/ui/src/main.ts b/crates/promptforge-ws-server/ui/src/main.ts index e75b3352..fd98dd01 100644 --- a/crates/promptforge-ws-server/ui/src/main.ts +++ b/crates/promptforge-ws-server/ui/src/main.ts @@ -39,12 +39,12 @@ function selectedModel(): string { return pickerEl.value; } -// The mic button joins murm-ui's composer through the plugin seam; the -// voice status message sits below the form. +// The mic button joins murm-ui's composer through the plugin seam; voice +// messages paint the status bar directly. let voiceHandle: VoiceHandle | null = null; const voicePlugin: ChatPlugin = { name: "voice", - onInputMount({ container, form, input }) { + onInputMount({ form, input }) { const mic = document.createElement("button"); mic.type = "button"; mic.className = "voice-mic mur-form-icon-btn"; @@ -55,17 +55,7 @@ const voicePlugin: ChatPlugin = { ''; form.insertBefore(mic, form.querySelector(".mur-form-footer-right")); - const formContainer = container.querySelector(".mur-chat-form-container"); - if (!formContainer) { - throw new Error("DOM Error: .mur-chat-form-container not found inside the container."); - } - const status = document.createElement("div"); - status.className = "voice-status"; - status.setAttribute("role", "status"); - status.setAttribute("aria-live", "polite"); - formContainer.appendChild(status); - - voiceHandle = setupVoice({ mic, status, input }, statusBar); + voiceHandle = setupVoice({ mic, input }, statusBar); }, onUserSubmit() { voiceHandle?.discardIfRecording(); diff --git a/crates/promptforge-ws-server/ui/src/status-bar.ts b/crates/promptforge-ws-server/ui/src/status-bar.ts index ab3799c4..7283583e 100644 --- a/crates/promptforge-ws-server/ui/src/status-bar.ts +++ b/crates/promptforge-ws-server/ui/src/status-bar.ts @@ -97,6 +97,13 @@ export class StatusBar { }, this.pulseMs()); } + /** Shows a locally-originated message (e.g. voice capture errors). The next observer frame overwrites it. */ + showLocal(label: string, severity: "info" | "error"): void { + this.text.textContent = label; + this.root.title = ""; + this.text.classList.toggle("status-bar__text--error", severity === "error"); + } + /** Lights or dims the REC badge with the mic's recording state. */ setRecording(on: boolean): void { this.rec.classList.toggle("status-bar__rec--active", on); diff --git a/crates/promptforge-ws-server/ui/src/voice.ts b/crates/promptforge-ws-server/ui/src/voice.ts index 487344bc..b90ee654 100644 --- a/crates/promptforge-ws-server/ui/src/voice.ts +++ b/crates/promptforge-ws-server/ui/src/voice.ts @@ -11,7 +11,6 @@ import type { StatusBar } from "./status-bar"; export interface VoiceElements { mic: HTMLButtonElement; - status: HTMLDivElement; input: HTMLTextAreaElement; } @@ -33,22 +32,11 @@ interface TakeState { } export function setupVoice(elements: VoiceElements, statusBar: StatusBar): VoiceHandle { - const { mic, status, input } = elements; + const { mic, input } = elements; let voice: VoiceSession | null = null; - let voiceStatusTimer = 0; let suppressReplies = false; let take: TakeState | null = null; - function showVoiceStatus(text: string, isError: boolean): void { - status.textContent = text; - status.classList.toggle("voice-status--error", Boolean(isError)); - status.classList.add("voice-status--visible"); - clearTimeout(voiceStatusTimer); - voiceStatusTimer = window.setTimeout(() => { - status.classList.remove("voice-status--visible"); - }, 8000); - } - function setRecording(next: boolean): void { mic.classList.toggle("voice-mic--recording", next); mic.setAttribute("aria-pressed", String(next)); @@ -139,17 +127,16 @@ export function setupVoice(elements: VoiceElements, statusBar: StatusBar): Voice if (text !== "") { finishTake(text); input.focus(); - showVoiceStatus("Transcript ready - edit, then send.", false); } else { finishTake(""); const frames = typeof msg.frames === "number" ? msg.frames : 0; - showVoiceStatus(`No speech detected (${frames} PCM frames captured).`, false); + statusBar.showLocal(`No speech detected (${frames} PCM frames captured).`, "info"); } return true; } // Anything else is shown verbatim and ends the take. finishTake(""); - showVoiceStatus(String(data), false); + statusBar.showLocal(String(data), "error"); return true; } @@ -167,7 +154,7 @@ export function setupVoice(elements: VoiceElements, statusBar: StatusBar): Voice async function startVoice(): Promise { if (!navigator.mediaDevices?.getUserMedia || !window.AudioContext || !window.WebSocket) { - showVoiceStatus("Voice capture is not available in this browser.", true); + statusBar.showLocal("Voice capture is not available in this browser.", "error"); return; } let stream: MediaStream; @@ -185,7 +172,7 @@ export function setupVoice(elements: VoiceElements, statusBar: StatusBar): Voice error instanceof Error && error.name === "NotAllowedError" ? "microphone permission denied" : `microphone unavailable: ${(error as Error).message || error}`; - showVoiceStatus(detail, true); + statusBar.showLocal(detail, "error"); return; } let ws: WebSocket | undefined; @@ -226,7 +213,7 @@ export function setupVoice(elements: VoiceElements, statusBar: StatusBar): Voice statusBar.setRecording(false); if (take) finishTake(""); releaseAudio(session); - showVoiceStatus("The voice connection dropped.", true); + statusBar.showLocal("The voice connection dropped.", "error"); } }); source.connect(node); @@ -238,7 +225,6 @@ export function setupVoice(elements: VoiceElements, statusBar: StatusBar): Voice ws.send("start"); setRecording(true); statusBar.setRecording(true); - showVoiceStatus("Recording - press the mic button again to stop.", false); } catch (error) { for (const track of stream.getTracks()) { track.stop(); @@ -249,7 +235,7 @@ export function setupVoice(elements: VoiceElements, statusBar: StatusBar): Voice if (ctx) { ctx.close().catch(() => {}); } - showVoiceStatus(`Voice capture failed: ${(error as Error).message || error}`, true); + statusBar.showLocal(`Voice capture failed: ${(error as Error).message || error}`, "error"); } } @@ -285,7 +271,6 @@ export function setupVoice(elements: VoiceElements, statusBar: StatusBar): Voice discardTake(); setRecording(false); statusBar.setRecording(false); - showVoiceStatus("Recording discarded.", false); } mic.addEventListener("click", () => { diff --git a/crates/promptforge-ws-server/ui/style.css b/crates/promptforge-ws-server/ui/style.css index 0730d74d..41c962e1 100644 --- a/crates/promptforge-ws-server/ui/style.css +++ b/crates/promptforge-ws-server/ui/style.css @@ -458,25 +458,6 @@ body { 0 0 calc(var(--led-glow-radius, 6px) * 2) color-mix(in oklab, var(--led-amber, #d9a03f) 55%, transparent); } -.voice-status { - width: 100%; - max-width: var(--mur-chat-form-width, 768px); - font-size: 12px; - color: var(--text-muted, #8b90a0); - max-height: 0; - overflow: hidden; - transition: max-height 0.15s ease-out, padding 0.15s ease-out; -} - -.voice-status--visible { - max-height: 40px; - padding: 0 var(--space-md, 8px); -} - -.voice-status--error { - color: var(--danger-text, #cf7f88); -} - .voice-mic { flex: none; } diff --git a/crates/promptforge-ws-server/ui/test/smoke.mjs b/crates/promptforge-ws-server/ui/test/smoke.mjs index bebaa575..a838bc19 100644 --- a/crates/promptforge-ws-server/ui/test/smoke.mjs +++ b/crates/promptforge-ws-server/ui/test/smoke.mjs @@ -239,6 +239,9 @@ if (!history) failures.push(".mur-chat-history missing"); if (!input) failures.push(".mur-chat-input missing"); if (!send) failures.push(".mur-send-btn missing"); if (!mic) failures.push("voice plugin did not insert the mic button"); +if (window.document.querySelector(".voice-status")) { + failures.push("a .voice-status element exists after the voice plugin mounted"); +} if (!statusBar) failures.push("status bar placeholder missing"); if (statusBar && statusBar.tagName !== "FOOTER") { failures.push("the status bar is not a