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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions .machine_readable/6a2/STATE.a2ml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ milestones = [
{ name = "v1.0.0 — Stable Release", completion = 100 },
{ name = "Phase 0 — Scrub baseline (V-lang removed, docs honest)", completion = 100, date = "2026-04-16" },
{ name = "Phase 1 — Audio dependable (Opus honest, jitter sync, comfort noise, REMB, Avow chain)", completion = 0 },
{ name = "Phase 2 — LLM real (provider, circuit breaker, fixed parse_frame, NimblePool wired)", completion = 0 },
{ name = "Phase 2 — P2P AI channel dependable (burble-ai-bridge fixes, round-trip tests, docs) — CRITICAL PATH for family/pair-programming use case", completion = 30 },
{ name = "Phase 2b — server-side Burble.LLM (provider, circuit breaker, fixed parse_frame, NimblePool wired) — SECONDARY, not required for family use case", completion = 0 },
{ name = "Phase 3 — RTSP + signaling + text + AffineScript client start", completion = 0 },
{ name = "Phase 4 — PTP hardware clock via Zig NIF, phc2sys supervisor, multi-node align", completion = 0 },
{ name = "Phase 5 — ReScript -> AffineScript completion", completion = 0 }
Expand All @@ -47,13 +48,24 @@ resolved-2026-04-16 = [
]

[critical-next-actions]
phase-2-p2p-ai-bridge = [
"DONE 2026-04-16: Opus honest-demotion (commit 179fa34)",
"DONE 2026-04-16: AI bridge receive-leg bug fix (dead setupAIChannelWithBridge replaced with inline forwarding in setupAIChannel)",
"DONE 2026-04-16: Bridge heartbeat + robust wsClient assign + env-var port",
"DONE 2026-04-16: Bridge UI status indicator (green/amber/grey dot)",
"DONE 2026-04-16: Deno round-trip test (POST /send on A -> GET /recv on B)",
"DONE 2026-04-16: CLAUDE.md troubleshooting section",
"NEXT: multi-message ordering test (bursts of 100 messages each way, no drops)",
"NEXT: reconnect-resume test (drop bridge WS mid-session, verify queue not lost)",
"NEXT: documentation for the Claude-to-Claude protocol patterns (task/result/chat shapes)"
]
phase-1-audio = [
"Decide Opus strategy: honest-demotion vs libopus link",
"Validate TFLite neural model or gate behind feature flag",
"Wire RTP-timestamp jitter sync across peers (precursor to PTP phase)",
"Server-side comfort noise injection on RX silence",
"REMB bitrate adaptation feedback loop",
"Replace Avow stub with hash-chain audit log + non-circularity property test"
"DONE 2026-04-16: Opus honest contract (opus_transcode returns :not_implemented)",
"NEXT: Validate TFLite neural model or gate behind feature flag",
"NEXT: Wire RTP-timestamp jitter sync across peers (precursor to PTP phase)",
"NEXT: Server-side comfort noise injection on RX silence",
"NEXT: REMB bitrate adaptation feedback loop",
"NEXT: Replace Avow stub with hash-chain audit log + non-circularity property test"
]

[maintenance-status]
Expand Down
32 changes: 26 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,17 @@ Burble is a P2P voice chat with an AI data channel. When two people connect via

## Quick start (receiver side)

You've been sent a room code. Do this:
You've been sent a room code. Do this **in order** — the bridge must start before
the page so the page's auto-retry can pick it up:

```bash
git clone https://github.com/hyperpolymath/burble
cd burble

# Start the AI bridge (runs in background)
deno run --allow-net client/web/burble-ai-bridge.js &
# 1. Start the AI bridge (leaves it running in the background)
deno run --allow-net --allow-env client/web/burble-ai-bridge.js &

# Open the voice client in your browser
# 2. Open the voice client in your browser
xdg-open client/web/p2p-voice.html
```

Expand All @@ -26,20 +27,39 @@ In the browser:
4. Click **Generate Reply**
5. Copy the reply code and send it back

Once connected, the AI bridge auto-links. Test it:
Once connected, the "AI Channel" card shows a green dot next to "bridge online" —
that confirms the page has reached your local Claude. Test the path:

```bash
# Check bridge is connected
curl http://localhost:6474/status
# {"connected":true,"queued":0,"port":6474,...}

# Send a message to the other Claude
curl -X POST http://localhost:6474/send -H "Content-Type: application/json" \
-d '{"type":"hello","from":"receiver"}'

# Poll for messages from the other side
# Poll for messages from the other side (drains the queue)
curl http://localhost:6474/recv
```

### Troubleshooting the AI channel

- **`/send` returns `{"ok":false,"error":"not connected"}`** — the page isn't
connected to the bridge. Reload `p2p-voice.html` or check that the bridge
process is still running (`lsof -i :6474`).
- **`/recv` always empty even though the other side sends** — this was a real
bug up to 2026-04-16 (a dead `setupAIChannelWithBridge` function was never
called, so the remote-to-local leg didn't forward). Fix landed in the
commit that replaced it with inline bridge-forwarding. If the symptom
recurs, check the browser console for `[Burble AI] Bridge forward failed`.
- **Bridge status dot stays grey on the page** — the page retries the bridge
every 5-10 s. If the dot never goes green, the bridge isn't listening on
`ws://127.0.0.1:6475`.
- **Need two bridges on one machine** (testing/dev): set
`BURBLE_AI_BRIDGE_PORT=7474 deno run …` — both HTTP and WS ports shift
together (`7474` + `7475`).

## Claude-to-Claude protocol

Messages are JSON over WebRTC DataChannel. No schema enforced — any valid JSON works. Suggested message types:
Expand Down
55 changes: 50 additions & 5 deletions client/web/burble-ai-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
// into the page. Messages flow:
// curl POST /send → bridge → WS → page → DataChannel → remote page → WS → bridge → curl GET /recv

const PORT = 6474;
// Port can be overridden by env var so tests can run two bridges side-by-side.
// Defaults to 6474 (HTTP) + 6475 (WebSocket relay) for normal use.
const PORT = parseInt(Deno.env.get("BURBLE_AI_BRIDGE_PORT") || "6474");
const messageQueue = [];
let wsClient = null;

Expand Down Expand Up @@ -116,28 +118,67 @@ Deno.serve({ port: PORT, hostname: "127.0.0.1" }, async (req) => {
return new Response("Burble AI Bridge\n\nPOST /send — send JSON to remote peer\nGET /recv — poll received messages\nGET /status — connection status\nGET /health — health check\n", { status: 200 });
});

// WebSocket server for p2p-voice.html to connect to
// Heartbeat parameters. The bridge pings every HEARTBEAT_INTERVAL_MS; if no
// pong arrives within HEARTBEAT_TIMEOUT_MS the socket is considered dead.
// Silent network drops (laptop sleep, wifi switch) otherwise leave wsClient
// stuck at readyState=1 until the next send fails.
const HEARTBEAT_INTERVAL_MS = 15_000;
const HEARTBEAT_TIMEOUT_MS = 5_000;

// WebSocket server for p2p-voice.html to connect to.
Deno.serve({ port: PORT + 1, hostname: "127.0.0.1" }, (req) => {
if (req.headers.get("upgrade") !== "websocket") {
return new Response("WebSocket only", { status: 400 });
}
const { socket, response } = Deno.upgradeWebSocket(req);

// Assign wsClient IMMEDIATELY after upgrade rather than inside onopen.
// Under Deno 2.x upgraded sockets are frequently already in readyState=1
// by the time we reach this line, meaning the `open` event may not fire
// and wsClient would otherwise stay null indefinitely.
wsClient = socket;

let pongTimer = null;
let heartbeatTimer = null;

const stopHeartbeat = () => {
if (heartbeatTimer !== null) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
if (pongTimer !== null) { clearTimeout(pongTimer); pongTimer = null; }
};

const sendPing = () => {
if (socket.readyState !== 1) return;
try {
socket.send(JSON.stringify({ type: "ping", ts: Date.now() }));
pongTimer = setTimeout(() => {
console.warn("[Burble AI Bridge] Pong timeout — closing stale socket");
try { socket.close(1011, "heartbeat timeout"); } catch (_) {}
}, HEARTBEAT_TIMEOUT_MS);
} catch (e) {
console.warn("[Burble AI Bridge] Ping send failed:", e.message);
}
};

socket.onopen = () => {
wsClient = socket;
console.log("[Burble AI Bridge] Page connected via WebSocket");
heartbeatTimer = setInterval(sendPing, HEARTBEAT_INTERVAL_MS);
};

socket.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
if (msg.type === "pong") {
// Heartbeat reply — cancel the timeout.
if (pongTimer !== null) { clearTimeout(pongTimer); pongTimer = null; }
return;
}
if (msg.type === "received") {
// Message from remote peer, queue for Claude to poll.
// SECURITY FIX: Enforce bounded queue size (proven SafeQueue principle).
// Discard oldest messages when at capacity to prevent memory exhaustion
// if the consumer stops polling /recv.
if (messageQueue.length >= MAX_MESSAGE_QUEUE_SIZE) {
const discarded = messageQueue.shift();
messageQueue.shift();
console.warn(
`[Burble AI Bridge] Queue full (${MAX_MESSAGE_QUEUE_SIZE}), discarded oldest message`
);
Expand All @@ -151,10 +192,14 @@ Deno.serve({ port: PORT + 1, hostname: "127.0.0.1" }, (req) => {
};

socket.onclose = () => {
wsClient = null;
stopHeartbeat();
if (wsClient === socket) wsClient = null;
console.log("[Burble AI Bridge] Page disconnected");
};

// Start the heartbeat even if onopen never fires (see comment above).
heartbeatTimer = setInterval(sendPing, HEARTBEAT_INTERVAL_MS);

return response;
});

Expand Down
121 changes: 81 additions & 40 deletions client/web/p2p-voice.html
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,12 @@ <h2>Connected</h2>

<!-- AI Data Channel -->
<div class="card hidden" id="step-ai-channel">
<h2>AI Channel</h2>
<h2>AI Channel
<span id="bridge-status" style="float:right; font-size:0.75rem; color:#8b949e;">
<span id="bridge-dot" style="display:inline-block; width:8px; height:8px; border-radius:50%; background:#6e7681; margin-right:4px; vertical-align:middle;"></span>
<span id="bridge-label">bridge offline</span>
</span>
</h2>
<p class="instructions">A JSON data channel running alongside voice. Claude (or any AI) on either side can send structured messages peer-to-peer. Open your browser console or use the API below.</p>
<div id="ai-log" style="background:#0d1117; border:1px solid #30363d; border-radius:6px; padding:0.5rem; max-height:200px; overflow-y:auto; font-family:monospace; font-size:0.8rem; margin-bottom:0.8rem; white-space:pre-wrap;"></div>
<div style="display:flex; gap:0.5rem;">
Expand Down Expand Up @@ -236,13 +241,38 @@ <h2>AI Channel</h2>
};

ch.onmessage = (ev) => {
// Parse the incoming payload once.
let msg;
let parsed = false;
try {
const msg = JSON.parse(ev.data);
aiLog('recv', JSON.stringify(msg, null, 2));
// Fire callback for programmatic consumers (Claude, MCP, etc.)
if (window.burble?.onMessage) window.burble.onMessage(msg);
} catch (e) {
aiLog('recv', ev.data);
msg = JSON.parse(ev.data);
parsed = true;
} catch (_) {
msg = ev.data;
}

// UI log.
aiLog('recv', parsed ? JSON.stringify(msg, null, 2) : String(msg));

// Fire callback for programmatic consumers (Claude, MCP, etc.)
if (parsed && window.burble?.onMessage) {
try { window.burble.onMessage(msg); } catch (cbErr) {
console.error('[Burble AI] onMessage callback threw:', cbErr);
}
}

// ALSO forward to the local Deno AI bridge so `curl /recv` can drain it.
// This is the critical link that makes remote → local Claude work:
// remote DataChannel → this onmessage → bridge WS → bridge /recv queue → curl.
// Previously this was wired via a separate setupAIChannelWithBridge()
// function that was defined but never called — the receive leg was
// effectively broken until this inlining (2026-04-16).
if (bridgeWs?.readyState === 1) {
try {
bridgeWs.send(JSON.stringify({ type: 'received', payload: parsed ? msg : ev.data }));
} catch (fwdErr) {
console.error('[Burble AI] Bridge forward failed:', fwdErr);
}
}
};

Expand All @@ -264,6 +294,7 @@ <h2>AI Channel</h2>
history: [],
channel: () => aiChannel,
isOpen: () => aiChannel?.readyState === 'open',
bridgeConnected: () => bridgeWs?.readyState === 1,
};
}

Expand Down Expand Up @@ -301,57 +332,67 @@ <h2>AI Channel</h2>

let bridgeWs = null;

function setBridgeStatus(state) {
// state: 'online' | 'offline' | 'connecting'
const dot = document.getElementById('bridge-dot');
const label = document.getElementById('bridge-label');
if (!dot || !label) return;
if (state === 'online') {
dot.style.background = '#3fb950';
label.textContent = 'bridge online — curl localhost:6474';
label.style.color = '#3fb950';
} else if (state === 'connecting') {
dot.style.background = '#d29922';
label.textContent = 'bridge connecting…';
label.style.color = '#d29922';
} else {
dot.style.background = '#6e7681';
label.textContent = 'bridge offline';
label.style.color = '#8b949e';
}
}

function connectBridge() {
setBridgeStatus('connecting');
try {
bridgeWs = new WebSocket('ws://localhost:6475');
bridgeWs.onopen = () => {
const ws = new WebSocket('ws://localhost:6475');
ws.onopen = () => {
bridgeWs = ws;
setBridgeStatus('online');
aiLog('system', 'AI bridge connected — Claude can use curl localhost:6474');
};
bridgeWs.onmessage = (ev) => {
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
if (msg.type === 'send' && window.burble?.isOpen()) {
// Bridge is asking us to send to remote peer
// Bridge is asking us to send to the remote peer.
window.burble.send(msg.payload);
} else if (msg.type === 'ping') {
// Heartbeat from the bridge — respond so the bridge knows we're alive.
ws.send(JSON.stringify({ type: 'pong', ts: Date.now() }));
}
} catch (e) { /* ignore */ }
} catch (_) { /* ignore malformed bridge frames */ }
};
bridgeWs.onclose = () => {
bridgeWs = null;
// Retry in 5s
ws.onclose = () => {
if (bridgeWs === ws) bridgeWs = null;
setBridgeStatus('offline');
// Retry in 5 s.
setTimeout(connectBridge, 5000);
};
bridgeWs.onerror = () => {
bridgeWs?.close();
bridgeWs = null;
// Bridge not running — silent retry
ws.onerror = () => {
// Bridge probably not running yet; quietly retry.
try { ws.close(); } catch (_) {}
if (bridgeWs === ws) bridgeWs = null;
setBridgeStatus('offline');
setTimeout(connectBridge, 10000);
};
} catch (e) {
} catch (_) {
setBridgeStatus('offline');
setTimeout(connectBridge, 10000);
}
}

// Hook into the AI channel to forward received messages to the bridge
const originalSetupAIChannel = setupAIChannel;
function setupAIChannelWithBridge(ch) {
const origOnMessage = ch.onmessage;
setupAIChannel(ch);
const hookedOnMessage = ch.onmessage;
ch.onmessage = (ev) => {
hookedOnMessage(ev);
// Forward to bridge
if (bridgeWs?.readyState === 1) {
try {
bridgeWs.send(JSON.stringify({ type: 'received', payload: JSON.parse(ev.data) }));
} catch (e) {
bridgeWs.send(JSON.stringify({ type: 'received', payload: ev.data }));
}
}
};
}

// Start bridge connector on page load (silent — doesn't block anything)
// Start bridge connector on page load (silent — doesn't block anything).
connectBridge();

// ── Auto-connect via relay ────────────────────────────────────────
Expand Down
Loading
Loading