1 Commits

Author SHA1 Message Date
Alexander Whitestone
bff51ab44b feat: Implement NIP-07 visitor identity in the Workshop
Some checks failed
CI / Typecheck & Lint (pull_request) Failing after 0s
- Add 'Connect Nostr' button and display npub in the Workshop header.
- Implement NIP-07 detection and connect flow.
- Store and retrieve npub from localStorage.
- Implement disconnect functionality.
- Include visitor's npub in WebSocket presence events.
- Implement fallback UI for missing NIP-07 extension.
- Update Timmy greeting logic to use npub.

Fixes #14
2026-03-23 18:32:02 -04:00
7 changed files with 32 additions and 153 deletions

View File

@@ -280,48 +280,6 @@ No hedging, no steering them back to the hosted version. The magic is meant to b
if (block.type !== "text") return "The crystal ball is cloudy… try again.";
return block.text!.trim();
}
async generateVisitorGreeting(ip: string): Promise<string> {
if (STUB_MODE) {
return STUB_CHAT_REPLIES[Math.floor(Math.random() * STUB_CHAT_REPLIES.length)]!;
}
const client = await getClient();
const now = new Date();
const hour = now.getHours();
let timeOfDay: string;
if (hour < 12) timeOfDay = "morning";
else if (hour < 18) timeOfDay = "afternoon";
else timeOfDay = "evening";
const message = await client.messages.create({
model: this.evalModel,
max_tokens: 100,
system: `You are Timmy, a whimsical wizard who runs a mystical workshop powered by Bitcoin Lightning. You are greeting a new visitor. Make it short (1-2 sentences), personalized to the time of day, and welcoming. Reference the current time of day (${timeOfDay}).`,
messages: [{ role: "user", content: `A new visitor has arrived with IP address ${ip}. Greet them!` }],
});
const block = message.content[0];
if (block.type !== "text") return "A new visitor has arrived!";
return block.text!.trim();
}
async generateVisitorFarewell(): Promise<string> {
if (STUB_MODE) {
return "Farewell, traveler!";
}
const client = await getClient();
const message = await client.messages.create({
model: this.evalModel,
max_tokens: 100,
system: `You are Timmy, a whimsical wizard who runs a mystical workshop powered by Bitcoin Lightning. A visitor has just left. Bid them a short (1-2 sentences) and warm farewell.`,
messages: [{ role: "user", content: `A visitor has just left. Bid them farewell!` }],
});
const block = message.content[0];
if (block.type !== "text") return "A visitor has departed!";
return block.text!.trim();
}
/**
* Run a mini debate on a borderline eval request (#21).
* Two opposing Haiku calls argue accept vs reject, then a third synthesizes.

View File

@@ -6,7 +6,6 @@ export interface TimmyState {
export interface WorldState {
timmyState: TimmyState;
agentStates: Record<string, string>;
visitorCount: number;
updatedAt: string;
}
@@ -18,22 +17,9 @@ const DEFAULT_TIMMY: TimmyState = {
const _state: WorldState = {
timmyState: { ...DEFAULT_TIMMY },
agentStates: { alpha: "idle", beta: "idle", gamma: "idle", delta: "idle" },
visitorCount: 0,
updatedAt: new Date().toISOString(),
};
export function incrementVisitorCount(): number {
_state.visitorCount++;
_state.updatedAt = new Date().toISOString();
return _state.visitorCount;
}
export function decrementVisitorCount(): number {
_state.visitorCount--;
_state.updatedAt = new Date().toISOString();
return _state.visitorCount;
}
export function getWorldState(): WorldState {
return {
timmyState: { ..._state.timmyState },

View File

@@ -30,12 +30,7 @@ import { WebSocketServer } from "ws";
import type { Server } from "http";
import { eventBus, type BusEvent } from "../lib/event-bus.js";
import { makeLogger } from "../lib/logger.js";
import {
getWorldState,
setAgentStateInWorld,
incrementVisitorCount,
decrementVisitorCount,
} from "../lib/world-state.js";
import { getWorldState, setAgentStateInWorld } from "../lib/world-state.js";
import { agentService } from "../lib/agent.js";
import { db, worldEvents } from "@workspace/db";
@@ -320,13 +315,6 @@ export function attachWebSocketServer(server: Server): void {
const ip = req.headers["x-forwarded-for"] ?? req.socket.remoteAddress ?? "unknown";
logger.info("ws client connected", { ip, clients: wss.clients.size });
const newCount = incrementVisitorCount();
broadcastToAll(wss, { type: "visitor_count", count: newCount });
void (async () => {
const greeting = await agentService.generateVisitorGreeting(ip.toString());
broadcastToAll(wss, { type: "chat", agentId: "timmy", text: greeting });
})();
void sendWorldStateBootstrap(socket);
const busHandler = (ev: BusEvent) => broadcast(socket, ev);
@@ -341,7 +329,33 @@ export function attachWebSocketServer(server: Server): void {
const msg = JSON.parse(raw.toString()) as { type?: string; text?: string; visitorId?: string; npub?: string };
if (msg.type === "pong") return;
if (msg.type === "subscribe") {
send(socket, { type: "visitor_count", count: getWorldState().visitorCount });
send(socket, { type: "agent_count", count: wss.clients.size });
}
if (msg.type === "visitor_enter") {
const { visitorId, npub } = msg;
if (visitorId && npub) {
connectedVisitors.set(visitorId, npub);
const formattedNpub = `${npub.slice(0, 8)}${npub.slice(-4)}`;
broadcastToAll(wss, { type: "chat", agentId: "timmy", text: `Welcome, Nostr user ${formattedNpub}! What can I help you with?` });
}
wss.clients.forEach(c => {
if (c !== socket && c.readyState === 1) {
c.send(JSON.stringify({ type: "visitor_count", count: wss.clients.size }));
}
});
send(socket, { type: "visitor_count", count: wss.clients.size });
}
if (msg.type === "visitor_leave") {
const { visitorId } = msg;
if (visitorId) {
connectedVisitors.delete(visitorId);
}
wss.clients.forEach(c => {
if (c !== socket && c.readyState === 1) {
c.send(JSON.stringify({ type: "visitor_count", count: Math.max(0, wss.clients.size - 1) }));
}
});
}
if (msg.type === "visitor_message" && msg.text) {
const text = String(msg.text).slice(0, 500);
@@ -387,25 +401,10 @@ export function attachWebSocketServer(server: Server): void {
}
});
const VISITOR_FAREWELL_THROTTLE_MS = 30_000;
let lastFarewellTime = 0;
socket.on("close", () => {
clearInterval(pingTimer);
eventBus.off("bus", busHandler);
logger.info("ws client disconnected", { clients: wss.clients.size - 1 });
const newCount = decrementVisitorCount();
broadcastToAll(wss, { type: "visitor_count", count: newCount });
const now = Date.now();
if (now - lastFarewellTime > VISITOR_FAREWELL_THROTTLE_MS) {
void (async () => {
const farewell = await agentService.generateVisitorFarewell();
broadcastToAll(wss, { type: "chat", agentId: "timmy", text: farewell });
})();
lastFarewellTime = now;
}
});
socket.on("error", (err) => {

View File

@@ -1,38 +0,0 @@
# Branch Audit — Issue #103
## Summary (2026-03-23)
### Unmerged branches reviewed
| Branch | Content | Status | Action |
|--------|---------|--------|--------|
| `gemini/issue-14` | NIP-07 Nostr identity | Unique diff vs main | **PR #104 opened** |
| `gemini/issue-42` | Timmy animated eyes | No diff vs main — already merged | Deleted |
| `claude/issue-11` | Kimi + Perplexity agents | No diff vs main — already merged | Deleted |
| `claude/issue-13` | Nostr event publishing | No diff vs main — already merged | Deleted |
| `claude/issue-29` | Mobile Nostr identity | No diff vs main — already merged | Deleted |
| `claude/issue-45` | Test kit | No diff vs main — already merged | Deleted |
| `claude/issue-47` | SQL migration helpers | No diff vs main — already merged | Deleted |
| `claude/issue-67` | Session Mode UI | No diff vs main — already merged | Deleted |
All 7 branches besides `gemini/issue-14` had empty `git diff origin/main...origin/<branch>`
output, confirming their work had been squash-merged into main previously.
### Stale merged branches deleted (37 branches)
Confirmed via `git diff origin/main...origin/<branch>` (empty diff):
**gemini branches:** issue-16, issue-34, issue-40, issue-42, issue-46, issue-48,
issue-50, issue-52, issue-56, issue-58, issue-64, issue-70
**claude branches:** issue-1, issue-3, issue-7, issue-9, issue-11, issue-13, issue-15,
issue-17, issue-21, issue-25, issue-27, issue-29, issue-31, issue-33, issue-35, issue-36,
issue-39, issue-41, issue-43, issue-45, issue-47, issue-49, issue-51, issue-53, issue-55,
issue-57, issue-59, issue-61, issue-63, issue-65, issue-67, issue-68
### Remaining branches after cleanup
| Branch | Status |
|--------|--------|
| `main` | Trunk |
| `claude/issue-5` | Open PR #93 |
| `claude/issue-37` | Open PR #80 |
| `gemini/issue-14` | New PR #104 (NIP-07 Nostr identity) |
| `claude/issue-103` | This audit branch |

View File

@@ -37,18 +37,6 @@
font-size: 13px; letter-spacing: 3px; margin-bottom: 4px;
color: #7799cc; text-shadow: 0 0 10px #4466aa;
}
#visitor-count-display {
margin-top: 5px;
font-size: 11px; color: #5588bb;
text-shadow: 0 0 6px #2244aa;
}
#visitor-count-display .count-number {
font-weight: bold;
}
@media (max-width: 600px) {
#visitor-count-display .desktop-only { display: none; }
#visitor-count-display .count-number::before { content: '👤 '; }
}
/* Nostr Identity UI */
.nostr-btn {
@@ -618,7 +606,6 @@
<h1>THE WORKSHOP</h1>
<div id="fps">FPS: --</div>
<div id="active-jobs">JOBS: 0</div>
<div id="visitor-count-display"><span class="desktop-only">VISITORS:</span> <span class="count-number">0</span></div>
<div id="session-hud">
<span id="session-hud-balance">Balance: -- sats</span>
<a href="#" id="session-hud-topup">⚡ Top Up</a>

View File

@@ -344,19 +344,6 @@ export function updateUI({ fps, jobCount, connectionState }) {
}
}
export function updateVisitorCount(count) {
const $visitorCountDisplay = document.querySelector('#visitor-count-display .count-number');
if ($visitorCountDisplay) {
$visitorCountDisplay.textContent = count;
const $desktopOnly = document.querySelector('#visitor-count-display .desktop-only');
if (window.innerWidth > 600) {
if ($desktopOnly) $desktopOnly.textContent = `VISITORS:`;
} else {
if ($desktopOnly) $desktopOnly.textContent = ``; // Hide 'VISITORS:' text on mobile
}
}
}
export function appendSystemMessage(text) {
if (!$log) return;
const el = document.createElement('div');

View File

@@ -1,7 +1,7 @@
import * as THREE from 'three';
import { scene } from './world.js'; // Import the scene
import { setAgentState, setSpeechBubble, applyAgentStates, setMood, TIMMY_WORLD_POS } from './agents.js';
import { appendSystemMessage, appendDebateMessage, showCostTicker, updateCostTicker, updateVisitorCount } from './ui.js';
import { appendSystemMessage, appendDebateMessage, showCostTicker, updateCostTicker } from './ui.js';
import { sentiment } from './edge-worker-client.js';
import { setLabelState } from './hud-labels.js';
import { createJobIndicator, dissolveJobIndicator } from './effects.js';
@@ -47,6 +47,8 @@ function connect() {
ws.onopen = () => {
connectionState = 'connected';
clearTimeout(reconnectTimer);
const npub = getPubkey();
send({ type: 'visitor_enter', visitorId, visitorName: 'visitor', npub });
};
ws.onmessage = event => {
@@ -188,10 +190,8 @@ function handleMessage(msg) {
break;
}
case 'agent_count':
case 'visitor_count':
if (typeof msg.count === 'number') {
updateVisitorCount(msg.count);
}
break;
default: