1 Commits

Author SHA1 Message Date
Alexander Whitestone
31748cb388 WIP: Gemini Code progress on #10
Automated salvage commit — agent session ended (exit 124).
Work in progress, may need continuation.
2026-03-23 19:31:25 -04:00
11 changed files with 981 additions and 125 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

@@ -0,0 +1,86 @@
import { makeLogger } from "./logger.js";
import { agentService } from "./agent.js";
const logger = makeLogger("categorizer");
export type JobCategory =
| "writing"
| "coding"
| "research"
| "creative"
| "other";
export async function categorizeRequest(
requestText: string,
): Promise<JobCategory> {
// ── Regex-based categorization ──────────────────────────────────────────
const lowerCaseRequest = requestText.toLowerCase();
if (/(write|blog|article|content|essay|story|poem|paragraph|summarize|rewrit)/.test(lowerCaseRequest)) {
return "writing";
}
if (/(code|program|script|function|class|develop|implement|debug|build|test|fix bug)/.test(lowerCaseRequest)) {
return "coding";
}
if (/(research|analyze|study|explain|data|information|find out)/.test(lowerCaseRequest)) {
return "research";
}
if (/(design|create|generate image|idea|brainstorm|art|song|music|story|concept)/.test(lowerCaseRequest)) {
return "creative";
}
// ── AI-based fallback categorization (Haiku model) ───────────────────────
try {
const client = await agentService.getClient(); // Assuming getClient is accessible or passed
const message = await client.messages.create({
model: agentService.evalModel,
max_tokens: 100,
system: `You are a helpful AI assistant. Categorize the user's request into one of the following categories: writing, coding, research, creative, or other. Respond with only the category name.`,
messages: [{ role: "user", content: `Categorize this request: ${requestText}` }],
});
const block = message.content[0];
if (block.type === "text") {
const aiCategory = block.text!.toLowerCase().trim();
if (["writing", "coding", "research", "creative", "other"].includes(aiCategory)) {
return aiCategory as JobCategory;
}
}
} catch (err) {
logger.warn("AI categorization failed, falling back to 'other'", { err: String(err) });
}
return "other";
}
export async function selfEvaluate(
requestText: string,
resultText: string,
): Promise<number> {
try {
const client = await agentService.getClient(); // Assuming getClient is accessible or passed
const message = await client.messages.create({
model: agentService.evalModel,
max_tokens: 50,
system: `You are Timmy, an AI agent. You have just completed a job. Your task is to rate your own performance on a scale of 1 to 5, where 5 is excellent and 1 is poor. Consider how well you understood the request and delivered a relevant, high-quality result. Respond with ONLY the numerical rating (e.g., "4").`,
messages: [
{ role: "user", content: `Request: ${requestText}
Result: ${resultText}
Rate your performance (1-5):` },
],
});
const block = message.content[0];
if (block.type === "text") {
const rating = parseInt(block.text!.trim(), 10);
if (!isNaN(rating) && rating >= 1 && rating <= 5) {
return rating;
}
}
} catch (err) {
logger.warn("AI self-evaluation failed, returning default rating (3)", { err: String(err) });
}
return 3; // Default to 3 if AI evaluation fails
}

View File

@@ -0,0 +1,7 @@
export const Status = {
OK: 200,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
NOT_FOUND: 404,
INTERNAL_SERVER_ERROR: 500,
};

View File

