diff --git a/artifacts/api-server/src/lib/categorizer.ts b/artifacts/api-server/src/lib/categorizer.ts new file mode 100644 index 0000000..90e30cf --- /dev/null +++ b/artifacts/api-server/src/lib/categorizer.ts @@ -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 { + // ── 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 { + 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 +} diff --git a/artifacts/api-server/src/lib/http.js b/artifacts/api-server/src/lib/http.js new file mode 100644 index 0000000..799d45f --- /dev/null +++ b/artifacts/api-server/src/lib/http.js @@ -0,0 +1,7 @@ +export const Status = { + OK: 200, + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + NOT_FOUND: 404, + INTERNAL_SERVER_ERROR: 500, +}; diff --git a/artifacts/api-server/src/lib/provisioner.ts b/artifacts/api-server/src/lib/provisioner.ts index c721a2c..d40d4d2 100644 --- a/artifacts/api-server/src/lib/provisioner.ts +++ b/artifacts/api-server/src/lib/provisioner.ts @@ -81,23 +81,15 @@ FakeKeyForJob${jobId} --size ${this.config.doSize} \ --image ubuntu-22-04-x64 \ --enable-private-networking \ - --vpc-uuid \ + --vpc-uuid ${this.config.doVpcUuid} \ --user-data '${cloudConfig}' \ - --ssh-keys \ - --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 diff --git a/artifacts/api-server/src/routes/jobs.ts b/artifacts/api-server/src/routes/jobs.ts index 341ba68..e577101 100644 --- a/artifacts/api-server/src/routes/jobs.ts +++ b/artifacts/api-server/src/routes/jobs.ts @@ -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`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; + diff --git a/lib/db/src/schema/jobs.ts b/lib/db/src/schema/jobs.ts index b5538c0..6aabf13 100644 --- a/lib/db/src/schema/jobs.ts +++ b/lib/db/src/schema/jobs.ts @@ -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"), diff --git a/the-matrix/index.html b/the-matrix/index.html index f3ac471..773ef77 100644 --- a/the-matrix/index.html +++ b/the-matrix/index.html @@ -254,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; @@ -263,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; @@ -639,6 +725,7 @@
+ ⚙ RELAY ADMIN
@@ -701,6 +788,48 @@
+ +
+ +

📊 TIMMY — PERFORMANCE

+ +
+
+
--
+
JOBS COMPLETED
+
+
+
--
+
AVG RATING (1-5)
+
+
+
--
+
TOP 3 CATEGORIES
+
+
+
--
+
TOTAL SAT EARNED
+
+
+
--
+
SATS LAST 24H
+
+
+ +
RECENT JOBS
+
+ +
+
Example: Generate an image of a cat...
+
+ ⭐ 4 + ⚡ 100 sats + 15s +
+
+
+
+
@@ -810,6 +939,18 @@