From ca18252eb7d649d8eb8d02d39c55a9724a5a59cc Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 6 Sep 2026 09:48:32 +0000 Subject: [PATCH] The agent asks, the person who knows answers, and gets paid for it Phase 2 of the Knowledge Influencer PRD. An agent building for a niche hits something only somebody who has done the job can settle and asks; the question lands on the operator's dashboard; the answer becomes niche knowledge and a scored contribution in the same request. That is the flywheel the whole programme rests on, because it is the only contribution route that arrives on its own instead of waiting for someone to go looking for work. Declining is a first-class answer and scores nothing. If the only paid action is answering, an operator who half-knows will guess, and a guess that gets verified becomes wrong knowledge that nothing downstream can tell from the real thing. A question left open costs a day; a confident wrong answer costs the niche its credibility. "Not enough context" leaves it open for someone else and "ask the agent to research more" hands it back. The internal route is signed, in the shape this deployment already verifies for CoinPay: t=,v1=, 300s tolerance, over the raw text rather than a re-serialised object. No secret means 503 rather than accepting unsigned work, because an open endpoint here mints scored contributions. The implementation is shared so Chovy verifies our answers with the code it signs its questions with. A question's context is whatever the agent scraped on the way to being stuck: attacker-influenced text heading for a human and then back into a model. It renders escaped inside a quoted block labelled as reported material, it is length-capped, and nothing on either side interpolates it into instructions. Verified with a script tag and an IGNORE ALL PREVIOUS INSTRUCTIONS payload. Two bugs found by running it, both older than this branch: jsonb columns were storing strings. Bun's driver passes a string parameter cast to jsonb straight through instead of parsing it, so every column written that way held "{}" rather than {}. It was already live: each opportunity page rendered a "How that score is made" list with two entries, 0 and 1, holding a brace each, because Object.keys of a string is its character indices. Writes now cast through text, reads go through asJson, and 0010 repairs the rows. And pending_count never moved, because it was only recomputed when something verified. The dashboard said nothing was awaiting review while a submission sat in the queue, which is how a person concludes the form is broken. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01W9NGGLDvNayi6uWheSXDGF --- .env.example | 9 + README.md | 7 + apps/web/public/styles.css | 34 +++ apps/web/src/app.js | 7 + apps/web/src/lib/chovy.js | 73 +++++ apps/web/src/routes/agents.js | 208 ++++++++++++++ apps/web/src/routes/knowledge.js | 7 +- apps/web/src/views/knowledge.jsx | 162 ++++++++++- docs/agent-questions.md | 152 ++++++++++ docs/architecture.md | 1 + docs/knowledge-influencers.md | 13 +- packages/config/src/index.js | 12 + .../db/migrations/0009_agent_questions.sql | 84 ++++++ .../migrations/0010_repair_jsonb_strings.sql | 38 +++ packages/db/package.json | 3 +- packages/db/src/agents.js | 270 ++++++++++++++++++ packages/db/src/knowledge.js | 13 +- packages/knowledge/package.json | 3 +- packages/knowledge/src/events.js | 3 + packages/knowledge/src/index.js | 33 +++ packages/knowledge/src/signing.js | 85 ++++++ test/knowledge.test.js | 35 +++ test/schema.test.js | 237 +++++++++++++++ test/signing.test.js | 98 +++++++ 24 files changed, 1572 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/lib/chovy.js create mode 100644 apps/web/src/routes/agents.js create mode 100644 docs/agent-questions.md create mode 100644 packages/db/migrations/0009_agent_questions.sql create mode 100644 packages/db/migrations/0010_repair_jsonb_strings.sql create mode 100644 packages/db/src/agents.js create mode 100644 packages/knowledge/src/signing.js create mode 100644 test/signing.test.js diff --git a/.env.example b/.env.example index cc1280d..9f37759 100644 --- a/.env.example +++ b/.env.example @@ -55,3 +55,12 @@ CRAWL_MAX_DAYS=30 # Loyalty: "spent cents:percent off" pairs. The more an agent has paid, the less a day costs. CRAWL_LOYALTY=1000:20,5000:40,10000:60 CRAWL_FLOOR_CENTS=10 + +# The agent question loop. CHOVY_SIGNING_SECRET signs both directions +# (X-Chovy-Signature: t=,v1=). Unset means the +# internal routes answer 503 rather than accepting unsigned work, because a +# question creates a scored contribution and moves somebody's revenue share. +CHOVY_SIGNING_SECRET= +# Optional: where an answered question is delivered back. Without it the loop +# still records and scores; the agent has to come and read instead. +CHOVY_WEBHOOK_URL= diff --git a/README.md b/README.md index e699248..164237d 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,13 @@ public page (plus `skill.md` and `manifest.json` for agents), `/@` is an operator's profile, `/dashboard/niches` is their own view, and `/admin/knowledge` is where claims and contributions are verified. +When an agent gets stuck on something only a person who has done the job can +settle, it asks: the question lands on the operator's dashboard, the answer +becomes niche knowledge and a scored contribution, and Chovy is told. Saying +"not enough context" is a first-class answer that costs nothing, because a +guess that gets verified is worse for the niche than an open question. See +[docs/agent-questions.md](docs/agent-questions.md). + Score comes only from contributions somebody verified, and volume does not buy it: repeated submissions of a type pay less each time, anything claiming a customer or a payment needs an outside reference, and a duplicate books once. diff --git a/apps/web/public/styles.css b/apps/web/public/styles.css index 172d71f..a4f0ae3 100644 --- a/apps/web/public/styles.css +++ b/apps/web/public/styles.css @@ -934,3 +934,37 @@ td.evidence { max-width: 22rem; overflow-wrap: anywhere; } + +/* the agent question loop */ +.card.question { + gap: 0.5rem; +} +.badge.urgent { + display: inline-block; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0.1rem 0.4rem; + border-radius: var(--radius-lg); + border: 1px solid var(--destructive); + color: var(--destructive); +} +/* What the agent scraped on its way to being stuck. Quoted so a reader can see + it is reported text and not the site talking, and collapsed so it does not + bury the question it is context for. */ +.agent-summary { + cursor: pointer; + font-size: 0.8rem; + color: var(--muted); +} +.agent-quote { + margin: 0.4rem 0 0; + padding: 0.5rem 0.75rem; + border-left: 2px solid var(--border); + color: var(--muted); + font-size: 0.85rem; + white-space: pre-wrap; + overflow-wrap: anywhere; + max-height: 18rem; + overflow-y: auto; +} diff --git a/apps/web/src/app.js b/apps/web/src/app.js index 2e390a5..41bf72f 100644 --- a/apps/web/src/app.js +++ b/apps/web/src/app.js @@ -6,6 +6,7 @@ import { modulesFor, withModules } from './lib/modules.js'; import { partners } from './lib/partners.js'; import { gateway, gatewayFor } from './lib/pricing.js'; import { Denied } from './lib/service.js'; +import { registerAgents } from './routes/agents.js'; import { registerApi } from './routes/api.js'; import { registerAuth } from './routes/auth.js'; import { registerAutomotive } from './routes/automotive.js'; @@ -100,6 +101,12 @@ registerApi(app); registerAutomotive(app); registerMcp(app); +/** + * The agent question loop. Before the niche routes, because its dashboard and + * API paths are literal and must be matched before `/:slug` is asked. + */ +registerAgents(app); + /** * Last, because a niche's page is served from the site root: every other * route is registered before `/:slug` can be asked. A niche may not take a diff --git a/apps/web/src/lib/chovy.js b/apps/web/src/lib/chovy.js new file mode 100644 index 0000000..e1e05d9 --- /dev/null +++ b/apps/web/src/lib/chovy.js @@ -0,0 +1,73 @@ +import { config } from '@nichedb/config'; +import { domainEvent } from '@nichedb/knowledge'; +import { SIGNATURE_HEADER, signPayload, verifySignature } from '@nichedb/knowledge/signing'; + +/** + * The wire between NicheDB and Chovy. + * + * Both directions are signed with the same secret and the same scheme, so the + * side that signs a question verifies the answer with the code it already has. + * Neither side reads the other's tables; everything crosses as an event with a + * globally unique id, which is what makes a redelivery cheap to ignore. + */ + +export const chovyConfigured = () => Boolean(config.chovy.signingSecret); + +/** + * Check the signature on an internal request. + * + * Returns the reason on failure so an integrator debugging a 401 can tell a + * wrong secret from a wrong clock. The reason is safe to return: it says + * nothing a caller holding the body does not already know. + */ +export async function verifyInternalRequest(c) { + if (!chovyConfigured()) + return { ok: false, status: 503, reason: 'this deployment has no CHOVY_SIGNING_SECRET' }; + + // The raw text, not a re-serialised object: an HMAC is over bytes, and + // JSON.stringify(JSON.parse(x)) is not always x. + const rawBody = await c.req.text(); + const result = verifySignature({ + rawBody, + header: c.req.header(SIGNATURE_HEADER), + secret: config.chovy.signingSecret, + }); + if (!result.ok) return { ok: false, status: 401, reason: result.reason }; + + try { + return { ok: true, body: rawBody ? JSON.parse(rawBody) : {}, rawBody }; + } catch { + return { ok: false, status: 400, reason: 'body is not JSON' }; + } +} + +/** + * Tell Chovy something happened. + * + * Never throws and never blocks the thing that triggered it. An operator's + * answer is recorded and scored whether or not the agent is reachable; a + * delivery that failed is a log line, not a lost contribution. Without a + * configured URL this is a no-op, which is the normal state until a Chovy + * deployment exists to point at. + */ +export async function notifyChovy(type, { payload, nicheId = null, userId = null } = {}) { + if (!chovyConfigured() || !config.chovy.webhookUrl) return { sent: false, reason: 'not wired' }; + const event = domainEvent({ type, producer: 'nichedb', payload, nicheId, userId }); + const rawBody = JSON.stringify(event); + try { + const res = await fetch(config.chovy.webhookUrl, { + method: 'POST', + headers: { + 'content-type': 'application/json', + [SIGNATURE_HEADER]: signPayload({ rawBody, secret: config.chovy.signingSecret }), + }, + body: rawBody, + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) console.error('[chovy] delivery refused', type, res.status); + return { sent: res.ok, status: res.status, eventId: event.id }; + } catch (err) { + console.error('[chovy] could not deliver', type, err?.message ?? err); + return { sent: false, reason: String(err?.message ?? err) }; + } +} diff --git a/apps/web/src/routes/agents.js b/apps/web/src/routes/agents.js new file mode 100644 index 0000000..f121f65 --- /dev/null +++ b/apps/web/src/routes/agents.js @@ -0,0 +1,208 @@ +import * as a from '@nichedb/db/agents'; +import * as k from '@nichedb/db/knowledge'; +import { chovyConfigured, notifyChovy, verifyInternalRequest } from '../lib/chovy.js'; +import { render, requireUser, respond } from '../lib/http.js'; +import { isAdmin } from '../lib/service.js'; +import { NicheQuestions } from '../views/knowledge.jsx'; + +/** + * The agent question loop. + * + * An agent gets stuck on something only a person who has done the job can + * settle, and asks. The operator answers in about two minutes, the answer + * becomes scored knowledge, and Chovy is told. + * + * The internal route is signed. Everything else here is an ordinary + * authenticated page or endpoint, because answering is a thing a person does. + */ +export function registerAgents(app) { + /* -------------------------------------------------------------- internal -- */ + + /** + * An agent asking for judgement. + * + * Signed with CHOVY_SIGNING_SECRET, because this creates work that pays: an + * unsigned endpoint here is a way to mint scored contributions. + */ + app.post('/api/v1/internal/expert-questions', async (c) => { + const auth = await verifyInternalRequest(c); + if (!auth.ok) return c.json({ error: auth.reason }, auth.status); + + const payload = auth.body?.payload ?? auth.body ?? {}; + const slug = payload.nicheSlug ?? payload.niche ?? null; + const niche = slug ? await k.getNiche(slug) : await k.getNicheById(payload.nicheId); + if (!niche) return c.json({ error: 'no such niche' }, 404); + if (!payload.title || !payload.question) + return c.json({ error: 'a question needs a title and a question' }, 400); + + const { question, duplicate } = await a.createQuestion({ + // Chovy's own id if it sent one, else the envelope's, so a redelivery of + // either shape books once. + externalId: payload.externalId ?? auth.body?.id ?? null, + nicheId: niche.id, + agentId: payload.agentId ?? niche.primary_agent_id ?? null, + title: payload.title, + question: payload.question, + context: payload.context ?? null, + options: payload.options ?? [], + urgency: payload.urgency ?? 'normal', + relatedResourceIds: payload.relatedResourceIds ?? [], + }); + + return c.json( + { + stored: Boolean(question), + duplicate, + question: question ? { id: String(question.id), status: question.status } : null, + }, + duplicate ? 200 : 201, + ); + }); + + /* ------------------------------------------------------------- operators -- */ + + app.get('/api/v1/niches/:slug/questions', async (c) => { + const niche = await k.getNiche(c.req.param('slug')); + if (!niche) return c.json({ error: 'not found' }, 404); + const user = requireUser(c); + const member = await k.memberOf({ nicheId: niche.id, userId: user.id }); + // A question carries the agent's raw research, which is not public. + if (member?.status !== 'active' && !isAdmin(user)) + return c.json({ error: 'you do not operate this niche' }, 403); + return c.json({ + questions: await a.listQuestions({ + nicheId: niche.id, + status: c.req.query('status') ?? 'waiting', + }), + }); + }); + + app.post('/api/v1/niches/:slug/questions/:id/answer', async (c) => { + const user = requireUser(c); + const niche = await k.getNiche(c.req.param('slug')); + if (!niche) return c.json({ error: 'not found' }, 404); + const body = await c.req.json().catch(() => ({})); + return answer(c, { + user, + questionId: c.req.param('id'), + kind: body.kind, + optionId: body.optionId, + body: body.answer ?? body.body, + }); + }); + + /* ------------------------------------------------------------- dashboard -- */ + + app.get('/dashboard/niches/:slug/questions', async (c) => { + const user = requireUser(c); + const niche = await k.getNiche(c.req.param('slug')); + if (!niche) return c.notFound(); + const member = await k.memberOf({ nicheId: niche.id, userId: user.id }); + if (member?.status !== 'active' && !isAdmin(user)) return c.notFound(); + + const [open, settled] = await Promise.all([ + a.listQuestions({ nicheId: niche.id, status: 'waiting' }), + a.listQuestions({ nicheId: niche.id, status: 'answered', limit: 20 }), + ]); + return c.html( + await render( + , + ), + ); + }); + + /** The form on the dashboard posts here. */ + app.post('/dashboard/questions/:id/answer', async (c) => { + const user = requireUser(c); + const form = await c.req.parseBody(); + return answer(c, { + user, + questionId: c.req.param('id'), + kind: form.kind, + optionId: form.optionId || null, + body: form.answer || null, + redirectTo: form.next ? String(form.next) : '/dashboard/niches', + }); + }); + + app.post('/dashboard/questions/:id/dismiss', async (c) => { + const user = requireUser(c); + const done = await a.dismissQuestion({ questionId: c.req.param('id'), influencerId: user.id }); + return respond(c, { + json: { dismissed: Boolean(done) }, + redirectTo: '/dashboard/niches', + notice: done ? 'Dismissed.' : null, + error: done ? null : 'That question is not yours to dismiss.', + }); + }); + + /** + * One place where an answer is recorded, whichever surface it came from. + * + * Chovy is told after the write, never before, and a delivery that fails + * does not fail the answer: the contribution is already banked. + */ + async function answer(c, { user, questionId, kind, optionId, body, redirectTo }) { + const out = await a.answerQuestion({ + questionId, + influencerId: user.id, + kind: kind ? String(kind) : 'answered', + optionId: optionId ? String(optionId) : null, + body: body ?? null, + }); + if (!out.ok) + return respond(c, { + json: { error: out.reason }, + status: 400, + redirectTo, + error: out.reason, + }); + + if (out.answer.kind === 'answered') { + await notifyChovy('expert_answer.created', { + nicheId: out.question.niche_id, + userId: user.id, + payload: { + questionId: String(out.question.id), + externalId: out.question.external_id, + answer: out.answer.body, + optionId: out.answer.option_id, + answeredBy: user.handle ?? null, + }, + }); + } else if (out.answer.kind === 'needs_research') { + await notifyChovy('agent.research_requested', { + nicheId: out.question.niche_id, + userId: user.id, + payload: { + questionId: String(out.question.id), + externalId: out.question.external_id, + note: out.answer.body, + }, + }); + } + + const said = + out.answer.kind === 'answered' + ? out.contributionStatus === 'verified' + ? `Thanks. That is ${out.points} points.` + : 'Thanks. It goes to review before it counts.' + : out.answer.kind === 'needs_research' + ? 'Sent back to the agent for more research.' + : 'Noted. It stays open for someone else.'; + + return respond(c, { + json: { ok: true, points: out.points, status: out.contributionStatus }, + redirectTo, + notice: said, + }); + } +} diff --git a/apps/web/src/routes/knowledge.js b/apps/web/src/routes/knowledge.js index 10cd442..cd05ee5 100644 --- a/apps/web/src/routes/knowledge.js +++ b/apps/web/src/routes/knowledge.js @@ -1,4 +1,5 @@ import { config } from '@nichedb/config'; +import * as agents from '@nichedb/db/agents'; import * as k from '@nichedb/db/knowledge'; import { CONTRIBUTION_EVENT_TYPES, @@ -118,10 +119,12 @@ export function registerKnowledge(app) { app.get('/dashboard/niches', async (c) => { const user = requireUser(c); - const [niches, contributions, tiers] = await Promise.all([ + const [niches, contributions, tiers, questions, questionCounts] = await Promise.all([ k.nichesForUser(user.id), k.listContributions({ influencerId: user.id, limit: 50 }), k.listTiers(), + agents.questionsAwaiting(user.id), + agents.openQuestionCounts(user.id), ]); return c.html( await render( @@ -130,6 +133,8 @@ export function registerKnowledge(app) { niches={niches} contributions={contributions} tiers={tiers} + questions={questions} + questionCounts={questionCounts} notice={c.req.query('notice')} error={c.req.query('error')} />, diff --git a/apps/web/src/views/knowledge.jsx b/apps/web/src/views/knowledge.jsx index 2c09d84..4fb9749 100644 --- a/apps/web/src/views/knowledge.jsx +++ b/apps/web/src/views/knowledge.jsx @@ -1,4 +1,4 @@ -import { formatBps, nextTierFor } from '@nichedb/knowledge'; +import { asJson, asJsonArray, formatBps, nextTierFor } from '@nichedb/knowledge'; import { Notice, Num, Relative } from './components.jsx'; import { Layout } from './Layout.jsx'; @@ -232,11 +232,11 @@ export const OpportunityPage = ({ user, opportunity, tiers, claim, notice, error ) : null} - {Object.keys(opportunity.dimensions ?? {}).length ? ( + {Object.keys(asJson(opportunity.dimensions)).length ? (

