Skip to content
Closed
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
8 changes: 4 additions & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,6 @@ wallets/
.wallets/
x402-test-wallets/
3dagent-log-export-*

# Site crawler (scripts/crawl-audit.mjs) — saved session + generated report
.crawl-auth.json
crawl-report.json
pump-sdk-case-study.html
.claude/scheduled_tasks.lock
threews-launch-week-case-study.html
Expand All @@ -105,6 +101,10 @@ scratch/screenshots/

# Playwright run artifacts
test-results/

# page-audit harness (scripts/page-audit.mjs)
reports/
.auth/
.env*
docs/internal/COINMARKETCAP_PARTNERSHIP_ARTICLE.md
docs/internal/COINMARKETCAP_ARTICLE.md
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Public feature history for [three.ws](https://three.ws), newest first. Feature d
## 2026-05-29

- **Forever — etch a message into Bitcoin** (`/forever`) — Inscribe a message onto the Bitcoin blockchain. It stays there. Forever.
- **Pay-As-You-Learn Tutor** (`/tutor`) — Ask anything and pay a cent per answer. A pay-as-you-learn AI tutor that bills $0.01 per explanation in USDC over x402, with a live itemized session invoice and signed attestation.
- **x402 Arbitrage** (`/arbitrage`) — Cross-provider price disparities surfaced live from the merged x402 facilitator catalog. Find the cheapest endpoint for any capability.
- **x402 Providers** (`/providers`) — Quantified operator profiles for the x402 paid API catalog. Service counts, price bands, dominant categories, and the underlying listings.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1959,7 +1959,7 @@ Full design and configuration in [docs/solana-pumpfun.md](docs/solana-pumpfun.md
Beyond the Solana reputation signals described above, the platform also ships consumer-facing pump.fun tooling:

- **Token Launcher** — UI for creating and launching new tokens, at [public/pumpfun.html](public/pumpfun.html).
- **Live Dashboard** — real-time tracker for new tokens, at [pump-live.html](pump-live.html).
- **Live Dashboard** — real-time tracker for new tokens, at [pages/pump-live.html](pages/pump-live.html).
- **Skills** — the [pump-fun-skills/](pump-fun-skills/) directory contains agent skills for reading and acting on pump.fun.

### Token launcher (USDC v2)
Expand Down
6 changes: 3 additions & 3 deletions agent-payments-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,9 @@
"bn.js": "^5.2.1",
"colyseus": "0.17.10",
"ethers": "^6.16.0",
"livepeer": "3.2.4",
"livepeer": "3.5.0",
"short-uuid": "6.0.3",
"viem": "^2.0.0"
"viem": "^2.51.3"
},
"peerDependencies": {
"zod": "^3.0.0"
Expand All @@ -101,7 +101,7 @@
"ts-jest": "^29.4.6",
"tsup": "^8.3.5",
"typescript": "^5.7.3",
"typescript-eslint": "^8.59.4",
"typescript-eslint": "^8.60.0",
"zod": "^3.25.76"
}
}
147 changes: 147 additions & 0 deletions agents/tutor/src/session.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
// Pay-As-You-Learn Tutor — session ledger.
//
// Each tutoring session keeps a running tab: every answered question appends an
// itemized charge, and "end session" produces an itemized invoice with a
// SHA-256 attestation over the entries. State lives in Upstash/Vercel KV (REST)
// with a 7-day TTL so a learner can resume a session after closing the tab.
//
// Storage is best-effort: when no KV is configured the tutor still works as a
// stateless per-question service — each answer simply reports its own charge
// and the session total reflects only the current request.

import { createHash } from 'crypto';

const SESSION_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days
const MAX_ENTRIES = 500; // hard cap so a session object can't grow unbounded

function kvCredentials() {
const url =
process.env.UPSTASH_REDIS_REST_URL ||
process.env.three_KV_REST_API_URL ||
process.env.KV_REST_API_URL;
const token =
process.env.UPSTASH_REDIS_REST_TOKEN ||
process.env.three_KV_REST_API_TOKEN ||
process.env.KV_REST_API_TOKEN;
return { url, token };
}

