diff --git a/src/lib/do-client.ts b/src/lib/do-client.ts index f11f2e2..a840076 100644 --- a/src/lib/do-client.ts +++ b/src/lib/do-client.ts @@ -293,6 +293,7 @@ export interface CreateSignalInput { tags: string[]; signature?: string; disclosure?: string; + agent_name?: string | null; } export interface CooldownInfo { diff --git a/src/lib/types.ts b/src/lib/types.ts index c7447d5..5c34b7e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -219,6 +219,8 @@ export interface Signal { readonly reviewed_at: string | null; /** Models, tools, and skills used to produce this signal */ readonly disclosure: string; + /** Agent display name captured at filing time (nullable for older signals) */ + readonly agent_name?: string | null; /** Auto-computed quality score (0–100) assigned at submission time */ readonly quality_score: number | null; /** Per-dimension breakdown of quality_score (parsed from JSON in DB) */ diff --git a/src/objects/news-do.ts b/src/objects/news-do.ts index d3c3f85..bf80ba7 100644 --- a/src/objects/news-do.ts +++ b/src/objects/news-do.ts @@ -5,7 +5,7 @@ import type { Env, Beat, Signal, SignalStatus, Streak, Brief, Classified, Classi import { validateSlug, validateHexColor, sanitizeString, validateDateFormat } from "../lib/validators"; import { generateId, getUTCDate, getUTCYesterday, getUTCDayStart, getUTCDayEnd, getNextDate } from "../lib/helpers"; import { CLASSIFIED_DURATION_DAYS, CLASSIFIED_BRIEF_SLOTS, CLASSIFIED_BRIEF_MAX_CHARS, CLASSIFIED_STATUSES, SIGNAL_COOLDOWN_HOURS, BEAT_EXPIRY_DAYS, MAX_SIGNALS_PER_DAY, MAX_INCLUDED_SIGNALS_PER_BRIEF, MAX_APPROVED_SIGNALS_PER_DAY, SIGNAL_STATUSES, REVIEWABLE_SIGNAL_STATUSES, CONFIG_PUBLISHER_ADDRESS, BRIEF_INCLUSION_PAYOUT_SATS, WEEKLY_PRIZE_1ST_SATS, WEEKLY_PRIZE_2ND_SATS, WEEKLY_PRIZE_3RD_SATS, SCORING_WEIGHTS, PAYMENT_STAGE_TTL_MS } from "../lib/constants"; -import { SCHEMA_SQL, MIGRATION_PHASE0_SQL, MIGRATION_PAYMENTS_SQL, MIGRATION_BEAT_RESTRUCTURE_SQL, MIGRATION_SBTC_TRACKING_SQL, MIGRATION_CLASSIFIEDS_CLEANUP_SQL, MIGRATION_CLASSIFIEDS_REVIEW_SQL, MIGRATION_SNAPSHOTS_SQL, MIGRATION_BEAT_CLAIMS_SQL, MIGRATION_RETRACTION_SQL, MIGRATION_BEAT_NETWORK_FOCUS_SQL, MIGRATION_BITCOIN_MACRO_SQL, MIGRATION_QUANTUM_BEAT_SQL, MIGRATION_PAYMENT_STAGING_SQL, MIGRATION_APPROVAL_CAP_INDEX_SQL, MIGRATION_BEAT_EDITORS_SQL, MIGRATION_EDITORIAL_REVIEWS_SQL, MIGRATION_EDITOR_REVIEW_RATE_SQL, MIGRATION_CURATION_CLEANUP_SQL, MIGRATION_LEADERBOARD_INDEXES_SQL, MIGRATION_BEAT_CONSOLIDATION_SQL, MIGRATION_SIGNAL_SCORING_SQL, MIGRATION_APR7_EARNINGS_SQL, MIGRATION_CLASSIFIEDS_TXID_UNIQUE_SQL, MIGRATION_SIGNAL_HOT_PATH_INDEXES_SQL, MIGRATION_CORRESPONDENTS_BUNDLE_INDEXES_SQL } from "./schema"; +import { SCHEMA_SQL, MIGRATION_PHASE0_SQL, MIGRATION_PAYMENTS_SQL, MIGRATION_BEAT_RESTRUCTURE_SQL, MIGRATION_SBTC_TRACKING_SQL, MIGRATION_CLASSIFIEDS_CLEANUP_SQL, MIGRATION_CLASSIFIEDS_REVIEW_SQL, MIGRATION_SNAPSHOTS_SQL, MIGRATION_BEAT_CLAIMS_SQL, MIGRATION_RETRACTION_SQL, MIGRATION_BEAT_NETWORK_FOCUS_SQL, MIGRATION_BITCOIN_MACRO_SQL, MIGRATION_QUANTUM_BEAT_SQL, MIGRATION_PAYMENT_STAGING_SQL, MIGRATION_APPROVAL_CAP_INDEX_SQL, MIGRATION_BEAT_EDITORS_SQL, MIGRATION_EDITORIAL_REVIEWS_SQL, MIGRATION_EDITOR_REVIEW_RATE_SQL, MIGRATION_CURATION_CLEANUP_SQL, MIGRATION_LEADERBOARD_INDEXES_SQL, MIGRATION_BEAT_CONSOLIDATION_SQL, MIGRATION_SIGNAL_SCORING_SQL, MIGRATION_APR7_EARNINGS_SQL, MIGRATION_CLASSIFIEDS_TXID_UNIQUE_SQL, MIGRATION_SIGNAL_HOT_PATH_INDEXES_SQL, MIGRATION_CORRESPONDENTS_BUNDLE_INDEXES_SQL, MIGRATION_AGENT_NAME_SQL } from "./schema"; import { scoreSignal } from "../lib/signal-scorer"; // ── State machine transition maps ── @@ -48,6 +48,7 @@ interface RawSignalRow { publisher_feedback: string | null; reviewed_at: string | null; disclosure: string; + agent_name: string | null; quality_score: number | null; score_breakdown: string | null; // JSON-encoded SignalScoreBreakdown } @@ -268,6 +269,7 @@ function rowToSignal(row: Record): Signal { publisher_feedback: raw.publisher_feedback ?? null, reviewed_at: raw.reviewed_at ?? null, disclosure: raw.disclosure ?? "", + agent_name: raw.agent_name ?? null, quality_score: raw.quality_score ?? null, score_breakdown: raw.score_breakdown ? (() => { try { return JSON.parse(raw.score_breakdown); } catch { return null; } })() @@ -713,7 +715,8 @@ export class NewsDO extends DurableObject { // 26 = Partial UNIQUE index on classifieds.payment_txid for replay protection across both placement paths // 27 = Signal hot-path composite indexes for Cloudflare bill reduction // 28 = Correspondents bundle composite indexes for DO timeout reduction - const CURRENT_MIGRATION_VERSION = 28; + // 29 = Agent name on signals (store display name at filing time, closes #369) + const CURRENT_MIGRATION_VERSION = 29; const versionRows = this.ctx.storage.sql .exec("SELECT value FROM config WHERE key = 'migration_version'") .toArray(); @@ -1129,6 +1132,18 @@ export class NewsDO extends DurableObject { } } + // Agent name on signals — store display name at filing time (closes #369). + if (appliedVersion < 29) { + try { + this.ctx.storage.sql.exec(MIGRATION_AGENT_NAME_SQL); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (!msg.includes("duplicate column")) { + console.error("Agent name migration failed:", e); + } + } + } + // Record current migration version so future cold starts skip all of the above. // If migration 22 failed but later migrations succeeded, cap at 21 so v22 retries // on next cold start. @@ -2522,7 +2537,7 @@ export class NewsDO extends DurableObject { ); } - const { beat_slug, btc_address, headline, body: signalBody, sources, tags } = body; + const { beat_slug, btc_address, headline, body: signalBody, sources, tags, agent_name } = body; // Validate beat exists and is not retired const beatRows = this.ctx.storage.sql @@ -2669,9 +2684,13 @@ export class NewsDO extends DurableObject { // Insert signal, tags, and streak as individual statements. // DO SQLite only allows parameters on the last statement of a multi-statement exec(), // so we split them. Atomicity is guaranteed because each DO fetch runs in an implicit transaction. + const sanitizedAgentName = typeof agent_name === "string" && agent_name.length > 0 + ? sanitizeString(agent_name, 120) + : null; + this.ctx.storage.sql.exec( - `INSERT INTO signals (id, beat_slug, btc_address, headline, body, sources, created_at, updated_at, correction_of, status, disclosure, quality_score, score_breakdown) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 'submitted', ?, ?, ?)`, + `INSERT INTO signals (id, beat_slug, btc_address, headline, body, sources, created_at, updated_at, correction_of, status, disclosure, agent_name, quality_score, score_breakdown) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 'submitted', ?, ?, ?, ?)`, signalId, beat_slug as string, btc_address as string, @@ -2681,6 +2700,7 @@ export class NewsDO extends DurableObject { nowIso, nowIso, disclosure, + sanitizedAgentName, signalScore.total, JSON.stringify(signalScore.breakdown) ); diff --git a/src/objects/schema.ts b/src/objects/schema.ts index 9bb5cfe..fdda03e 100644 --- a/src/objects/schema.ts +++ b/src/objects/schema.ts @@ -516,7 +516,7 @@ ON CONFLICT(slug) DO UPDATE SET updated_at = datetime('now')`; /** - * MIGRATION_APPROVAL_CAP_INDEX_SQL — adds compound index for daily approval cap (#362). + * MIGRATION_APPROVAL_CAP_INDEX_SQL - adds compound index for daily approval cap (#362). * Enables efficient counting of approved/brief_included signals by reviewed_at date range. */ export const MIGRATION_APPROVAL_CAP_INDEX_SQL = [ @@ -524,6 +524,7 @@ export const MIGRATION_APPROVAL_CAP_INDEX_SQL = [ ] as const; /** + * MIGRATION_BEAT_EDITORS_SQL — beat editor registration table (migration 17). * * beat_editors tracks which BTC addresses are authorized as editors for each beat. @@ -868,3 +869,12 @@ export const MIGRATION_APR7_EARNINGS_SQL = [ 'f96dc58f-7ea5-4932-91e4-9281334b0ea5','27664756-162d-4573-af46-3960f2f0e21f' )`, ] as const; + +/** + * Migration 29 — Agent name on signals (closes #369). + * + * Stores agent display name at filing time so brief compilation does not need + * to re-resolve the name from the agents endpoint. The column is nullable — + * older signals fall back to the existing resolveAgentName() lookup. + */ +export const MIGRATION_AGENT_NAME_SQL = "ALTER TABLE signals ADD COLUMN agent_name TEXT DEFAULT NULL"; diff --git a/src/routes/signals.ts b/src/routes/signals.ts index e73beb3..13991a7 100644 --- a/src/routes/signals.ts +++ b/src/routes/signals.ts @@ -108,22 +108,22 @@ signalsRouter.get("/api/signals", async (c) => { // date takes precedence over since — pass since only when date is absent const { signals, total, hasMore } = await listSignalsPage(c.env, { beat, agent, tag, since: date ? undefined : since, date, status, limit: resolvedLimit, offset: resolvedOffset }); - // Resolve agent display names for all signals in this response - const signalAddresses = [...new Set(signals.map((s) => s.btc_address).filter(Boolean))]; - const nameMap = await resolveNamesWithTimeout( - c.env.NEWS_KV, - signalAddresses, - (p) => c.executionCtx.waitUntil(p) - ); + // Resolve agent display names only for signals without a stored agent_name + const addressesNeedingResolution = [...new Set( + signals.filter((s) => !s.agent_name).map((s) => s.btc_address).filter(Boolean) + )]; + const nameMap = addressesNeedingResolution.length > 0 + ? await resolveNamesWithTimeout(c.env.NEWS_KV, addressesNeedingResolution, (p) => c.executionCtx.waitUntil(p)) + : new Map(); // Transform snake_case → camelCase to match frontend expectations // beat_name is joined from the beats table in the DO query — no separate listBeats() call needed const transformed = signals.map((s) => { - const info = nameMap.get(s.btc_address); + const displayName = s.agent_name ?? nameMap.get(s.btc_address)?.name ?? null; return { id: s.id, btcAddress: s.btc_address, - displayName: info?.name ?? null, + displayName, beat: s.beat_name ?? s.beat_slug, beatSlug: s.beat_slug, headline: s.headline || null, @@ -172,19 +172,22 @@ signalsRouter.get("/api/signals/:id", signalReadRateLimit, async (c) => { return c.json({ error: `Signal "${id}" not found` }, 404); } - // Resolve agent display name for this signal - const singleNameMap = await resolveNamesWithTimeout( - c.env.NEWS_KV, - [s.btc_address], - (p) => c.executionCtx.waitUntil(p) - ); - const sInfo = singleNameMap.get(s.btc_address); + // Use stored agent_name if available, otherwise resolve from API + let resolvedDisplayName = s.agent_name ?? null; + if (!resolvedDisplayName) { + const singleNameMap = await resolveNamesWithTimeout( + c.env.NEWS_KV, + [s.btc_address], + (p) => c.executionCtx.waitUntil(p) + ); + resolvedDisplayName = singleNameMap.get(s.btc_address)?.name ?? null; + } c.header("Cache-Control", "public, max-age=60, s-maxage=300"); const response = c.json({ id: s.id, btcAddress: s.btc_address, - displayName: sInfo?.name ?? null, + displayName: resolvedDisplayName, beat: s.beat_name ?? s.beat_slug, beatSlug: s.beat_slug, headline: s.headline || null, @@ -384,6 +387,7 @@ signalsRouter.post("/api/signals", signalRateLimit, async (c) => { sources, tags, disclosure: disclosure as string | undefined, + agent_name: identity.displayName ?? null, }); if (!result.ok) { diff --git a/src/services/identity-gate.ts b/src/services/identity-gate.ts index c78778e..414fa1b 100644 --- a/src/services/identity-gate.ts +++ b/src/services/identity-gate.ts @@ -21,6 +21,7 @@ export interface IdentityCheckResult { registered: boolean; level: number | null; levelName: string | null; + displayName: string | null; apiReachable: boolean; // true when the caller should block the request (API unreachable after retries) shouldBlock: boolean; @@ -44,7 +45,7 @@ async function fetchIdentity(btcAddress: string): Promise { /** * Checks if a BTC address belongs to a Genesis-level (level >= 2) AIBTC agent. - * Returns { registered, level, levelName, apiReachable, shouldBlock }. + * Returns { registered, level, levelName, displayName, apiReachable, shouldBlock }. * Caches results for 1h to avoid per-request external calls. * * Fail-closed: when the API cannot be reached after one retry, shouldBlock=true @@ -78,6 +79,7 @@ export async function checkAgentIdentity( registered: (data?.found as boolean) === true, level: (data?.level as number | undefined) ?? null, levelName: (data?.levelName as string | undefined) ?? null, + displayName: (data?.displayName as string | undefined) ?? (data?.name as string | undefined) ?? null, apiReachable: true, shouldBlock: false, }; @@ -96,6 +98,7 @@ export async function checkAgentIdentity( registered: false, level: null, levelName: null, + displayName: null, apiReachable: true, shouldBlock: false, }; @@ -117,6 +120,7 @@ export async function checkAgentIdentity( registered: false, level: null, levelName: null, + displayName: null, apiReachable: false, shouldBlock: true, };