How that score is made

    - {Object.entries(opportunity.dimensions).map(([k, v]) => ( + {Object.entries(asJson(opportunity.dimensions)).map(([k, v]) => (
  • {k.replace(/_/g, ' ')} {v}
  • @@ -330,10 +330,23 @@ export const InfluencerPage = ({ user, influencer }) => ( ); /** The operator's own view: what needs them, and where they stand. */ -export const InfluencerDashboard = ({ user, niches, contributions, tiers, notice, error }) => ( +export const InfluencerDashboard = ({ + user, + niches, + contributions, + tiers, + questions = [], + questionCounts = {}, + notice, + error, +}) => ( + {/* First on the page, because this is the thing that actually wants a + human today. Everything below it is a record of what already happened. */} + +

    Your niches

    {niches.length === 0 ? (

    @@ -364,6 +377,12 @@ export const InfluencerDashboard = ({ user, niches, contributions, tiers, notice <>At the top of the ladder. )}

    + ); })} @@ -556,3 +575,138 @@ export const KnowledgeAdmin = ({ user, claims, pending, audit, notice, error })
); + +/** + * One question, with the three things a person can say about it. + * + * Everything from the agent is rendered as text. `context` in particular is + * whatever it scraped on the way to being stuck, so it is untrusted input on + * its way to a human and then back to a model: it goes in a quoted block that + * says where it came from, and nothing on either side of the loop treats it as + * an instruction. + */ +export const QuestionCard = ({ question, next }) => ( +
  • +

    + {question.urgency === 'high' ? urgent : null}{' '} + {question.title} +

    + {question.niche_slug ? ( +

    + {question.niche_name} + {question.status === 'researching' ? ' · sent back for research' : ''} +

    + ) : null} + +

    {question.question}

    + + {question.context ? ( +
    + What the agent found + {/* Quoted, not obeyed. This is crawled text. */} +
    {question.context}
    +
    + ) : null} + +
    + + {asJsonArray(question.options).length ? ( +
    + Which is it? + {asJsonArray(question.options).map((o) => ( + + ))} +
    + ) : null} + +