export function kvAvailable() {
const { url, token } = kvCredentials();
return Boolean(url && token);
}

async function kvGet(key) {
const { url, token } = kvCredentials();
if (!url || !token) return null;
try {
const r = await fetch(`${url}/get/${encodeURIComponent(key)}`, {
headers: { authorization: `Bearer ${token}` },
});
const d = await r.json();
return d.result ? JSON.parse(d.result) : null;
} catch {
return null;
}
}

async function kvSet(key, value, ttlSeconds) {
const { url, token } = kvCredentials();
if (!url || !token) return;
try {
await fetch(`${url}/set/${encodeURIComponent(key)}`, {
method: 'POST',
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: JSON.stringify({ value: JSON.stringify(value), ex: ttlSeconds }),
});
} catch {
// Non-fatal — session simply won't persist.
}
}

function sessionKey(sessionId) {
return `tutor:session:v1:${sessionId}`;
}

function emptySession(sessionId) {
return { sessionId, createdAt: new Date().toISOString(), entries: [], totalAtomics: 0, status: 'open' };
}

/** Load a session, or a fresh empty one when absent/unstored. */
export async function loadSession(sessionId) {
const existing = await kvGet(sessionKey(sessionId));
return existing || emptySession(sessionId);
}

/**
* Append one answered-question charge to the session tab and persist it.
* Returns the updated session.
*/
export async function appendCharge(sessionId, entry) {
const session = await loadSession(sessionId);
if (session.status === 'closed') {
const err = new Error('session is closed — start a new session');
err.status = 409;
err.code = 'session_closed';
throw err;
}
session.entries.push({
question: String(entry.question || '').slice(0, 500),
level: entry.level,
costAtomics: entry.costAtomics,
outputTokens: entry.outputTokens || 0,
sandboxRan: Boolean(entry.sandboxRan),
at: new Date().toISOString(),
});
if (session.entries.length > MAX_ENTRIES) {
session.entries = session.entries.slice(-MAX_ENTRIES);
}
session.totalAtomics = session.entries.reduce((sum, e) => sum + (e.costAtomics || 0), 0);
await kvSet(sessionKey(sessionId), session, SESSION_TTL_SECONDS);
return session;
}

/** Convert atomics (USDC 6dp) to a human "$x.xxxxxx" string. */
export function atomicsToUsd(atomics) {
return (Number(atomics) / 1_000_000).toFixed(6);
}

/**
* Close a session and produce an itemized, attested invoice.
* Idempotent: closing an already-closed session returns the same invoice.
*/
export async function closeSession(sessionId) {
const session = await loadSession(sessionId);

const lineItems = session.entries.map((e, i) => ({
n: i + 1,
question: e.question,
level: e.level,
outputTokens: e.outputTokens,
costAtomics: e.costAtomics,
costUsd: atomicsToUsd(e.costAtomics),
at: e.at,
}));

const attestation =
'sha256:' +
createHash('sha256')
.update(JSON.stringify({ sessionId, lineItems, totalAtomics: session.totalAtomics }))
.digest('hex');

const invoice = {
sessionId,
createdAt: session.createdAt,
closedAt: new Date().toISOString(),
questionCount: lineItems.length,
lineItems,
totalAtomics: session.totalAtomics,
totalUsd: atomicsToUsd(session.totalAtomics),
attestation,
};

session.status = 'closed';
session.invoice = invoice;
await kvSet(sessionKey(sessionId), session, SESSION_TTL_SECONDS);
return invoice;
}
122 changes: 122 additions & 0 deletions agents/tutor/src/teach.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// Pay-As-You-Learn Tutor — LLM-backed explanation generator.
//
// Routes through the platform's shared provider policy (api/_lib/llm.js):
// Groq and OpenRouter are the funded free defaults; Anthropic is used only when
// the operator brings their own key. The tutor turns a learner's question
// (optionally with code/context) into a structured, level-appropriate
// explanation, returning the output-token count so the endpoint can bill the
// per-token surcharge accurately.

import { llmComplete } from '../../../api/_lib/llm.js';

