S4 (a1): the media engine — VOICE templates, /desk, Ghost drafts

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 <noreply@anthropic.com>
This commit is contained in:
Kev
2026-07-11 14:31:35 -04:00
parent d242b11b4b
commit 0e7871ac0e
12 changed files with 912 additions and 0 deletions
+2
View File
@@ -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);
+41
View File
@@ -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 publishers 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;
+19
View File
@@ -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
+126
View File
@@ -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 } };
+77
View File
@@ -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: `<pre>${escapeHtml(wire.text)}</pre>`,
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: `<pre>${escapeHtml(settle.text)}</pre>`,
tags: ['settle'],
}, opts));
}
return { ok: results.every((r) => r.ok), results };
}
const escapeHtml = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
module.exports = { saveDraft, saveDailyDrafts, ghostJwt, isConfigured };
+258
View File
@@ -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('Slates 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 tonights 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 },
};
+6
View File
@@ -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);
}