From 0e7871ac0e8f075acb2307698efb29fa67fe74b2 Mon Sep 17 00:00:00 2001 From: Kev Date: Sat, 11 Jul 2026 14:31:35 -0400 Subject: [PATCH] =?UTF-8?q?S4=20(a1):=20the=20media=20engine=20=E2=80=94?= =?UTF-8?q?=20VOICE=20templates,=20/desk,=20Ghost=20drafts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4a VOICE v1.1 committed (board start); lint is EXECUTABLE — banned list + no-exclamation law enforced in the engine (throws in test, drops in prod) and locked by tests. Curly-apostrophe variants covered. 4b mediaEngine: deterministic templates (MORNING WIRE, SIGNAL, STREAK WATCH, THE SETTLE, RECEIPTS, ARCHETYPE WATCH, LINE DISPATCH) filled ONLY from snapshot/ledger/streaks JSON. Record percentages never render under n>=20 (counts + 'Record building' below). Stark layer = curated committed library (content/stark-lines.json), day-rotated selection — selected, never generated. 4c /desk (founder-only: requireAuth + DESK_OWNERS email allowlist, deny-by-default): all formats as text + <=280-char pre-segmented tweets with per-tweet copy buttons + char counts, wire/numbers-only variants, DATA BRIEF block (structured day numbers) with copy-for-claude.ai. ntfy ping after the day's first snapshot: 'Desk pack ready'. 4d ghostPublisher: DRAFTS ONLY (status:'draft' test-locked), env-gated no-op, HS256 JWT via node crypto (zero new deps). POST /api/internal/ghost/drafts saves slate preview + settle drafts. Nothing anywhere auto-posts. Co-Authored-By: Claude Fable 5 --- content/stark-lines.json | 34 ++++ src/app.js | 2 + src/routes/desk.js | 41 +++++ src/routes/internal.js | 19 +++ src/services/deskService.js | 126 ++++++++++++++ src/services/ghostPublisher.js | 77 +++++++++ src/services/mediaEngine.js | 258 +++++++++++++++++++++++++++++ src/snapshotScheduler.js | 6 + tests/unit/mediaEngine.test.js | 179 ++++++++++++++++++++ web/src/app/api/desk/pack/route.ts | 21 +++ web/src/app/desk/page.tsx | 148 +++++++++++++++++ web/src/lib/routes.js | 1 + 12 files changed, 912 insertions(+) create mode 100644 content/stark-lines.json create mode 100644 src/routes/desk.js create mode 100644 src/services/deskService.js create mode 100644 src/services/ghostPublisher.js create mode 100644 src/services/mediaEngine.js create mode 100644 tests/unit/mediaEngine.test.js create mode 100644 web/src/app/api/desk/pack/route.ts create mode 100644 web/src/app/desk/page.tsx diff --git a/content/stark-lines.json b/content/stark-lines.json new file mode 100644 index 0000000..95e1fdf --- /dev/null +++ b/content/stark-lines.json @@ -0,0 +1,34 @@ +{ + "_comment": "THE STARK LAYER library (VOICE v1.1). Curated by Kev + Claude, committed like code. Templates SELECT from these — they never generate wit. One line per post max, only beside a number. No exclamation points.", + "morning": [ + "The model collects debts.", + "The slate doesn't know we're watching. It will.", + "Quants get floors. We got a terminal.", + "The board posted. So did we." + ], + "settle_winning": [ + "The Ledger doesn't gloat. It just doesn't delete.", + "Same format tomorrow, whatever it says.", + "The record talks. We just post it." + ], + "settle_losing": [ + "It's on the Ledger like everything else.", + "Same format as the wins. That's the point.", + "The model logs its misses. The industry deletes theirs." + ], + "steam": [ + "Someone's watching. Good.", + "The market chased the read.", + "Three minutes after the wire posted. Noted." + ], + "streak": [ + "Most hot streaks are soft schedules wearing a costume.", + "The lens checks what the streak was built on. Calendars lie.", + "A streak is a claim. The matchup is the cross-examination." + ], + "identity": [ + "One terminal, built in Detroit.", + "Garage-built. Out-reading the tower.", + "The books employ floors full of quants. Seems fair." + ] +} diff --git a/src/app.js b/src/app.js index 5d0f905..ba4f9a0 100644 --- a/src/app.js +++ b/src/app.js @@ -132,6 +132,8 @@ app.use('/api/props', propsRoutes); app.use('/api/players', require('./routes/players')); // Session 60 (night2/F) — per-user utility reads (Settings scan meter). app.use('/api/user', require('./routes/user')); +// Session 63 (A1-S4) — the founder's media desk (email-allowlisted). +app.use('/api/desk', require('./routes/desk')); app.use('/api/waitlist', waitlistRoutes); app.use('/api/pipeline', pipelineRoutes); app.use('/api/share-card', shareCardRoutes); diff --git a/src/routes/desk.js b/src/routes/desk.js new file mode 100644 index 0000000..1fcac8b --- /dev/null +++ b/src/routes/desk.js @@ -0,0 +1,41 @@ +'use strict'; + +/** + * /api/desk (Session 63 / A1-S4) — Kev's copy-paste arsenal. + * + * GET /pack — the assembled daily media pack + DATA BRIEF. + * Auth: requireAuth + email allowlist (env DESK_OWNERS, comma-separated). + * Unset allowlist → 403 for everyone (deny by default; the desk is the + * founder's surface, not a tier feature). + */ + +const express = require('express'); +const { requireAuth } = require('../middleware/auth'); +const { createRateLimit } = require('../middleware/rateLimit'); +const { assembleDeskPack } = require('../services/deskService'); + +const router = express.Router(); +router.use(createRateLimit({ windowMs: 60_000, max: 20 })); + +function isOwner(req) { + const owners = String(process.env.DESK_OWNERS || '') + .split(',').map((s) => s.trim().toLowerCase()).filter(Boolean); + const email = String(req.user?.email || '').toLowerCase(); + return owners.length > 0 && email && owners.includes(email); +} + +router.get('/pack', requireAuth, async (req, res) => { + if (!isOwner(req)) { + return res.status(403).json({ error: 'The desk is the publisher’s surface. Set DESK_OWNERS to grant access.' }); + } + try { + const pack = await assembleDeskPack(); + res.set('Cache-Control', 'private, max-age=60'); + return res.json(pack); + } catch (err) { + console.error('[desk/pack]', err.message); + return res.status(200).json({ generated_at: null, formats: {}, data_brief: null, error: 'assembly failed' }); + } +}); + +module.exports = router; diff --git a/src/routes/internal.js b/src/routes/internal.js index e83ce59..7f70cf3 100644 --- a/src/routes/internal.js +++ b/src/routes/internal.js @@ -180,6 +180,25 @@ router.post('/outcomes/all', async (req, res) => { } }); +/** + * POST /api/internal/ghost/drafts (Session 63 / A1-S4d) — assemble today's + * desk pack and save the slate-preview + settle drafts to Ghost. DRAFTS + * ONLY; Kev publishes from the Ghost admin. Env-gated no-op without Ghost. + */ +router.post('/ghost/drafts', async (req, res) => { + try { + const { assembleDeskPack } = require('../services/deskService'); + const ghost = require('../services/ghostPublisher'); + const pack = await assembleDeskPack(); + const result = await ghost.saveDailyDrafts(pack); + return res.json({ ok: result.ok, result }); + } catch (err) { + const message = err && err.message ? err.message : String(err); + console.error('[internal/ghost/drafts] failed:', message); + return res.status(500).json({ ok: false, error: message }); + } +}); + /** * POST /api/internal/refresh/all (Session 60, Phase 2.5) — manual intraday * odds-only refresh: STEAM/VALUE movement, public revisions, closing diff --git a/src/services/deskService.js b/src/services/deskService.js new file mode 100644 index 0000000..571bb49 --- /dev/null +++ b/src/services/deskService.js @@ -0,0 +1,126 @@ +'use strict'; + +/** + * deskService — assembles Kev's daily copy-paste arsenal (Session 63 / A1-S4). + * + * Pulls the day's REAL pipeline data (snapshots, streaks+lens, ledger + * aggregate + settled rows) and fills the VOICE v1.1 templates. Output is + * the /desk pack: every format as text + pre-segmented tweets (≤280) + + * 2 copy variants (with/without the Stark line) + the DATA BRIEF (structured + * numbers Kev pastes into claude.ai for freeform writing). + * + * NOTHING auto-posts. This service only assembles. + */ + +const media = require('./mediaEngine'); + +const SPORTS = ['mlb', 'wnba', 'nba', 'soccer']; + +function variants(text) { + if (!text) return []; + // Variant B: the same post without its Stark line (pure numbers). The + // Stark lines are the only non-data lines, all sourced from the library. + const starkSet = new Set(Object.values(media.__internals.STARK).flat()); + const stripped = text.split('\n').filter((l) => !starkSet.has(l)).join('\n'); + const out = [{ label: 'wire', text, tweets: media.segmentThread(text) }]; + if (stripped !== text) out.push({ label: 'numbers only', text: stripped, tweets: media.segmentThread(stripped) }); + return out; +} + +/** + * Assemble the pack. deps injectable: cacheGet, loadRosterLogs, + * computeStreaks/computeFormHeat/applyLens, ledger (getModelAggregate + sb + * reads), now. + */ +async function assembleDeskPack(opts = {}) { + const deps = { + cacheGet: opts.cacheGet || require('../utils/redis').cacheGet, + loadRosterLogs: opts.loadRosterLogs || require('./rosterLogs').loadRosterLogs, + streaks: opts.streaks || require('./streaksService'), + lens: opts.lens || require('./streakLens'), + ledger: opts.ledger || require('./ledgerService'), + now: opts.now || (() => new Date().toISOString()), + }; + const nowIso = deps.now(); + + // 1. Snapshots → counts, loudest signal, signals, archetype watch, dispatches. + const counts = {}; + let allGrades = []; + for (const sp of SPORTS) { + const snap = await deps.cacheGet(`snapshot:${sp}:latest`); + const grades = snap && Array.isArray(snap.grades) ? snap.grades : []; + counts[sp] = grades.length; + allGrades = allGrades.concat(grades.map((g) => ({ ...g, sport: sp }))); + } + const graded = allGrades.filter((g) => g.grade && !g.outcome); + graded.sort((a, b) => (Number(b.confidence) || 0) - (Number(a.confidence) || 0)); + const loudest = graded[0] || null; + + // 2. Streaks through the lens (MLB primary; extendable). + let streakRows = []; + try { + const roster = await deps.loadRosterLogs('mlb'); + const rows = [ + ...deps.streaks.computeStreaks(roster, 'mlb', { limit: 6 }), + ...deps.streaks.computeFormHeat(roster, 'mlb', { limit: 3 }), + ]; + const sched = await deps.cacheGet(`schedule:mlb:${nowIso.slice(0, 10)}`); + streakRows = deps.lens.applyLens(rows, { scheduleGames: Array.isArray(sched) ? sched : [], pitcherGames: [] }); + } catch { /* streak section degrades to absent */ } + + // 3. Ledger — aggregate + yesterday's settled rows for the Settle/Receipts. + const aggregate = await deps.ledger.getModelAggregate({}); + let settledRows = []; + try { + if (opts.fetchSettled) settledRows = await opts.fetchSettled(); + else { + const { getSupabaseServiceClient } = require('../utils/supabase'); + if (deps.ledger.__internals.isConfigured()) { + const sb = getSupabaseServiceClient(); + const { data } = await sb.from('ledger_entries') + .select('player_name, stat, line, side, grade, graded_at, locked_odds, closing_line, closing_odds, clv, clv_result, outcome, actual_value, settled_at') + .is('user_id', null).not('outcome', 'is', null) + .order('settled_at', { ascending: false }).limit(30); + settledRows = data || []; + } + } + } catch { /* receipts degrade to absent */ } + const misses = settledRows.filter((r) => r.outcome === 'miss'); + + // 4. Fill the formats. Absent data → absent format, never filler. + const dispatches = graded + .filter((g) => g.movement && (g.movement.kind === 'steam' || g.movement.kind === 'value')) + .slice(0, 4) + .map((g) => media.lineDispatch(g, nowIso)) + .filter(Boolean); + + const pack = { + generated_at: nowIso, + formats: { + morning_wire: variants(media.morningWire({ dateIso: nowIso, counts, loudest })), + signals: graded.slice(0, 3).map((g) => variants(media.signal(g))).filter((v) => v.length), + streak_watch: variants(media.streakWatch(streakRows, nowIso)), + settle: variants(media.theSettle({ aggregate, misses, dateIso: nowIso })), + receipts: settledRows.slice(0, 3).map((r) => variants(media.receipt(r))).filter((v) => v.length), + archetype_watch: variants(media.archetypeWatch(graded, nowIso)), + line_dispatches: dispatches.map((d) => variants(d)), + }, + // 5. DATA BRIEF — the day's structured numbers for freeform writing. + data_brief: { + date: nowIso, + slate_counts: counts, + top_signals: graded.slice(0, 8).map((g) => ({ + player: g.player || g.player_name, sport: g.sport, stat: g.stat_type || g.stat, + line: g.line, side: g.direction, grade: g.grade, confidence: g.confidence, + archetype: g.archetype || null, odds: (g.gradedAt && g.gradedAt.odds) || null, + movement: g.movement || null, + })), + streaks: streakRows.slice(0, 8).map((r) => ({ player: r.player, team: r.team, read: r.lens && r.lens.read })), + record: aggregate, + settled_today: settledRows.slice(0, 20), + }, + }; + return pack; +} + +module.exports = { assembleDeskPack, __internals: { variants, SPORTS } }; diff --git a/src/services/ghostPublisher.js b/src/services/ghostPublisher.js new file mode 100644 index 0000000..7efe784 --- /dev/null +++ b/src/services/ghostPublisher.js @@ -0,0 +1,77 @@ +'use strict'; + +/** + * ghostPublisher (Session 63 / A1-S4d) — saves DRAFTS to the self-hosted + * Ghost instance. DRAFTS ONLY — nothing anywhere auto-posts; Kev publishes. + * + * Zero new dependencies: the Ghost Admin API JWT is HS256 built with node + * crypto. Env-gated (GHOST_URL + GHOST_ADMIN_API_KEY 'id:secret') — without + * env every call is a documented no-op. + */ + +const crypto = require('crypto'); + +const b64url = (buf) => Buffer.from(buf).toString('base64url'); + +/** Ghost Admin API JWT: HS256, kid = key id, aud '/admin/', 5-min expiry. */ +function ghostJwt(adminKey, nowSec = Math.floor(Date.now() / 1000)) { + const [id, secret] = String(adminKey || '').split(':'); + if (!id || !secret) return null; + const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT', kid: id })); + const payload = b64url(JSON.stringify({ iat: nowSec, exp: nowSec + 300, aud: '/admin/' })); + const sig = crypto.createHmac('sha256', Buffer.from(secret, 'hex')) + .update(`${header}.${payload}`).digest('base64url'); + return `${header}.${payload}.${sig}`; +} + +function isConfigured() { + return Boolean(process.env.GHOST_URL && process.env.GHOST_ADMIN_API_KEY); +} + +/** Save one draft. Returns { ok, id? } — never throws. */ +async function saveDraft({ title, html, tags = [] }, opts = {}) { + if (!opts.force && !isConfigured()) return { ok: false, reason: 'ghost not configured' }; + const fetchImpl = opts.fetchImpl || fetch; + const base = (opts.url || process.env.GHOST_URL || '').replace(/\/$/, ''); + const token = ghostJwt(opts.adminKey || process.env.GHOST_ADMIN_API_KEY, opts.nowSec); + if (!token) return { ok: false, reason: 'bad admin key' }; + try { + const res = await fetchImpl(`${base}/ghost/api/admin/posts/?source=html`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Ghost ${token}` }, + body: JSON.stringify({ posts: [{ title, html, status: 'draft', tags }] }), + }); + if (!res.ok) return { ok: false, reason: `ghost ${res.status}` }; + const data = await res.json().catch(() => ({})); + return { ok: true, id: data.posts && data.posts[0] && data.posts[0].id }; + } catch (e) { + return { ok: false, reason: e.message }; + } +} + +/** Daily slate preview + weekly record review drafts from the desk pack. */ +async function saveDailyDrafts(pack, opts = {}) { + if (!pack || !pack.formats) return { ok: false, reason: 'no pack' }; + const results = []; + const wire = pack.formats.morning_wire && pack.formats.morning_wire[0]; + if (wire) { + results.push(await saveDraft({ + title: `The Slate — ${new Date(pack.generated_at).toDateString()}`, + html: `
${escapeHtml(wire.text)}
`, + tags: ['slate'], + }, opts)); + } + const settle = pack.formats.settle && pack.formats.settle[0]; + if (settle) { + results.push(await saveDraft({ + title: `The Settle — ${new Date(pack.generated_at).toDateString()}`, + html: `
${escapeHtml(settle.text)}
`, + tags: ['settle'], + }, opts)); + } + return { ok: results.every((r) => r.ok), results }; +} + +const escapeHtml = (s) => String(s).replace(/&/g, '&').replace(//g, '>'); + +module.exports = { saveDraft, saveDailyDrafts, ghostJwt, isConfigured }; diff --git a/src/services/mediaEngine.js b/src/services/mediaEngine.js new file mode 100644 index 0000000..68c409c --- /dev/null +++ b/src/services/mediaEngine.js @@ -0,0 +1,258 @@ +'use strict'; + +/** + * mediaEngine — VOICE v1.1 template engine (Session 63 / A1-S4). + * + * Deterministic templates for the wire formats (MORNING WIRE, SIGNAL, + * STREAK WATCH, THE SETTLE, RECEIPTS, ARCHETYPE WATCH, LINE DISPATCH), + * filled ONLY from pipeline JSON (snapshot/ledger/streaks). Templates never + * compose numbers — the Data Semantics Rule extends to marketing. Record + * claims obey the product's n≥20 gate. Stark-layer lines are SELECTED from + * the curated committed library (content/stark-lines.json), never generated. + * + * Every emitted string passes lintVoice() — the banned list + the + * no-exclamation law are enforced at build time by tests and at runtime by + * assembly (a template that fails lint throws in dev, drops the line in prod). + * + * NOTHING here posts anywhere. Output goes to /desk for Kev to publish. + */ + +const fs = require('fs'); +const path = require('path'); + +const STARK = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'content', 'stark-lines.json'), 'utf8')); + +// ---- VOICE lint ----------------------------------------------------------- +const BANNED_PATTERNS = [ + /!/, // no exclamation points, ever + /\block(s|ed)?\b/i, /🔒/, + /🔥/, /💰/, /🚀/, /💯/, + /we['’]?re so back/i, /it['’]?s so over/i, + /\btail\b/i, /\bfade\b/i, // as calls to action — banned outright + /can['’]?t lose/i, /free money/i, /guarantee/i, + /RT if/i, /who['’]?s tailing/i, /\btailing\b/i, + /\beating\b/i, /\bcooking\b/i, /\bGOAT\b/, +]; + +/** Returns [] when clean, else the matched patterns (as strings). */ +function lintVoice(text) { + const hits = []; + for (const re of BANNED_PATTERNS) { + if (re.test(String(text || ''))) hits.push(re.source); + } + return hits; +} + +function assertVoice(text, context) { + const hits = lintVoice(text); + if (hits.length > 0) { + const msg = `[mediaEngine] VOICE lint failed (${context}): ${hits.join(', ')}`; + if (process.env.NODE_ENV === 'test') throw new Error(msg); + console.warn(msg); + return false; + } + return true; +} + +// ---- helpers --------------------------------------------------------------- +const sideCh = (dir) => (String(dir || 'over').toLowerCase() === 'under' ? 'u' : 'o'); + +function etTime(iso, withDay = false) { + const d = iso ? new Date(iso) : new Date(); + if (Number.isNaN(d.getTime())) return ''; + const opts = withDay + ? { timeZone: 'America/New_York', weekday: 'short', month: 'short', day: 'numeric' } + : { timeZone: 'America/New_York', hour: 'numeric', minute: '2-digit' }; + return new Intl.DateTimeFormat('en-US', opts).format(d); +} + +/** Deterministic stark selection: day-of-year rotation, never random + * (Data Semantics: reproducible output for the same inputs + date). */ +function starkLine(category, dateIso) { + const lines = STARK[category] || []; + if (lines.length === 0) return null; + const d = dateIso ? new Date(dateIso) : new Date(); + const doy = Math.floor((d.getTime() - Date.UTC(d.getUTCFullYear(), 0, 0)) / 86_400_000); + return lines[doy % lines.length]; +} + +/** Segment a post into ≤280-char tweets on line boundaries. */ +function segmentThread(text, limit = 280) { + const lines = String(text || '').split('\n'); + const tweets = []; + let cur = ''; + for (const line of lines) { + const candidate = cur ? `${cur}\n${line}` : line; + if (candidate.length > limit && cur) { + tweets.push(cur); + cur = line; + } else { + cur = candidate; + } + } + if (cur.trim()) tweets.push(cur); + return tweets; +} + +// ---- formats --------------------------------------------------------------- + +/** THE MORNING WIRE. data = { dateIso, counts: {mlb, wnba, ...}, loudest: grade row|null } */ +function morningWire(data = {}) { + const parts = Object.entries(data.counts || {}) + .filter(([, n]) => n > 0) + .map(([sp, n]) => `${n} ${sp.toUpperCase()}.`); + const lines = [`THE WIRE — ${etTime(data.dateIso, true)}`]; + if (parts.length > 0) lines.push(parts.join(' ')); + if (data.loudest && data.loudest.grade) { + const l = data.loudest; + const arch = l.archetype ? `a ${l.archetype} read` : `an ${l.grade} read`; + lines.push(`Loudest signal: ${arch} — ${l.player} ${l.stat_type || l.stat} ${sideCh(l.direction)}${l.line} · ${l.grade}.`); + } + const stark = starkLine('morning', data.dateIso); + if (stark) lines.push(stark); + lines.push('Slate’s graded. 3 free reads a day.', 'vyndr.app'); + const text = lines.join('\n'); + assertVoice(text, 'morningWire'); + return text; +} + +/** SIGNAL. g = one graded snapshot row (must carry gradedAt). */ +function signal(g = {}) { + if (!g.player || !g.grade) return null; + const lines = [ + `${g.player} — ${humanStat(g.stat_type || g.stat)} ${sideCh(g.direction)}${g.line} · ${g.grade}`, + ]; + if (g.archetype) lines.push(`${g.archetype} read.`); + const odds = g.gradedAt && g.gradedAt.odds != null ? ` at ${g.gradedAt.odds}` : ''; + const ts = g.gradedAt && g.gradedAt.timestamp ? `Graded ${etTime(g.gradedAt.timestamp)} ET${odds}.` : null; + if (ts) lines.push(ts); + const text = lines.join('\n'); + assertVoice(text, 'signal'); + return text; +} + +/** STREAK WATCH. rows = lens-applied streak rows. */ +function streakWatch(rows = [], dateIso) { + const real = rows.filter((r) => r && r.lens && r.lens.read).slice(0, 4); + if (real.length === 0) return null; + const lines = [`STREAK WATCH — ${etTime(dateIso, true)}`]; + for (const r of real) lines.push(`${r.player}: ${r.lens.read}.`); + const stark = starkLine('streak', dateIso); + if (stark) lines.push(stark); + lines.push('vyndr.app/explore'); + const text = lines.join('\n'); + assertVoice(text, 'streakWatch'); + return text; +} + +/** + * THE SETTLE. agg = ledger model aggregate; misses = settled miss rows. + * Record percentages ONLY at n≥20 (the aggregate already nulls them) — + * below that, counts only, honestly labeled. + */ +function theSettle({ aggregate, misses = [], dateIso } = {}) { + const a = aggregate || {}; + const lines = [`THE SETTLE — ${etTime(dateIso, true)}`]; + const tierA = a.by_tier && a.by_tier['A']; + if (tierA && tierA.settled > 0) lines.push(`A-tier: ${tierA.hits}-${tierA.misses}`); + if (a.settled > 0) lines.push(`All grades: ${a.hits}-${a.misses}${a.pushes ? ` (${a.pushes} push)` : ''}`); + else lines.push('Nothing settled today.'); + if (a.hit_pct == null && a.settled > 0) { + lines.push(`Record building — ${a.settled} settled, ${a.pending || 0} pending. Percentages post at 20.`); + } + const missRows = misses.filter((m) => m && m.player_name).slice(0, 6); + if (missRows.length > 0) { + lines.push(`Misses, by name: ${missRows.map((m) => `${m.player_name} ${humanStat(m.stat)} ${sideCh(m.side)}${m.line} ❌`).join(' · ')}`); + } + const stark = starkLine(missRows.length > 0 ? 'settle_losing' : 'settle_winning', dateIso); + if (stark) lines.push(stark); + lines.push('vyndr.app/ledger'); + const text = lines.join('\n'); + assertVoice(text, 'theSettle'); + return text; +} + +/** RECEIPTS. row = settled ledger row with lock + close + outcome. */ +function receipt(row = {}) { + if (!row.player_name || !row.outcome) return null; + const s = sideCh(row.side); + const lines = [ + `Posted ${etTime(row.graded_at)} ET: ${row.player_name} ${humanStat(row.stat)} ${s}${row.line} · ${row.grade}${row.locked_odds ? ` · ${row.locked_odds}` : ''}`, + ]; + if (row.closing_line != null && row.clv_result) { + const clvNote = row.clv_result === 'beat' ? 'Market chased the read.' + : row.clv_result === 'faded' ? 'Market went the other way.' : 'Close held.'; + lines.push(`Close: ${s}${row.closing_line}${row.closing_odds ? ` at ${row.closing_odds}` : ''}. ${clvNote}`); + } + const mark = row.outcome === 'hit' ? '✅' : row.outcome === 'miss' ? '❌' : 'push'; + lines.push(`Result: ${row.actual_value != null ? `${row.actual_value} ` : ''}${mark}`); + if (row.clv_result === 'beat' && row.outcome === 'hit') { + const stark = starkLine('steam', row.settled_at); + if (stark) lines.push(stark); + } + const text = lines.join('\n'); + assertVoice(text, 'receipt'); + return text; +} + +/** ARCHETYPE WATCH. grades = snapshot rows. */ +function archetypeWatch(grades = [], dateIso) { + const byArch = {}; + for (const g of grades) { + if (!g.archetype || !g.grade) continue; + byArch[g.archetype] = byArch[g.archetype] || []; + byArch[g.archetype].push(g); + } + const top = Object.entries(byArch).sort((a, b) => b[1].length - a[1].length)[0]; + if (!top || top[1].length < 2) return null; + const [arch, rows] = top; + const lines = [ + `${arch} watch: ${rows.length} on tonight’s slate.`, + ...rows.slice(0, 4).map((g) => `${g.player} ${humanStat(g.stat_type || g.stat)} ${sideCh(g.direction)}${g.line} · ${g.grade}`), + 'vyndr.app', + ]; + const text = lines.join('\n'); + assertVoice(text, 'archetypeWatch'); + return text; +} + +/** LINE DISPATCH. move = intraday movement row on a graded prop. */ +function lineDispatch(g = {}, nowIso) { + const m = g.movement; + if (!m || m.delta == null) return null; + const s = sideCh(g.direction); + const locked = g.gradedAt && g.gradedAt.line != null ? g.gradedAt.line : g.line; + const arrow = m.currentLine > locked ? '▲' : '▼'; + const kind = m.kind === 'steam' ? 'Steam.' : m.kind === 'value' ? 'Value — the read holds at the new number.' : 'Line moved.'; + const lines = [ + `${g.player} ${humanStat(g.stat_type || g.stat)}: ${s}${locked} → ${s}${m.currentLine} ${arrow}`, + `${kind} ${etTime(nowIso)} ET.`, + ]; + const text = lines.join('\n'); + assertVoice(text, 'lineDispatch'); + return text; +} + +const STAT_HUMAN = { + total_bases: 'TB', home_runs: 'HR', hits: 'Hits', rbi: 'RBI', runs: 'Runs', + strikeouts: 'Ks', stolen_bases: 'SB', earned_runs: 'ER', innings_pitched: 'IP', + points: 'PTS', rebounds: 'REB', assists: 'AST', threes: '3PT', outs: 'Outs', doubles: '2B', +}; +function humanStat(stat) { + const k = String(stat || '').toLowerCase(); + return STAT_HUMAN[k] || k.replace(/_/g, ' '); +} + +module.exports = { + lintVoice, + segmentThread, + starkLine, + morningWire, + signal, + streakWatch, + theSettle, + receipt, + archetypeWatch, + lineDispatch, + __internals: { BANNED_PATTERNS, etTime, humanStat, assertVoice, STARK }, +}; diff --git a/src/snapshotScheduler.js b/src/snapshotScheduler.js index f894123..e726310 100644 --- a/src/snapshotScheduler.js +++ b/src/snapshotScheduler.js @@ -110,6 +110,12 @@ function startSnapshotScheduler(opts = {}) { const results = await runAll(); const ok = results.filter((r) => r.status === 'ok'); console.log(`[snapshot] cron fired ${h}:00 UTC — ${ok.length}/${results.length} sports graded`); + // Session 63 (A1-S4) — after the day's FIRST slot, tell Kev the media + // pack is ready. One ping, only when something actually graded. + if (h === HOURS_UTC[0] && ok.length > 0) { + const total = results.reduce((n, r) => n + (r.gradeCount || 0), 0); + await notify(`Desk pack ready — ${total} props graded across ${ok.length} sports. vyndr.app/desk`, { title: 'VYNDR desk', tags: ['newspaper'] }); + } } catch (e) { console.warn('[snapshot] cron run failed:', e.message); } diff --git a/tests/unit/mediaEngine.test.js b/tests/unit/mediaEngine.test.js new file mode 100644 index 0000000..ee210fd --- /dev/null +++ b/tests/unit/mediaEngine.test.js @@ -0,0 +1,179 @@ +// Session 63 (A1-S4) — the VOICE v1.1 template engine. Numbers only from +// pipeline JSON; lint enforces the banned list + no-exclamation law; record +// claims obey n≥20; Stark lines are SELECTED from the committed library. + +const media = require('../../src/services/mediaEngine'); +const { lintVoice, segmentThread, starkLine, morningWire, signal, theSettle, receipt, streakWatch, archetypeWatch, lineDispatch } = media; + +describe('lintVoice — the banned list is law', () => { + test.each([ + ['This one is a lock!', 2], // "lock" + exclamation + ['🔥 free money, who’s tailing', 3], + ['We’re so back 🚀', 2], + ])('%s → %i violations', (text, n) => { + expect(lintVoice(text).length).toBeGreaterThanOrEqual(n); + }); + test('clean wire copy passes', () => { + expect(lintVoice('THE SETTLE — Jul 11\nA-tier: 6-2\nvyndr.app/ledger')).toEqual([]); + }); +}); + +describe('every template output passes its own lint', () => { + const NOW = '2026-07-12T14:05:00.000Z'; + const GRADE = { + player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, direction: 'over', + grade: 'A', confidence: 78, archetype: 'BOMBER', + gradedAt: { line: 1.5, odds: -120, timestamp: NOW }, + }; + + test('morning wire: real counts, stark from the library, no bans', () => { + const text = morningWire({ dateIso: NOW, counts: { mlb: 15, wnba: 3, nba: 0 }, loudest: GRADE }); + expect(text).toContain('15 MLB.'); + expect(text).toContain('3 WNBA.'); + expect(text).not.toContain('0 NBA'); // zero sports never listed + expect(text).toContain('Aaron Judge'); + expect(text).toContain('vyndr.app'); + expect(lintVoice(text)).toEqual([]); + }); + + test('signal: player + line + grade + ET timestamp with real odds', () => { + const text = signal(GRADE); + expect(text).toContain('Aaron Judge — TB o1.5 · A'); + expect(text).toContain('BOMBER read.'); + expect(text).toContain('at -120'); + expect(lintVoice(text)).toEqual([]); + }); + + test('the settle: counts always, percentages NEVER under n≥20', () => { + const agg = { settled: 8, hits: 5, misses: 3, pushes: 0, hit_pct: null, pending: 16, by_tier: { A: { settled: 4, hits: 3, misses: 1, hit_pct: null } } }; + const misses = [{ player_name: 'A’ja Wilson', stat: 'points', line: 23.5, side: 'over' }]; + const text = theSettle({ aggregate: agg, misses, dateIso: NOW }); + expect(text).toContain('All grades: 5-3'); + expect(text).toContain('A-tier: 3-1'); + expect(text).not.toContain('%'); // no percentage below the gate + expect(text).toContain('Record building — 8 settled'); + expect(text).toContain('Wilson PTS o23.5 ❌'); // misses BY NAME + expect(lintVoice(text)).toEqual([]); + }); + + test('receipt: lock beside close beside result', () => { + const row = { + player_name: 'Aaron Judge', stat: 'total_bases', line: 1.5, side: 'over', grade: 'A', + graded_at: NOW, locked_odds: '-120', closing_line: 1.5, closing_odds: '-145', + clv: 0, clv_result: 'flat', outcome: 'hit', actual_value: 2, settled_at: NOW, + }; + const text = receipt(row); + expect(text).toContain('Posted'); + expect(text).toContain('Judge TB o1.5 · A · -120'); + expect(text).toContain('Close: o1.5 at -145'); + expect(text).toContain('Result: 2 ✅'); + expect(lintVoice(text)).toEqual([]); + }); + + test('streak watch: lens reads only, absent lens rows dropped', () => { + const rows = [ + { player: 'Brice Turang', lens: { read: '12-game on-base streak; tonight @ Pittsburgh Pirates' } }, + { player: 'No Lens Guy', lens: { read: null } }, + ]; + const text = streakWatch(rows, NOW); + expect(text).toContain('Turang'); + expect(text).not.toContain('No Lens Guy'); + expect(lintVoice(text)).toEqual([]); + }); + + test('archetype watch needs ≥2 of a kind, else null', () => { + expect(archetypeWatch([GRADE], NOW)).toBeNull(); + const two = archetypeWatch([GRADE, { ...GRADE, player: 'Shohei Ohtani' }], NOW); + expect(two).toContain('BOMBER watch: 2'); + expect(lintVoice(two)).toEqual([]); + }); + + test('line dispatch from a real movement row', () => { + const text = lineDispatch({ ...GRADE, movement: { kind: 'steam', delta: 1, currentLine: 2.5 } }, NOW); + expect(text).toContain('o1.5 → o2.5 ▲'); + expect(text).toContain('Steam.'); + expect(lintVoice(text)).toEqual([]); + }); +}); + +describe('stark layer — selected, never generated; deterministic', () => { + test('same date → same line; comes from the committed library', () => { + const a = starkLine('morning', '2026-07-12T10:00:00Z'); + const b = starkLine('morning', '2026-07-12T22:00:00Z'); + expect(a).toBe(b); + expect(media.__internals.STARK.morning).toContain(a); + }); + test('the whole library passes lint', () => { + for (const lines of Object.values(media.__internals.STARK)) { + if (!Array.isArray(lines)) continue; + for (const l of lines) expect(lintVoice(l)).toEqual([]); + } + }); +}); + +describe('segmentThread — ≤280 per tweet on line boundaries', () => { + test('splits long threads and preserves every line', () => { + const long = Array.from({ length: 30 }, (_, i) => `Line ${i} of the settle with some padding text`).join('\n'); + const tweets = segmentThread(long); + expect(tweets.length).toBeGreaterThan(1); + for (const t of tweets) expect(t.length).toBeLessThanOrEqual(280); + expect(tweets.join('\n')).toBe(long); + }); +}); + +describe('deskService — pack assembly from pipeline data only', () => { + const { assembleDeskPack } = require('../../src/services/deskService'); + test('assembles formats + data brief from injected caches', async () => { + const NOW = '2026-07-12T14:05:00.000Z'; + const store = { + 'snapshot:mlb:latest': { grades: [ + { player: 'Aaron Judge', stat_type: 'total_bases', line: 1.5, direction: 'over', grade: 'A', confidence: 80, archetype: 'BOMBER', gradedAt: { line: 1.5, odds: -120, timestamp: NOW } }, + { player: 'Shohei Ohtani', stat_type: 'home_runs', line: 0.5, direction: 'over', grade: 'A', confidence: 76, archetype: 'BOMBER', gradedAt: { line: 0.5, odds: 130, timestamp: NOW } }, + ] }, + }; + const pack = await assembleDeskPack({ + cacheGet: async (k) => store[k] ?? null, + loadRosterLogs: async () => [], + ledger: { getModelAggregate: async () => ({ settled: 0, hits: 0, misses: 0, pushes: 0, hit_pct: null, pending: 24 }), __internals: { isConfigured: () => false } }, + fetchSettled: async () => [], + now: () => NOW, + }); + expect(pack.formats.morning_wire[0].text).toContain('2 MLB.'); + expect(pack.formats.signals.length).toBe(2); + expect(pack.formats.archetype_watch[0].text).toContain('BOMBER watch: 2'); + // variants: wire + numbers-only when a stark line was present + expect(pack.formats.morning_wire.length).toBeGreaterThanOrEqual(1); + expect(pack.data_brief.top_signals[0].player).toBe('Aaron Judge'); + expect(pack.data_brief.record.pending).toBe(24); + // every emitted text passes lint + const all = JSON.stringify(pack.formats); + expect(all).not.toContain('!'); + }); +}); + +describe('ghostPublisher — drafts only, env-gated, no deps', () => { + const ghost = require('../../src/services/ghostPublisher'); + test('JWT is a valid HS256 three-parter with the key id', () => { + const t = ghost.ghostJwt('abc123:6465616462656566', 1_752_000_000); + const [h, p] = t.split('.'); + const header = JSON.parse(Buffer.from(h, 'base64url').toString()); + const payload = JSON.parse(Buffer.from(p, 'base64url').toString()); + expect(header.kid).toBe('abc123'); + expect(payload.aud).toBe('/admin/'); + expect(payload.exp - payload.iat).toBe(300); + }); + test('unconfigured → no-op, never a throw', async () => { + const r = await ghost.saveDraft({ title: 't', html: '

x

' }); + expect(r.ok).toBe(false); + expect(r.reason).toBe('ghost not configured'); + }); + test('posts DRAFT status only', async () => { + let body = null; + const r = await ghost.saveDraft({ title: 't', html: '

x

' }, { + force: true, url: 'https://blog.test', adminKey: 'id:6162', + fetchImpl: async (url, opts) => { body = JSON.parse(opts.body); return { ok: true, json: async () => ({ posts: [{ id: 'p1' }] }) }; }, + }); + expect(r.ok).toBe(true); + expect(body.posts[0].status).toBe('draft'); // NOTHING auto-publishes + }); +}); diff --git a/web/src/app/api/desk/pack/route.ts b/web/src/app/api/desk/pack/route.ts new file mode 100644 index 0000000..47cd01c --- /dev/null +++ b/web/src/app/api/desk/pack/route.ts @@ -0,0 +1,21 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:3000'; + +/** Desk pack proxy (Session 63 / A1-S4) — forwards the founder's auth. */ +export async function GET(req: NextRequest) { + const auth = req.headers.get('authorization'); + if (!auth) return NextResponse.json({ error: 'auth required' }, { status: 401 }); + try { + const upstream = await fetch(`${BACKEND_URL}/api/desk/pack`, { + headers: { Accept: 'application/json', Authorization: auth }, + cache: 'no-store', + }); + const data = await upstream.json().catch(() => ({})); + return NextResponse.json(data, { status: upstream.status }); + } catch { + return NextResponse.json({ error: 'desk unavailable' }, { status: 200 }); + } +} diff --git a/web/src/app/desk/page.tsx b/web/src/app/desk/page.tsx new file mode 100644 index 0000000..ec397b3 --- /dev/null +++ b/web/src/app/desk/page.tsx @@ -0,0 +1,148 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useAuth } from '@/contexts/AuthContext'; + +/** + * /desk (Session 63 / A1-S4c) — the founder's daily copy-paste arsenal. + * Auth: gated route + backend email allowlist (DESK_OWNERS). Everything on + * this page is assembled from the pipeline by the VOICE v1.1 template + * engine; per-tweet copy buttons + character counts; the DATA BRIEF block + * pastes into claude.ai for freeform writing. Nothing auto-posts. + */ + +interface Variant { label: string; text: string; tweets: string[] } +interface DeskPack { + generated_at: string | null; + formats: { + morning_wire?: Variant[]; + signals?: Variant[][]; + streak_watch?: Variant[]; + settle?: Variant[]; + receipts?: Variant[][]; + archetype_watch?: Variant[]; + line_dispatches?: Variant[][]; + }; + data_brief?: unknown; + error?: string; +} + +function CopyBtn({ text, label = 'COPY' }: { text: string; label?: string }) { + const [done, setDone] = useState(false); + return ( + + ); +} + +function VariantBlock({ v }: { v: Variant }) { + return ( +
+
+ {v.label.toUpperCase()} + +
+ {v.tweets.map((t, i) => ( +
+
{t}
+
+ + 280 ? 'var(--miss)' : 'var(--text-2)' }}>{t.length}/280 +
+
+ ))} +
+ ); +} + +function FormatCard({ title, variants }: { title: string; variants?: Variant[] }) { + if (!variants || variants.length === 0) return null; + return ( +
+
{title}
+ {variants.map((v, i) => )} +
+ ); +} + +export default function DeskPage() { + const { session } = useAuth(); + const [pack, setPack] = useState(null); + const [status, setStatus] = useState<'loading' | 'denied' | 'ready' | 'error'>('loading'); + + useEffect(() => { + const token = session?.access_token; + if (!token) return; + let active = true; + fetch('/api/desk/pack', { headers: { Authorization: `Bearer ${token}` } }) + .then(async (r) => { + if (!active) return; + if (r.status === 403 || r.status === 401) { setStatus('denied'); return; } + const data = (await r.json()) as DeskPack; + setPack(data); + setStatus(data && data.formats ? 'ready' : 'error'); + }) + .catch(() => { if (active) setStatus('error'); }); + return () => { active = false; }; + }, [session]); + + if (status === 'denied') { + return ( +
+

The desk is the publisher’s surface. DESK_OWNERS grants access.

+
+ ); + } + if (status !== 'ready' || !pack) { + return ( +
+

{status === 'loading' ? 'Assembling the pack…' : 'Pack unavailable. Check back after the first pipeline run.'}

+
+ ); + } + + const f = pack.formats; + return ( +
+
+
THE DESK
+

Today’s arsenal

+ {pack.generated_at && ( +

+ Assembled {new Date(pack.generated_at).toLocaleString('en-US', { timeZone: 'America/New_York' })} ET · nothing auto-posts +

+ )} +
+ + + {(f.signals || []).map((v, i) => )} + + + {(f.line_dispatches || []).map((v, i) => )} + + {(f.receipts || []).map((v, i) => )} + + {/* DATA BRIEF — paste into claude.ai for freeform Read Room threads. */} + {pack.data_brief != null && ( +
+
+ DATA BRIEF + + paste into claude.ai for Read Room + freeform +
+
{JSON.stringify(pack.data_brief, null, 2)}
+
+ )} +
+ ); +} diff --git a/web/src/lib/routes.js b/web/src/lib/routes.js index 813b92a..43855e2 100644 --- a/web/src/lib/routes.js +++ b/web/src/lib/routes.js @@ -16,6 +16,7 @@ gating them would be a monetization regression. We gate only the genuinely personal surfaces (a user's own ledger, bets, account, alerts). */ const GATED_ROUTES = [ + '/desk', // Session 63 (A1-S4) — the founder's media surface (+ backend allowlist) '/ledger', '/tracker', '/account',