const LEVELS = {
beginner:
'The learner is a BEGINNER. Assume no prior knowledge. Define jargon the first ' +
'time it appears, use a concrete everyday analogy, and keep sentences short.',
intermediate:
'The learner is INTERMEDIATE. They know the basics. Skip trivial definitions, ' +
'focus on the "why" and common pitfalls, and connect the idea to related concepts.',
expert:
'The learner is an EXPERT. Be precise and dense. Lead with the nuance, edge cases, ' +
'performance/complexity characteristics, and trade-offs. Do not pad with basics.',
};

export const LEVEL_NAMES = Object.keys(LEVELS);

function buildSystemPrompt(level) {
return [
'You are the three.ws Tutor — a patient, rigorous teacher that explains one thing at a time.',
LEVELS[level] || LEVELS.intermediate,
'',
'Rules:',
'- Answer ONLY what was asked. Do not invent follow-up questions.',
'- When code is provided, reference specific lines/identifiers from it.',
'- Prefer a short worked example over abstract prose.',
'- If the question is ambiguous, state the most useful interpretation in one line, then answer it.',
'- Never fabricate APIs, citations, or facts. If unsure, say what is uncertain and why.',
'',
'Respond with a JSON object exactly matching this schema (no markdown fences, no preamble):',
'{',
' "explanation": string, // the core teaching answer, may use \\n for paragraphs',
' "keyPoints": string[], // 2-5 takeaways the learner should remember',
' "example": string | null, // a short worked example or code snippet, or null',
' "followUp": string | null // one suggested next question to deepen understanding, or null',
'}',
].join('\n');
}

function buildUserPrompt({ question, context }) {
const parts = [`Question: ${question}`];
if (context && context.trim()) {
parts.push('', 'Context / code provided by the learner:', '```', context.trim().slice(0, 6000), '```');
}
parts.push('', 'Return the JSON object only.');
return parts.join('\n');
}

function coerceShape(parsed, rawText) {
const out = {
explanation: '',
keyPoints: [],
example: null,
followUp: null,
};
if (parsed && typeof parsed === 'object') {
out.explanation =
typeof parsed.explanation === 'string' && parsed.explanation.trim()
? parsed.explanation.trim()
: (rawText || '').trim();
if (Array.isArray(parsed.keyPoints)) {
out.keyPoints = parsed.keyPoints
.filter((p) => typeof p === 'string' && p.trim())
.map((p) => p.trim())
.slice(0, 5);
}
if (typeof parsed.example === 'string' && parsed.example.trim()) out.example = parsed.example.trim();
if (typeof parsed.followUp === 'string' && parsed.followUp.trim()) out.followUp = parsed.followUp.trim();
} else {
out.explanation = (rawText || '').trim();
}
if (!out.explanation) out.explanation = 'No explanation could be generated for that question.';
return out;
}

/**
* Generate a structured tutoring explanation.
*
* @param {object} opts
* @param {string} opts.question What the learner asked.
* @param {string} [opts.context] Optional code/context to ground the answer.
* @param {string} [opts.level] beginner | intermediate | expert.
* @param {string} [opts.anthropicKey] Operator BYOK key (optional).
* @returns {Promise<{ explanation, keyPoints, example, followUp, outputTokens, provider, model }>}
*/
export async function teach({ question, context = '', level = 'intermediate', anthropicKey = null }) {
const lvl = LEVELS[level] ? level : 'intermediate';

const { text, usage, provider, model } = await llmComplete({
system: buildSystemPrompt(lvl),
user: buildUserPrompt({ question, context }),
maxTokens: 1200,
anthropicKey,
timeoutMs: 45_000,
});

let parsed = null;
try {
// Tolerate stray prose/fences around the JSON object.
const match = text.match(/\{[\s\S]*\}/);
parsed = JSON.parse(match ? match[0] : text);
} catch {
parsed = null;
}

const shaped = coerceShape(parsed, text);
return {
...shaped,
level: lvl,
outputTokens: usage?.output || 0,
provider,
model,
};
}
Loading
Loading