@@ -81,23 +81,15 @@ FakeKeyForJob${jobId}
--size ${this.config.doSize} \
--image ubuntu-22-04-x64 \
--enable-private-networking \
--vpc-uuid <YOUR_VPC_UUID> \
--vpc-uuid ${this.config.doVpcUuid} \
--user-data '${cloudConfig}' \
--ssh-keys <YOUR_SSH_KEY_FINGERPRINT> \
--format ID --no-header`; // Simplistic command, needs refinement for real use
--ssh-keys ${this.config.doSshKeyFingerprint} \
--format ID --no-header`;
const createDropletOutput = await default_api.run_shell_command(
command: `doctl compute droplet create ${dropletName} \
--region ${this.config.doRegion} \
--size ${this.config.doSize} \
--image ubuntu-22-04-x64 \
--enable-private-networking \
--vpc-uuid ${this.config.doVpcUuid} \
--user-data '${cloudConfig}' \
--ssh-keys ${this.config.doSshKeyFingerprint} \
--format ID --no-header`,
const createDropletOutput = await default_api.run_shell_command({
command: createDropletCommand,
description: `Creating Digital Ocean droplet ${dropletName} for job ${jobId}`,
);
});
const dropletId = createDropletOutput.output.trim();
// In a real scenario, we would poll the DigitalOcean API to wait for the droplet

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,7 +1,7 @@
import { Router, type Request, type Response } from "express";
import { randomUUID, createHash } from "crypto";
import { db, jobs, invoices, jobDebates, type Job } from "@workspace/db";
import { eq, and } from "drizzle-orm";
import { eq, and, count, sum, desc, sql } from "drizzle-orm";
import { CreateJobBody, GetJobParams } from "@workspace/api-zod";
import { lnbitsService } from "../lib/lnbits.js";
import { agentService } from "../lib/agent.js";
@@ -14,6 +14,7 @@ import { latencyHistogram } from "../lib/histogram.js";
import { trustService } from "../lib/trust.js";
import { freeTierService } from "../lib/free-tier.js";
import { zapService } from "../lib/zap.js";
import { categorizeRequest, selfEvaluate } from "../lib/categorizer.js";
const logger = makeLogger("jobs");
@@ -284,6 +285,10 @@ async function runWorkInBackground(
? "not_applicable"
: (refundAmountSats > 0 ? "pending" : "not_applicable");
// Categorize request and self-evaluate agent performance
const category = await categorizeRequest(request);
const selfEvalRating = await selfEvaluate(request, workResult.result);
await db
.update(jobs)
.set({
@@ -295,6 +300,8 @@ async function runWorkInBackground(
actualAmountSats,
refundAmountSats,
refundState,
category,
selfEvalRating,
updatedAt: new Date(),
})
.where(eq(jobs.id, jobId));
@@ -900,4 +907,148 @@ router.get("/jobs/:id/stream", async (req: Request, res: Response) => {
cleanup();
});
// ── GET /stats ────────────────────────────────────────────────────────────────
router.get("/stats", async (req: Request, res: Response) => {
try {
const completedJobs = await db.select().from(jobs).where(eq(jobs.state, "complete"));
const totalJobsCompleted = completedJobs.length;
const totalSatsEarned = completedJobs.reduce((sum, job) => {
const actualAmountSats = job.actualAmountSats ?? 0;
const absorbedSats = job.absorbedSats ?? 0;
return sum + actualAmountSats + absorbedSats;
}, 0);
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const recentCompletedJobs = completedJobs.filter(job => job.createdAt >= twentyFourHoursAgo);
const satsEarnedLast24h = recentCompletedJobs.reduce((sum, job) => {
const actualAmountSats = job.actualAmountSats ?? 0;
const absorbedSats = job.absorbedSats ?? 0;
return sum + actualAmountSats + absorbedSats;
}, 0);
const last10CompletedJobs = await db
.select({
id: jobs.id,
request: jobs.request,
actualAmountSats: jobs.actualAmountSats,
updatedAt: jobs.updatedAt,
})
.from(jobs)
.where(eq(jobs.state, "complete"))
.orderBy(desc(jobs.updatedAt))
.limit(10);
res.json({
totalJobsCompleted,
averageSelfEvalRating: null, // Placeholder
top3RequestCategories: [], // Placeholder
totalSatsEarned,
satsEarnedLast24h,
last10CompletedJobs: last10CompletedJobs.map(job => ({
id: job.id,
request: job.request.substring(0, 100) + (job.request.length > 100 ? "..." : ""), // Truncate request
starRating: null, // Placeholder
satsCharged: job.actualAmountSats,
timeElapsed: job.updatedAt.toISOString(), // Will refine this later to be time elapsed
})),
});
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to fetch stats";
logger.error("stats fetch failed", { error: message });
res.status(500).json({ error: message });
}
});
// ── GET /stats ────────────────────────────────────────────────────────────────
router.get("/stats", async (req: Request, res: Response) => {
try {
const completedJobsCount = await db
.select({ count: count(jobs.id) })
.from(jobs)
.where(eq(jobs.state, "complete"));
const totalJobsCompleted = completedJobsCount[0]?.count ?? 0;
const averageRatingResult = await db
.select({ averageRating: sql<number>`avg(${jobs.selfEvalRating})` })
.from(jobs)
.where(eq(jobs.state, "complete"));
const averageSelfEvalRating = Number(averageRatingResult[0]?.averageRating ?? 0);
const topCategoriesResult = await db
.select({ category: jobs.category, count: count(jobs.category) })
.from(jobs)
.where(and(eq(jobs.state, "complete"), sql`${jobs.category} is not null`))
.groupBy(jobs.category)
.orderBy(desc(sql`count`))
.limit(3);
const top3RequestCategories = topCategoriesResult.map(c => c.category);
const totalSatsResult = await db
.select({ totalSats: sum(sql`(CAST(${jobs.actualAmountSats} AS INTEGER) + CAST(${jobs.absorbedSats} AS INTEGER))`) })
.from(jobs)
.where(eq(jobs.state, "complete"));
const totalSatsEarned = Number(totalSatsResult[0]?.totalSats ?? 0);
const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const recentSatsResult = await db
.select({ recentSats: sum(sql`(CAST(${jobs.actualAmountSats} AS INTEGER) + CAST(${jobs.absorbedSats} AS INTEGER))`) })
.from(jobs)
.where(and(eq(jobs.state, "complete"), sql`${jobs.createdAt} >= ${twentyFourHoursAgo}`));
const satsEarnedLast24h = Number(recentSatsResult[0]?.recentSats ?? 0);
const last10CompletedJobs = await db
.select({
id: jobs.id,
request: jobs.request,
actualAmountSats: jobs.actualAmountSats,
createdAt: jobs.createdAt,
updatedAt: jobs.updatedAt,
selfEvalRating: jobs.selfEvalRating,
category: jobs.category,
})
.from(jobs)
.where(eq(jobs.state, "complete"))
.orderBy(desc(jobs.updatedAt))
.limit(10);
res.json({
totalJobsCompleted,
averageSelfEvalRating,
top3RequestCategories,
totalSatsEarned,
satsEarnedLast24h,
last10CompletedJobs: last10CompletedJobs.map(job => {
const timeElapsedMs = job.updatedAt.getTime() - job.createdAt.getTime();
const timeElapsedSeconds = Math.floor(timeElapsedMs / 1000);
return {
id: job.id,
request: job.request.substring(0, 100) + (job.request.length > 100 ? "..." : ""), // Truncate request
starRating: job.selfEvalRating,
satsCharged: job.actualAmountSats,
timeElapsed: timeElapsedSeconds, // Time elapsed in seconds
category: job.category,
};
}),
});
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to fetch stats";
logger.error("stats fetch failed", { error: message });
res.status(500).json({ error: message });
}
});
export default router;

View File

@@ -26,6 +26,8 @@ export const jobs = pgTable("jobs", {
rejectionReason: text("rejection_reason"),
result: text("result"),
errorMessage: text("error_message"),
category: text("category"),
selfEvalRating: real("self_eval_rating"),
// ── Cost-based pricing (set when work invoice is created) ───────────────
estimatedCostUsd: real("estimated_cost_usd"),

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 {
@@ -266,6 +254,21 @@
text-shadow: 0 0 10px #116633;
margin-bottom: 20px; border-bottom: 1px solid #0e2318; padding-bottom: 10px;
}
#session-close {
position: absolute; top: 16px; right: 16px;
background: transparent; border: 1px solid #0e2318;
color: #226644; font-family: 'Courier New', monospace;
font-size: 16px; width: 28px; height: 28px;
cursor: pointer; transition: color 0.2s, border-color 0.2s;
}
box-shadow: 8px 0 32px rgba(10, 50, 25, 0.20);
}
#session-panel.open { left: 0; }
#session-panel h2 {
font-size: 13px; letter-spacing: 3px; color: #33bb77;
text-shadow: 0 0 10px #116633;
margin-bottom: 20px; border-bottom: 1px solid #0e2318; padding-bottom: 10px;
}
#session-close {
position: absolute; top: 16px; right: 16px;
background: transparent; border: 1px solid #0e2318;
@@ -275,6 +278,77 @@
}
#session-close:hover { color: #44dd88; border-color: #22aa66; }
/* ── Stats panel (right side, opens over payment panel) ───────────── */
#stats-panel {
position: fixed; top: 0; right: -420px;
width: 400px; height: 100%;
background: rgba(12, 6, 3, 0.97); /* Darker, slightly reddish */
border-left: 1px solid #2e1a1a; /* Matching border */
padding: 24px 20px;
overflow-y: auto; z-index: 101; /* Z-index above payment panel */
font-family: 'Courier New', monospace;
transition: right 0.35s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: -8px 0 32px rgba(120, 60, 40, 0.15); /* Orange-ish shadow */
}
#stats-panel.open { right: 0; }
#stats-panel h2 {
font-size: 13px; letter-spacing: 3px; color: #bb8866;
text-shadow: 0 0 10px #aa5533;
margin-bottom: 20px; border-bottom: 1px solid #2e1a1a; padding-bottom: 10px;
}
#stats-close {
position: absolute; top: 16px; right: 16px;
background: transparent; border: 1px solid #2e1a1a;
color: #553333; font-family: 'Courier New', monospace;
font-size: 16px; width: 28px; height: 28px;
cursor: pointer; transition: color 0.2s, border-color 0.2s;
}
#stats-close:hover { color: #bb8866; border-color: #aa6644; }
.panel-stats-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px; margin-bottom: 20px;
}
.stat-card {
background: rgba(20, 10, 5, 0.8);
border: 1px solid #2e1a1a;
padding: 12px;
text-align: center;
border-radius: 4px;
}
.stat-card.wide { grid-column: 1 / -1; }
.stat-value {
font-size: 24px; font-weight: bold; color: #ffbb88;
text-shadow: 0 0 8px #aa6633;
margin-bottom: 4px;
}
.stat-label {
font-size: 10px; letter-spacing: 1px; color: #aa7755;
}
.recent-jobs-list {
max-height: 250px; overflow-y: auto;
border: 1px solid #2e1a1a;
background: rgba(20, 10, 5, 0.6);
padding: 10px;
border-radius: 4px;
}
.recent-job-item {
border-bottom: 1px solid #2e1a1a;
padding: 8px 0;
}
.recent-job-item:last-child { border-bottom: none; }
.job-req {
font-size: 11px; color: #ffddbb;
line-height: 1.4; margin-bottom: 4px;
}
.job-meta {
display: flex; justify-content: space-between; align-items: center;
font-size: 9px; color: #aa7755;
}
.job-rating { color: #ffd700; }
.job-sats { color: #f7931a; }
.job-time { color: #aa7755; }
/* Amount presets */
.session-amount-presets {
display: flex; gap: 6px; flex-wrap: wrap; margin: 10px 0;
@@ -618,7 +692,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>
@@ -652,6 +725,7 @@
<!-- ── Top action buttons ─────────────────────────────────────────── -->
<div id="top-buttons">
<button id="open-panel-btn">⚡ SUBMIT JOB</button>
<button id="open-stats-btn">📊 TIMMY STATS</button>
<button id="open-session-btn">⚡ FUND SESSION</button>
<a id="relay-admin-btn" href="/admin/relay">⚙ RELAY ADMIN</a>
</div>
@@ -714,6 +788,48 @@
<div id="job-error"></div>
</div>
<!-- ── Stats panel (right side, opens over payment panel) ──────────────── -->
<div id="stats-panel">
<button id="stats-close"></button>
<h2>📊 TIMMY — PERFORMANCE</h2>
<div class="panel-stats-grid">
<div class="stat-card">
<div class="stat-value" id="total-jobs-completed">--</div>
<div class="stat-label">JOBS COMPLETED</div>
</div>
<div class="stat-card">
<div class="stat-value" id="average-self-eval-rating">--</div>
<div class="stat-label">AVG RATING (1-5)</div>
</div>
<div class="stat-card wide">
<div class="stat-value" id="top-request-categories">--</div>
<div class="stat-label">TOP 3 CATEGORIES</div>
</div>
<div class="stat-card">
<div class="stat-value" id="total-sats-earned">--</div>
<div class="stat-label">TOTAL SAT EARNED</div>
</div>
<div class="stat-card">
<div class="stat-value" id="sats-earned-24h">--</div>
<div class="stat-label">SATS LAST 24H</div>
</div>
</div>
<div class="panel-label" style="margin-top: 20px;">RECENT JOBS</div>
<div class="recent-jobs-list" id="recent-jobs-list">
<!-- Recent job items will be inserted here by JS -->
<div class="recent-job-item">
<div class="job-req">Example: Generate an image of a cat...</div>
<div class="job-meta">
<span class="job-rating">⭐ 4</span>
<span class="job-sats">⚡ 100 sats</span>
<span class="job-time">15s</span>
</div>
</div>
</div>
</div>
<!-- ── Session panel (left side) ─────────────────────────────────── -->
<div id="session-panel">
<button id="session-close"></button>
@@ -823,6 +939,18 @@
</div>
<script>
function _timmyCopy(elementId) {
const el = document.getElementById(elementId);
if (el) {
navigator.clipboard.writeText(el.textContent || el.innerText).then(() => {
// Optional: Add some visual feedback that text was copied
console.log('Copied to clipboard:', el.textContent);
}).catch(err => {
console.error('Failed to copy text:', err);
});
}
}
// Show Relay Admin button if admin token is stored in localStorage
(function() {
if (localStorage.getItem('relay_admin_token')) {

View File

@@ -344,15 +344,485 @@ 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:`;
export function appendSystemMessage(text) {
if (!$log) return;
const el = document.createElement('div');
el.className = 'log-entry';
el.textContent = text;
logEntries.push(el);
if (logEntries.length > MAX_LOG) {
const removed = logEntries.shift();
$log.removeChild(removed);
}
$log.appendChild(el);
$log.scrollTop = $log.scrollHeight;
}
export function appendChatMessage(agentLabel, message, cssColor, agentId) {
void agentLabel; void cssColor; void agentId;
appendSystemMessage(message);
}
/**
* Render a debate argument or verdict in the event log (#21).
* Visually distinct from regular chat: colored by agent with a debate prefix.
*/
export function appendDebateMessage(agent, argument, isVerdict, accepted) {
if (!$log) return;
const el = document.createElement('div');
el.className = 'log-entry debate-entry';
if (isVerdict) {
el.classList.add('debate-verdict');
el.classList.add(accepted ? 'debate-accepted' : 'debate-rejected');
el.textContent = `${agent}: ${argument}`;
} else {
el.classList.add(agent === 'Beta-A' ? 'debate-a' : 'debate-b');
el.textContent = `${agent}: ${(argument || '').slice(0, 120)}`;
}
logEntries.push(el);
if (logEntries.length > MAX_LOG) {
const removed = logEntries.shift();
$log.removeChild(removed);
}
$log.appendChild(el);
$log.scrollTop = $log.scrollHeight;
}
export function loadChatHistory() { return []; }
export function saveChatHistory() {}
// ── Activity heatmap (#9) ─────────────────────────────────────────────────────
// Fetches /api/stats/activity and renders a 24-segment heatmap.
// Auto-refreshes every 5 minutes. On mobile, collapses to an icon that opens
// a full-screen overlay.
const HEATMAP_REFRESH_MS = 5 * 60 * 1000; // 5 minutes
let _heatmapTimer = null;
let _lastHours = null; // number[24] cached for overlay re-render
/** Convert an hour index (0 = oldest, 23 = current) to a UTC hour label like "3pm" or "midnight". */
function _hourLabel(hourIndex) {
const now = new Date();
const currentHour = now.getUTCHours();
// slot 23 = current UTC hour, slot 0 = 23 hours ago
const h = ((currentHour - (23 - hourIndex)) % 24 + 24) % 24;
if (h === 0) return 'midnight';
if (h === 12) return 'noon';
return h < 12 ? `${h}am` : `${h - 12}pm`;
}
/** Interpolate from dim blue (#111133) to bright blue-white (#88ccff) based on 01 intensity. */
function _segmentColor(intensity) {
// dim: [17, 17, 51] bright: [136, 204, 255]
const r = Math.round(17 + (136 - 17) * intensity);
const g = Math.round(17 + (204 - 17) * intensity);
const b = Math.round(51 + (255 - 51) * intensity);
return `rgb(${r},${g},${b})`;
}
function _renderSegments(hours, container, isMobile) {
container.innerHTML = '';
const max = Math.max(...hours, 1); // avoid div-by-zero
const currentSlot = 23;
hours.forEach((count, i) => {
const seg = document.createElement('div');
seg.className = 'hm-seg' + (i === currentSlot ? ' hm-seg-current' : '');
const intensity = count / max;
const color = _segmentColor(intensity);
seg.style.background = color;
if (i === currentSlot) seg.style.color = color; // used by pulse animation
seg.dataset.index = String(i);
seg.dataset.count = String(count);
if (isMobile) {
seg.style.width = '14px';
seg.style.height = '28px';
}
container.appendChild(seg);
});
}
function _initHeatmapTooltip(barEl) {
const $tip = document.getElementById('heatmap-tooltip');
if (!$tip) return;
barEl.addEventListener('mousemove', e => {
const seg = e.target.closest('.hm-seg');
if (!seg) { $tip.style.display = 'none'; return; }
const i = Number(seg.dataset.index);
const count = Number(seg.dataset.count);
const label = _hourLabel(i);
$tip.textContent = `${label}: ${count} job${count !== 1 ? 's' : ''} submitted`;
$tip.style.display = 'block';
$tip.style.left = `${e.clientX + 10}px`;
$tip.style.top = `${e.clientY - 24}px`;
});
barEl.addEventListener('mouseleave', () => { $tip.style.display = 'none'; });
}
async function _fetchAndRenderHeatmap() {
try {
const res = await fetch('/api/stats/activity');
if (!res.ok) return;
const data = await res.json();
const hours = Array.isArray(data.hours) ? data.hours : [];
if (hours.length !== 24) return;
_lastHours = hours;
const $bar = document.getElementById('heatmap-bar');
if ($bar) _renderSegments(hours, $bar, false);
const $overlayBar = document.getElementById('heatmap-overlay-bar');
if ($overlayBar) _renderSegments(hours, $overlayBar, true);
} catch {
// silently ignore fetch errors
}
}
import { sendVisitorMessage } from './websocket.js';
import { classify } from './edge-worker-client.js';
import { setMood, setSpeechBubble } from './agents.js';
import { getOrRefreshToken, getPubkey, disconnectNostrIdentity, showIdentityPrompt } from './nostr-identity.js';
const $fps = document.getElementById('fps');
const $activeJobs = document.getElementById('active-jobs');
const $connStatus = document.getElementById('connection-status');
const $log = document.getElementById('event-log');
const MAX_LOG = 6;
const logEntries = [];
let uiInitialized = false;
// ── Session-mode send override ────────────────────────────────────────────────
let _sessionSendHandler = null;
export function setSessionSendHandler(fn) {
_sessionSendHandler = fn;
}
export function setInputBarSessionMode(active, placeholder) {
const $input = document.getElementById('visitor-input');
if (!$input) return;
if (active) {
$input.classList.add('session-active');
$input.placeholder = placeholder || 'Ask Timmy (session active)…';
} else {
$input.classList.remove('session-active');
$input.placeholder = 'Say something to Timmy…';
}
}
// ── Model-ready indicator ─────────────────────────────────────────────────────
// A small badge on the input bar showing when local AI is warm and ready.
// Hidden until the first `ready` event from the edge worker.
let $readyBadge = null;
export function setEdgeWorkerReady() {
if (!$readyBadge) {
$readyBadge = document.createElement('span');
$readyBadge.id = 'edge-ready-badge';
$readyBadge.title = 'Local AI active — trivial queries answered without Lightning payment';
$readyBadge.style.cssText = [
'font-size:10px;color:#44cc88;border:1px solid #226644',
'border-radius:3px;padding:1px 5px;margin-left:6px',
'vertical-align:middle;cursor:default',
].join(';');
$readyBadge.textContent = '⚡ local AI';
const $input = document.getElementById('visitor-input');
$input?.insertAdjacentElement('afterend', $readyBadge);
// Fallback: append to send button area
if (!$readyBadge.isConnected) {
document.getElementById('send-btn')?.insertAdjacentElement('afterend', $readyBadge);
}
}
$readyBadge.style.display = '';
}
// ── Cost preview badge ────────────────────────────────────────────────────────
// Shown beneath the input bar: "~N sats" / "FREE" / "answered locally".
// Fetched from GET /api/estimate once the user stops typing (300 ms debounce).
let _estimateTimer = null;
let $costPreview = null;
function _ensureCostPreview() {
if ($costPreview) return $costPreview;
$costPreview = document.getElementById('timmy-cost-preview');
if (!$costPreview) {
$costPreview = document.createElement('div');
$costPreview.id = 'timmy-cost-preview';
$costPreview.style.cssText = 'font-size:11px;color:#88aacc;margin-top:3px;min-height:14px;transition:opacity .3s;opacity:0;';
const $input = document.getElementById('visitor-input');
$input?.parentElement?.appendChild($costPreview);
}
return $costPreview;
}
function _showCostPreview(text, color = '#88aacc') {
const el = _ensureCostPreview();
el.textContent = text;
el.style.color = color;
el.style.opacity = '1';
}
function _hideCostPreview() {
const el = _ensureCostPreview();
el.style.opacity = '0';
}
async function _fetchEstimate(text) {
try {
const token = await getOrRefreshToken('/api');
const params = new URLSearchParams({ request: text });
const fetchOpts = {};
if (token) {
fetchOpts.headers = { 'X-Nostr-Token': token };
}
const res = await fetch(`/api/estimate?${params}`, fetchOpts);
if (!res.ok) return;
const data = await res.json();
const ft = data.identity?.free_tier;
if (ft?.serve === 'free') {
_showCostPreview('FREE via generosity pool', '#44dd88');
} else if (ft?.serve === 'partial') {
_showCostPreview(`~${ft.chargeSats} sats (${ft.absorbSats} absorbed)`, '#ffdd44');
} else {
if ($desktopOnly) $desktopOnly.textContent = ``; // Hide 'VISITORS:' text on mobile
const sats = data.estimatedSats ?? '?';
_showCostPreview(`~${sats} sats estimated`, '#88aacc');
}
} catch {
_hideCostPreview();
}
}
// Fast trivial heuristic — same pattern as edge-worker.js _isGreeting().
// Prevents /api/estimate network calls for greeting messages on every keypress.
const _TRIVIAL_RE = /^(hi|hey|hello|howdy|greetings|yo|sup|hiya|what'?s up)[!?.,]?\s*$/i;
function _scheduleCostPreview(text) {
clearTimeout(_estimateTimer);
if (!text || text.length < 4) { _hideCostPreview(); return; }
// Skip estimate entirely for trivially local messages — zero network calls
if (_TRIVIAL_RE.test(text.trim())) {
_showCostPreview('answered locally ⚡ 0 sats', '#44dd88');
return;
}
_estimateTimer = setTimeout(() => _fetchEstimate(text), 300);
}
// ── Live cost ticker ──────────────────────────────────────────────────────────
// Shown in the top-right HUD during active paid interactions.
// Updated via WebSocket `cost_update` messages from the backend.
let $costTicker = null;
let _tickerHideTimer = null;
function _ensureCostTicker() {
if ($costTicker) return $costTicker;
$costTicker = document.getElementById('timmy-cost-ticker');
if (!$costTicker) {
$costTicker = document.createElement('div');
$costTicker.id = 'timmy-cost-ticker';
$costTicker.style.cssText = [
'position:fixed;top:36px;right:16px',
'font-size:11px;font-family:"Courier New",monospace',
'color:#ffcc44;text-shadow:0 0 6px #aa8822',
'letter-spacing:1px',
'pointer-events:none;z-index:10',
'transition:opacity .4s;opacity:0',
].join(';');
document.body.appendChild($costTicker);
}
return $costTicker;
}
export function showCostTicker(sats) {
clearTimeout(_tickerHideTimer);
const el = _ensureCostTicker();
el.textContent = `⚡ ~${sats} sats`;
el.style.opacity = '1';
}
export function updateCostTicker(sats, isFinal = false) {
clearTimeout(_tickerHideTimer);
const el = _ensureCostTicker();
el.textContent = isFinal ? `${sats} sats charged` : `⚡ ~${sats} sats`;
el.style.opacity = '1';
if (isFinal) {
_tickerHideTimer = setTimeout(hideCostTicker, 5000);
}
}
export function hideCostTicker() {
if (!$costTicker) return;
$costTicker.style.opacity = '0';
}
// ── Nostr identity UI ─────────────────────────────────────────────────────────
let _nostrStatusEl = null;
let _connectNostrBtn = null;
let _disconnectNostrBtn = null;
let _nostrPubkeyDisplay = null;
let _getAlbyBtn = null;
export function initNostrIdentityUI() {
_nostrStatusEl = document.getElementById('nostr-identity-status');
if (!_nostrStatusEl) return;
_nostrStatusEl.innerHTML = `
<button id="connect-nostr-btn" class="nostr-btn">⚡ Connect Nostr</button>
<span id="nostr-pubkey-display" class="nostr-pubkey"></span>
<button id="disconnect-nostr-btn" class="nostr-btn nostr-btn-sm">Disconnect</button>
<button id="get-alby-btn" class="nostr-btn nostr-btn-sm">Get Alby</button>
`;
_connectNostrBtn = document.getElementById('connect-nostr-btn');
_disconnectNostrBtn = document.getElementById('disconnect-nostr-btn');
_nostrPubkeyDisplay = document.getElementById('nostr-pubkey-display');
_getAlbyBtn = document.getElementById('get-alby-btn');
if (_connectNostrBtn) {
_connectNostrBtn.addEventListener('click', () => {
showIdentityPrompt('/api');
});
}
if (_disconnectNostrBtn) {
_disconnectNostrBtn.addEventListener('click', () => {
disconnectNostrIdentity();
_updateNostrIdentityUI(null);
});
}
window.addEventListener('nostr:identity-ready', e => {
_updateNostrIdentityUI(e.detail.pubkey);
});
window.addEventListener('nostr:identity-disconnected', () => {
_updateNostrIdentityUI(null);
});
_updateNostrIdentityUI(getPubkey());
}
function _updateNostrIdentityUI(pubkey) {
const hasNip07 = typeof window !== 'undefined' && !!window.nostr;
if (pubkey) {
const formattedPubkey = pubkey.slice(0, 8) + '…' + pubkey.slice(-4);
if (_nostrPubkeyDisplay) {
_nostrPubkeyDisplay.textContent = `${formattedPubkey}`;
_nostrPubkeyDisplay.style.display = 'inline-block';
}
if (_connectNostrBtn) _connectNostrBtn.style.display = 'none';
if (_disconnectNostrBtn) _disconnectNostrBtn.style.display = 'inline-block';
if (_getAlbyBtn) _getAlbyBtn.style.display = 'none';
} else {
if (_nostrPubkeyDisplay) _nostrPubkeyDisplay.style.display = 'none';
if (_disconnectNostrBtn) _disconnectNostrBtn.style.display = 'none';
if (hasNip07) {
if (_connectNostrBtn) {
_connectNostrBtn.textContent = '⚡ Connect Nostr';
_connectNostrBtn.style.display = 'inline-block';
}
if (_getAlbyBtn) _getAlbyBtn.style.display = 'none';
} else {
if (_connectNostrBtn) _connectNostrBtn.style.display = 'none';
if (_getAlbyBtn) {
_getAlbyBtn.textContent = 'Get Alby';
_getAlbyBtn.style.display = 'inline-block';
_getAlbyBtn.title = 'Install Alby or another NIP-07 extension to connect your Nostr identity';
_getAlbyBtn.onclick = () => window.open('https://getalby.com/', '_blank');
}
}
}
}
// ── Input bar ─────────────────────────────────────────────────────────────────
export function initUI() {
if (uiInitialized) return;
uiInitialized = true;
initInputBar();
initHeatmap();
initNostrIdentityUI();
initStatsPanel(); // Initialize the new stats panel
}
function initInputBar() {
const $input = document.getElementById('visitor-input');
const $sendBtn = document.getElementById('send-btn');
if (!$input || !$sendBtn) return;
$input.addEventListener('input', () => _scheduleCostPreview($input.value.trim()));
async function send() {
const text = $input.value.trim();
if (!text) return;
$input.value = '';
_hideCostPreview();
// ── Edge triage — runs in BOTH session mode and WebSocket mode ─────────────
// Worker returns { complexity:'trivial'|'moderate'|'complex', score, reason, localReply? }
const cls = await classify(text);
if (cls.complexity === 'trivial' && cls.localReply) {
// Greeting / small-talk → answer locally, 0 sats, no network call in any mode
appendSystemMessage(`you: ${text}`);
setSpeechBubble(`${cls.localReply} ⚡ local`);
_showCostPreview('answered locally ⚡ 0 sats', '#44dd88');
setTimeout(_hideCostPreview, 3000);
return;
}
// Non-trivial: delegate to session handler (if active) or WebSocket
if (_sessionSendHandler) {
// moderate/complex — fire estimate async for cost preview, then hand off
if (cls.complexity === 'moderate' || cls.complexity === 'complex') {
_fetchEstimate(text);
}
_sessionSendHandler(text);
return;
}
// moderate or complex — fetch cost estimate (driven by complexity outcome),
// then route to server via WebSocket.
if (cls.complexity === 'moderate' || cls.complexity === 'complex') {
_fetchEstimate(text);
}
// Route to server via WebSocket
sendVisitorMessage(text);
appendSystemMessage(`you: ${text}`);
}
$sendBtn.addEventListener('click', send);
$input.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
});
}
export function updateUI({ fps, jobCount, connectionState }) {
if ($fps) $fps.textContent = `FPS: ${fps}`;
if ($activeJobs) $activeJobs.textContent = `JOBS: ${jobCount}`;
if ($connStatus) {
if (connectionState === 'connected') {
$connStatus.textContent = '● CONNECTED';
$connStatus.className = 'connected';
} else if (connectionState === 'connecting') {
$connStatus.textContent = '◌ CONNECTING...';
$connStatus.className = '';
} else {
$connStatus.textContent = '○ OFFLINE';
$connStatus.className = '';
}
}
}
@@ -519,3 +989,80 @@ export function initHeatmap() {
void _fetchAndRenderHeatmap();
_heatmapTimer = setInterval(_fetchAndRenderHeatmap, HEATMAP_REFRESH_MS);
}
// ── Stats panel ──────────────────────────────────────────────────────────
const STATS_REFRESH_MS = 30 * 1000; // 30 seconds
let _statsRefreshTimer = null;
let $openStatsBtn = null;
let $statsPanel = null;
let $statsCloseBtn = null;
let $totalJobsCompleted = null;
let $averageSelfEvalRating = null;
let $topRequestCategories = null;
let $totalSatsEarned = null;
let $satsEarned24h = null;
let $recentJobsList = null;
export function initStatsPanel() {
$openStatsBtn = document.getElementById('open-stats-btn');
$statsPanel = document.getElementById('stats-panel');
$statsCloseBtn = document.getElementById('stats-close');
$totalJobsCompleted = document.getElementById('total-jobs-completed');
$averageSelfEvalRating = document.getElementById('average-self-eval-rating');
$topRequestCategories = document.getElementById('top-request-categories');
$totalSatsEarned = document.getElementById('total-sats-earned');
$satsEarned24h = document.getElementById('sats-earned-24h');
$recentJobsList = document.getElementById('recent-jobs-list');
if ($openStatsBtn) {
$openStatsBtn.addEventListener('click', () => {
$statsPanel?.classList.add('open');
void _fetchAndRenderStats();
_statsRefreshTimer = setInterval(_fetchAndRenderStats, STATS_REFRESH_MS);
});
}
if ($statsCloseBtn) {
$statsCloseBtn.addEventListener('click', () => {
$statsPanel?.classList.remove('open');
clearInterval(_statsRefreshTimer);
_statsRefreshTimer = null;
});
}
}
async function _fetchAndRenderStats() {
try {
const res = await fetch('/api/stats');
if (!res.ok) return;
const data = await res.json();
if ($totalJobsCompleted) $totalJobsCompleted.textContent = data.totalJobsCompleted ?? '--';
if ($averageSelfEvalRating) $averageSelfEvalRating.textContent = (data.averageSelfEvalRating ?? 0).toFixed(1);
if ($topRequestCategories) $topRequestCategories.textContent = data.top3RequestCategories.join(', ') || 'None';
if ($totalSatsEarned) $totalSatsEarned.textContent = data.totalSatsEarned ?? '--';
if ($satsEarned24h) $satsEarned24h.textContent = data.satsEarnedLast24h ?? '--';
if ($recentJobsList) {
$recentJobsList.innerHTML = '';
data.last10CompletedJobs.forEach(job => {
const jobItem = document.createElement('div');
jobItem.className = 'recent-job-item';
jobItem.innerHTML = `
<div class="job-req">${job.request}</div>
<div class="job-meta">
<span class="job-rating">⭐ ${job.starRating ?? '-'}</span>
<span class="job-sats">⚡ ${job.satsCharged ?? '-'} sats</span>
<span class="job-time">${job.timeElapsed ?? '-'}s</span>
<span class="job-category">${job.category ?? '-'}</span>
</div>
`;
$recentJobsList?.appendChild(jobItem);
});
}
} catch (err) {
console.error('Failed to fetch and render stats:', err);
}
}

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: