Skip to content

Latest commit

 

History

History
265 lines (213 loc) · 19 KB

File metadata and controls

265 lines (213 loc) · 19 KB

Roundtable — Multi-Subscription AI Chat for VS Code

Working name "Roundtable" — rename freely. This file is the canonical project brief and rules file. If any other doc conflicts with this one, this one wins.

1. What we're building

A VS Code extension providing a single inline chat panel where multiple AI models — each authenticated via the user's existing consumer subscriptions (Claude Max, ChatGPT Plus/Pro, Gemini, etc.), NOT API keys — participate in one shared-context conversation.

Core insight: the backends are stateless OpenAI-compatible endpoints (via CLIProxyAPI). There is no per-model memory to sync. The extension maintains one canonical transcript, and whenever any model speaks, it receives the entire transcript (with speaker labels) as its message history. Every participant is therefore always "on the same page" by construction.

What this is NOT

  • Not an agent. No tool use, no file editing, no terminal execution in v1. It is a conversation surface. (Agentic features are a possible v3+.)
  • Not a Copilot Chat model provider. We own the whole UI via a webview. Do not use the Language Model Chat Provider API.
  • Not an API-key product. Zero API-key code paths in v1. Subscriptions only, via the CLIProxyAPI sidecar.

2. Architecture overview

┌────────────────────────────────────────────────────┐
│ VS Code                                            │
│  ┌──────────────┐   postMessage   ┌─────────────┐  │
│  │ Webview UI   │◄───────────────►│ Extension   │  │
│  │ (chat panel) │                 │ Host (Node) │  │
│  └──────────────┘                 │  - Orchestr.│  │
│                                   │  - Router   │  │
│                                   │  - Sidecar  │  │
│                                   │    manager  │  │
│                                   └──────┬──────┘  │
└──────────────────────────────────────────┼─────────┘
                                           │ HTTP (OpenAI-compatible, SSE)
                                    ┌──────▼──────┐
                                    │ CLIProxyAPI │  localhost:8317
                                    │  (sidecar)  │
                                    └──────┬──────┘
                          ┌────────────────┼────────────────┐
                          ▼                ▼                ▼
                     Claude OAuth     Codex OAuth      Gemini OAuth
                     (Max sub)        (ChatGPT sub)    (Google acct)
  • Extension host (TypeScript, Node): owns transcript state, provider registry, turn router, streaming, persistence, and the CLIProxyAPI sidecar lifecycle.
  • Webview: pure presentation + input. Dumb by design. All state lives in the extension host; the webview renders what it's told and emits user intents.
  • CLIProxyAPI: open-source Go binary (github.com/router-for-me/CLIProxyAPI). Wraps subscription OAuth logins and exposes POST /v1/chat/completions (OpenAI-compatible, streaming supported) on localhost. The extension spawns and supervises it.

3. Repository layout

roundtable/
├── CLAUDE.md                  # this file (canonical rules)
├── package.json               # extension manifest
├── tsconfig.json
├── esbuild.js                 # bundler script (esbuild, not webpack)
├── src/
│   ├── extension.ts           # activate/deactivate, command registration
│   ├── orchestrator.ts        # transcript state machine, turn execution
│   ├── router.ts              # decides which participant(s) respond
│   ├── providers.ts           # provider/model registry, endpoint mapping
│   ├── sidecar.ts             # CLIProxyAPI download/spawn/health/kill
│   ├── streaming.ts           # SSE client for /v1/chat/completions
│   ├── contextInjector.ts     # active-file / selection context blocks
│   ├── persistence.ts         # transcript load/save
│   └── panel.ts               # webview panel creation, message bridge
├── media/                     # webview assets
│   ├── main.js                # webview-side logic (vanilla JS or Preact)
│   ├── main.css
│   └── index.html             # template (CSP-compliant)
├── test/
│   └── *.test.ts              # vitest unit tests (router, orchestrator, schema)
└── .vscode/
    └── launch.json            # F5 Extension Development Host config

4. Extension manifest essentials (package.json)

  • engines.vscode: ^1.90.0 or later.
  • main: ./dist/extension.js (esbuild bundle).
  • activationEvents: activate on the view or the open command only — no * activation.
  • Contributes:
    • viewsContainers.activitybar: one container, id roundtable, with an icon.
    • views: one webview view roundtable.chat inside that container. (Also register a command to open it as an editor-area panel for wide-screen use — same webview code, two mounts.)
    • commands:
      • roundtable.open — open/focus the chat panel
      • roundtable.newThread — archive current transcript, start fresh
      • roundtable.addSelectionToChat — push editor selection into the composer as a context block
      • roundtable.sidecar.restart, roundtable.sidecar.login — sidecar management
    • menus.editor/context: expose addSelectionToChat.
    • configuration (settings):
      • roundtable.sidecar.mode: "managed" | "external" (default managed)
      • roundtable.sidecar.port: number (default 8317)
      • roundtable.sidecar.binaryPath: string (optional override; if empty in managed mode, download from GitHub releases)
      • roundtable.sidecar.externalUrl: string (used when mode = external)
      • roundtable.participants: array of participant configs (see §6)
      • roundtable.router.defaultMode: "manual" | "roundRobin" (default manual)
      • roundtable.context.autoIncludeActiveFile: boolean (default false)

5. Transcript schema (canonical data model)

Stored as JSON. One file per thread.

{
  "version": 1,
  "id": "uuid",
  "title": "auto-generated or user-set",
  "createdAt": "ISO-8601",
  "updatedAt": "ISO-8601",
  "participants": ["claude", "gpt"],        // participant ids active in this thread
  "messages": [
    {
      "id": "uuid",
      "seq": 0,                              // monotonic, gap-free
      "role": "user" | "assistant" | "system",
      "speaker": "user" | "<participantId>", // who actually said it
      "content": "string (markdown)",
      "contextBlocks": [                     // optional, user messages only
        { "kind": "file" | "selection", "path": "src/foo.ts", "range": [10, 42], "content": "..." }
      ],
      "targets": ["claude"],                 // which participants this message was addressed to (user msgs)
      "status": "complete" | "streaming" | "error" | "cancelled",
      "error": "string?",                    // populated when status = error
      "usage": { "inputTokens": 0, "outputTokens": 0 },  // if the endpoint reports it
      "createdAt": "ISO-8601"
    }
  ]
}

Invariants (must always hold — write tests for these):

  1. seq is strictly increasing with no gaps among persisted messages.
  2. Every assistant message has a speaker matching a known participant id.
  3. A message with status: "streaming" is never persisted to disk; persist only on completion, error, or cancellation (partial content saved with the terminal status).
  4. The transcript is the single source of truth. The webview never mutates it directly — it sends intents; the orchestrator mutates and broadcasts the new state.

6. Provider & participant registry

Two layers, deliberately separated:

  • Provider: an endpoint + protocol. In v1 there is effectively one provider: the CLIProxyAPI base URL (http://127.0.0.1:<port>/v1), speaking OpenAI Chat Completions with SSE streaming. Keep the abstraction thin but present so a direct-CLI provider (headless claude -p / codex exec) can be added later without touching the orchestrator.
  • Participant: a named seat at the table. Config shape:
{
  "id": "claude",                 // stable, used in @-mentions and transcript
  "displayName": "Claude",
  "color": "#D97706",             // avatar/label accent in UI
  "model": "claude-opus-4-8",     // model string passed to the endpoint
  "systemPrompt": "optional per-participant persona/instructions",
  "enabled": true
}

On startup and on demand, query GET /v1/models from the sidecar to surface which model strings are actually available, and validate participant configs against it (warn, don't block — model lists shift).

7. Prompt assembly (the shared-context mechanism)

When participant P is chosen to respond, build the request messages array as follows:

  1. System message (always first), composed of:
    • A framing preamble, something like: "You are {displayName}, one of several AI assistants in a shared group conversation with a human user. Other participants: {list}. Messages are labeled with their speaker. Respond as yourself; do not impersonate other participants; do not prefix your reply with your own name."
    • The participant's own systemPrompt, if set.
  2. The full transcript, mapped:
    • User messages → role: "user", content prefixed [User] plus rendered context blocks (fenced code with path/range header).
    • P's own past messages → role: "assistant", content unprefixed.
    • Other participants' messages → role: "user" with content prefixed [{displayName}]:. This is deliberate: OpenAI-compatible endpoints only model a two-party conversation, so everyone else's speech is presented as incoming context. Do not send other models' output as assistant role — the model would treat it as its own prior speech.
  3. System messages in the transcript (thread-level notes) → role: "system" appended after the main system message, or inlined as [System]-prefixed user content if the endpoint rejects multiple system messages. Handle both.

Context budget: naive full-replay is fine for v1. Add a character-count guard: if the assembled prompt exceeds a configurable soft limit (default ~150k chars), drop oldest messages first but ALWAYS keep the system message and the N most recent messages, and inject one [System] Earlier messages omitted for length. marker. No summarization in v1 — omission markers only, so behavior stays predictable.

8. Router (turn-taking)

router.ts exposes one pure function: given (transcript, user message, settings) → ordered list of participant ids to respond. Pure = trivially testable.

Rules, in priority order:

  1. @-mentions: @claude, @gpt etc. in the user message → those participants respond, in mention order. Multiple mentions = sequential responses (each later responder sees the earlier one's reply, because prompt assembly always uses the live transcript).
  2. @all → every enabled participant responds, sequentially, in participant-config order.
  3. No mention, manual mode (default): the participant currently selected in the UI's "speaking to" selector responds. The selector defaults to the last-addressed participant.
  4. No mention, roundRobin mode: next enabled participant after the previous assistant speaker.

Sequential only in v1 — no parallel responses. It keeps the shared-context invariant simple (each response is appended before the next request is assembled) and avoids quota spikes.

Cancellation: a visible Stop button aborts the in-flight SSE request. Partial content is persisted with status: "cancelled" and rendered with a "(stopped)" marker. If a multi-participant turn (e.g. @all) is stopped, remaining queued responders are skipped.

9. Streaming

  • Use fetch with ReadableStream parsing of SSE (data: {...} lines, [DONE] terminator) against POST /v1/chat/completions with "stream": true.
  • Forward deltas to the webview via postMessage batched on a ~50ms interval (don't post per-token).
  • Timeouts: 30s to first byte, 120s stall timeout between deltas → mark error with a human-readable message.
  • On HTTP 429 or 5xx from the sidecar: surface the provider's error body in the message bubble (these often contain quota-reset info). One automatic retry on 5xx only, never on 429.

10. Sidecar management (sidecar.ts)

Two modes:

  • external (simplest, build first): user runs CLIProxyAPI themselves; extension just health-checks GET {externalUrl}/v1/models and shows a status pill (green/red) in the panel header with the failure reason on hover.
  • managed: extension owns the lifecycle:
    1. On activation, check for binary at binaryPath or in globalStorageUri. If absent, download the correct platform asset from the CLIProxyAPI GitHub releases page (pin a known-good version in a constant; verify checksum), with an explicit user-consent prompt before downloading.
    2. Spawn with a generated config.yaml in globalStorageUri (port from settings, auth-dir under global storage, remote management disabled, no API-key auth for localhost).
    3. Health-check loop; restart with backoff (max 3 attempts) on crash; kill on deactivate.
    4. OAuth logins are interactive — do not try to automate them. roundtable.sidecar.login opens a VS Code terminal running the binary with the chosen --*-login flag and instructs the user to complete the browser flow. Afterwards, re-query /v1/models and refresh the participant validation.

Never log, transmit, or read the contents of the OAuth token files. The extension's only interface to auth state is /v1/models and request success/failure.

11. Webview ⇄ extension protocol

All messages are { type: string, payload: object }. Exhaustive v1 set:

Webview → extension (intents):

  • ready — webview mounted; extension replies with full state
  • sendMessage { text, targets?: string[] }
  • stopGeneration
  • newThread
  • switchThread { threadId }
  • setDefaultTarget { participantId }
  • retryMessage { messageId } — re-runs a failed/cancelled assistant turn
  • deleteMessage { messageId } — removes a message (and renumbers nothing; seq gaps are forbidden, so implement as tombstone: content cleared, status: "deleted", excluded from prompt assembly)
  • openSettings, requestLogin { provider }

Extension → webview (state):

  • state { transcript, participants, sidecarStatus, threads: [{id,title,updatedAt}] } — full sync (on ready, thread switch, and any structural change)
  • delta { messageId, contentDelta } — streaming append
  • messageStatus { messageId, status, error? }
  • sidecarStatus { ok: boolean, detail: string }

Rule: the webview holds a render-model only. Any doubt about state → request/receive full state. Correctness over cleverness.

12. Webview UI (v1 scope)

  • Header: thread title, thread switcher dropdown, sidecar status pill, "speaking to" participant selector, New Thread button.
  • Message list: speaker label + accent color per participant, markdown rendering (use markdown-it bundled into the webview; sanitize HTML), fenced code blocks with copy button, collapsed-by-default context blocks on user messages.
  • Composer: multiline textarea, Enter to send / Shift+Enter newline, @ triggers a mention autocomplete of enabled participants, Stop button while streaming, small token/length hint of the assembled-prompt size for the current target.
  • Must respect VS Code theming (use --vscode-* CSS variables throughout; no hardcoded colors except participant accents).
  • Strict CSP: no remote scripts, no inline event handlers, nonce on the script tag.

13. Persistence

  • Threads saved as individual JSON files under context.globalStorageUri/threads/ (cross-workspace by default; a workspace-scoped mode can come later).
  • Write-behind: debounce saves 500ms after last mutation; also save on deactivate.
  • On load, validate against the schema (version field present, invariants hold); if a file is corrupt, rename it *.corrupt-{ts}.json and continue — never crash activation over one bad thread.

14. Non-negotiable rules

  1. Subscriptions only. No code path may ask for, store, or transmit an API key in v1.
  2. ToS honesty in UX. First-run notice (one-time, dismissible): using subscription accounts outside official clients may violate provider Terms of Service and can lead to rate limiting or account suspension; the user proceeds at their own discretion. Do not bury this.
  3. The transcript is law. All prompt assembly derives from it; no hidden side-channel context between participants.
  4. Sequential turns only in v1.
  5. Fail loud, degrade soft. Provider errors render in-thread with real error text; sidecar death shows a red status pill with a restart action; nothing silently no-ops.
  6. No telemetry. Zero network calls except to the sidecar and (managed mode, with consent) the pinned GitHub release download.
  7. Pure router, tested invariants. router.ts and prompt assembly must be pure functions with unit tests covering: mention parsing (including @all, unknown mentions, mid-word @), role-mapping of other participants' messages, seq invariants, tombstone exclusion, and context-budget truncation.
  8. Fix the class, not the instance. Any bug fix must generalize; add a test reproducing the failure before fixing it.

15. Milestones

M1 — Skeleton (prove the loop): external-mode sidecar only. Panel opens, one hardcoded participant, send → full-transcript prompt assembly → streamed reply renders. Persistence of a single thread. M2 — The table: participant registry from settings, @-mention router, @all, sequential multi-responder turns, speaker-labeled role mapping, manual/roundRobin modes, stop/retry/tombstone. M3 — Sidecar managed mode: download-with-consent, spawn/supervise, login command flow, status pill, /v1/models validation. M4 — Polish: multi-thread management, selection/file context injection + editor context menu, context-budget truncation, prompt-size hint, mention autocomplete, first-run ToS notice, theming pass.

Each milestone must build (npm run compile), pass tests (npm test), and run in the F5 Extension Development Host before starting the next.

16. Tooling & conventions

  • TypeScript strict mode. esbuild for bundling (fast, standard for extensions). vitest for unit tests. ESLint with the standard @typescript-eslint config.
  • No frameworks in the webview unless it earns its weight; vanilla + small helpers preferred, Preact acceptable if state complexity demands it.
  • Package for sideload with npx vsce package → install via code --install-extension roundtable-x.y.z.vsix. Marketplace publishing is out of scope.
  • Version bumps: update package.json version on every packaged build.