Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 23 additions & 6 deletions src/agent/llm.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { test, afterEach } from 'node:test'
import assert from 'node:assert/strict'
import { failLoudlyOnProviderError } from './llm.ts'
import { createLlm, failLoudlyOnProviderError } from './llm.ts'
import { DEFAULT_LLM, LLM_FALLBACK_MODELS } from '../constants/index.ts'

const realFetch = globalThis.fetch

Expand All @@ -14,9 +15,6 @@ function stubFetch(body: string, init: { status?: number; contentType?: string }
globalThis.fetch = (async () => new Response(body, { status, headers: { 'content-type': contentType } })) as typeof fetch
}

// The real payload that took the trending job down: HTTP 200, an `error` object,
// and no `choices`. The OpenAI SDK treats 200 as success and hands back an empty
// result, which only explodes later as `undefined.message`.
test('throws on an HTTP 200 error body with no choices', async () => {
stubFetch(JSON.stringify({ error: { message: 'Upstream error from Nvidia: ResourceExhausted (32/32)', code: 502 } }))

Expand Down Expand Up @@ -44,8 +42,6 @@ test('passes a normal completion straight through', async () => {
assert.deepEqual(await res.json(), { choices: [{ message: { content: 'OK' } }] })
})

// A body carrying BOTH an error and choices is a partial success — the SDK can
// still read a completion out of it, so it must not be turned into a throw.
test('does not throw when choices are present alongside an error', async () => {
stubFetch(JSON.stringify({ error: { message: 'partial' }, choices: [{ message: { content: 'OK' } }] }))

Expand Down Expand Up @@ -88,3 +84,24 @@ test('leaves the response body readable for the caller', async () => {

assert.equal(parsed.choices[0].message.content, 'still here')
})

test('every configured fallback actually reaches OpenRouter', () => {
const key = process.env.LLM_API_KEY
const model = process.env.LLM_MODEL

process.env.LLM_API_KEY = 'dummy'
delete process.env.LLM_MODEL

try {
const { modelKwargs } = createLlm() as unknown as { modelKwargs: { models: string[] } }

assert.equal(modelKwargs.models[0], DEFAULT_LLM, 'primary must be tried first')
for (const fb of LLM_FALLBACK_MODELS) {
assert.ok(modelKwargs.models.includes(fb), `configured fallback silently dropped: ${fb}`)
}
} finally {
if (key !== undefined) process.env.LLM_API_KEY = key
else delete process.env.LLM_API_KEY
if (model !== undefined) process.env.LLM_MODEL = model
}
})
30 changes: 7 additions & 23 deletions src/constants/index.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,22 @@
export const DEFAULT_LLM = 'nvidia/nemotron-3-ultra-550b-a55b:free'
export const DEFAULT_LLM_BASE_URL = 'https://openrouter.ai/api/v1'
// Free, tool-capable models OpenRouter falls back to when the primary is at
// capacity. Order matters — first available wins.
export const LLM_FALLBACK_MODELS = ['nvidia/nemotron-3-super-120b-a12b:free', 'google/gemma-4-31b-it:free', 'openai/gpt-oss-20b:free']
export const LLM_FALLBACK_MODELS = ['nvidia/nemotron-3-super-120b-a12b:free']
// How many repos make the digest, ranked by stars gained today.
export const TRENDING_TOP_N = 8
export const TELEGRAM_MAX_CHARS = 4096
export const TRENDING_SUMMARY_MAX = 140
export const TRENDING_TAGS_MAX = 5
export const TRENDING_TAG_MAX = 24
export const COMPANY_CLEANUP_TABLE = 'mockTestUsers'
export const COMPANY_CLEANUP_THRESHOLD_DAYS = '30'
// ── AI news digest (Tavily) ──────────────────────────────────────────────────
// How many stories make the digest.
export const AI_NEWS_TOP_N = 4
// Over-fetch: dedupe drops stories already sent, so asking for exactly 4 would
// deliver fewer than 4 on any day with repeats.
export const AI_NEWS_FETCH_N = 10
export const AI_NEWS_LOOKBACK_DAYS = 1
export const AI_NEWS_SNIPPET_MAX = 200
// Telegram drops the whole message past 4096 chars rather than truncating, and
// titleOf falls back to the full url when Tavily returns a blank title.
export const AI_NEWS_TITLE_MAX = 200
// Tavily's `score` is relevance to this query, not popularity — a vague query
// like "artificial intelligence" scores wellness blogs above model launches.
// Digest quality lives here and in AI_NEWS_DOMAINS, not in the ranking.
//
// Scope is AI *technology*: new models, developer tools, releases. Wording the
// query around releases and tools is what keeps funding rounds and stock moves
// out — asking for "industry announcements" pulled in ETFs and defence news.
export const AI_NEWS_QUERY = 'new AI model releases, developer tools, and open source AI software'
// Tech press for launches and product news, developer sources for tooling and
// releases. Deliberately no reuters/bloomberg — their AI coverage is finance,
// which is what dragged in stock and ETF stories.
//
// ponytail: an allowlist, not a hard filter — Tavily treats include_domains as a
// strong hint, so the occasional off-list SEO listicle still slips through. Add
// exclude_domains only if one actually reaches a digest.
export const AI_NEWS_DOMAINS = [
'techcrunch.com',
'theverge.com',
Expand All @@ -49,5 +33,5 @@ export const AI_NEWS_DOMAINS = [

export const DEFAULT_CRONJOB_TIME = '0 17 * * *'
export const DEFAULT_CRONJOB_TIMEZONE = 'Australia/Sydney' // Change to Your local time zone when you need to use this constant value
export const NEWS_CRON_TIME = '0 8 * * *' // 8:00 AM Sydney
export const AI_NEWS_CRON_TIME = '30 8 * * *' // 8:30 AM Sydney — 30 min after the trending digest
export const NEWS_CRON_TIME = '0 7 * * *' // 7:00 AM Sydney
export const AI_NEWS_CRON_TIME = '30 7 * * *' // 7:30 AM Sydney — 30 min after the trending digest
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ async function main(): Promise<void> {
const job = findJobByCliArg(process.argv)

if (job) {
await initDb()
await job.run()

process.exit(0)
Expand Down
11 changes: 0 additions & 11 deletions src/storage/own-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,6 @@ export async function initDb(): Promise<void> {
);
`)

// The T15 AI news feature (removed in T20) left an ai_news table behind on any
// database it ever ran against — including production. CREATE TABLE IF NOT
// EXISTS silently keeps that older shape, so inserts fail on the missing
// columns until it is brought forward. Its `summary` held what is now
// `snippet`, so the rename preserves the old rows as dedupe history.
await pool.query(`
DO $$
BEGIN
Expand Down Expand Up @@ -119,9 +114,6 @@ export async function saveCleanupLog(result: CleanupResult, tableName: string):
)
}

// One row per repo. A repo that trends again updates in place — star counts stay
// current instead of going stale behind a dedup filter. created_at keeps its
// original value, so it still reads as "first seen".
export async function saveTrendingRepos(repos: TrendingRepoLog[]): Promise<void> {
for (const repo of repos) {
await pool.query(
Expand Down Expand Up @@ -154,9 +146,6 @@ export async function saveTrendingRepos(repos: TrendingRepoLog[]): Promise<void>
}
}

// One row per story, keyed on url. A story that resurfaces updates in place
// rather than inserting a duplicate — created_at keeps its original value, so it
// still reads as "first seen".
export async function saveAiNews(items: AiNewsLog[]): Promise<void> {
for (const item of items) {
await pool.query(
Expand Down
97 changes: 97 additions & 0 deletions src/tools/news-telegram.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { buildTrendingMessage } from './news-telegram.tool.ts'
import { TELEGRAM_MAX_CHARS, TRENDING_SUMMARY_MAX, TRENDING_TAG_MAX, TRENDING_TAGS_MAX, TRENDING_TOP_N } from '../constants/index.ts'
import type { CuratedRepo } from '../schemas/index.ts'

const repo = (over: Partial<CuratedRepo> = {}): CuratedRepo => ({
repo_name: 'facebook/react',
url: 'https://github.com/facebook/react',
description: 'A library for web and native user interfaces',
language: 'JavaScript',
stars: 238000,
today_stars: 412,
summary: 'The library that popularised the component model.',
tags: ['ui', 'framework'],
...over,
})

test('numbers the repos and shows stars, language, tags and link', () => {
const message = buildTrendingMessage([repo(), repo({ repo_name: 'vuejs/core' })], '2026-08-01')

assert.match(message, /1️⃣ <b>facebook\/react<\/b>/)
assert.match(message, /2️⃣ <b>vuejs\/core<\/b>/)
assert.match(message, /⭐ 238,000 \(\+412 today\) · JavaScript/)
assert.match(message, /🏷 #ui #framework/)
assert.match(message, /<a href="https:\/\/github\.com\/facebook\/react">View on GitHub<\/a>/)
})

test('escapes html so a stray bracket cannot break Telegram parse_mode', () => {
const message = buildTrendingMessage([repo({ repo_name: 'a<b>&c', summary: 'x < y && z' })], '2026-08-01')

assert.match(message, /a&lt;b&gt;&amp;c/)
assert.match(message, /x &lt; y &amp;&amp; z/)
assert.ok(!message.includes('<b>&c'))
})

test('truncates an over-long summary at the bound', () => {
const message = buildTrendingMessage([repo({ summary: 'S'.repeat(TRENDING_SUMMARY_MAX + 200) })], '2026-08-01')
const italics = message.match(/<i>(.*?)<\/i>/)

assert.ok(italics)
assert.equal(italics[1].length, TRENDING_SUMMARY_MAX)
})

test('caps both the number of tags and the length of each one', () => {
const message = buildTrendingMessage(
[repo({ tags: Array.from({ length: TRENDING_TAGS_MAX + 20 }, () => 'T'.repeat(TRENDING_TAG_MAX + 30)) })],
'2026-08-01'
)
const rendered = message.match(/🏷 (.*)/)?.[1] ?? ''
const tags = rendered.split(' ')

assert.equal(tags.length, TRENDING_TAGS_MAX)
for (const t of tags) assert.ok(t.length <= TRENDING_TAG_MAX + 1, `tag too long: ${t.length}`) // +1 for the leading #
})

test('omits the tag line entirely when the curator returned none', () => {
const message = buildTrendingMessage([repo({ tags: [] })], '2026-08-01')

assert.ok(!message.includes('🏷'))
assert.match(message, /⭐ 238,000/) // the rest of the entry is intact
})

// The bug this file was written for: tags were uncapped, so one verbose entry
// pushed the digest past Telegram's limit and Telegram rejected the WHOLE
// message — losing seven perfectly normal repos along with the one bad one.
test('one repo with a pathological tags array cannot cost the whole digest', () => {
const normal = Array.from({ length: TRENDING_TOP_N - 1 }, () => repo())
const badApple = repo({ tags: Array.from({ length: 40 }, (_, i) => `machine-learning-infrastructure-tag-${i}`) })

const message = buildTrendingMessage([...normal, badApple], '2026-08-01')

assert.ok(message.length <= TELEGRAM_MAX_CHARS, `message was ${message.length} chars`)
assert.match(message, /8️⃣/) // all eight still made it
})

test('a worst-case digest stays inside the limit Telegram hard-fails at', () => {
const worst = Array.from({ length: TRENDING_TOP_N }, () =>
repo({
repo_name: 'R'.repeat(140),
summary: 'S'.repeat(TRENDING_SUMMARY_MAX + 500),
tags: Array.from({ length: TRENDING_TAGS_MAX + 10 }, () => 'T'.repeat(TRENDING_TAG_MAX + 10)),
url: `https://github.com/${'u'.repeat(120)}`,
})
)

assert.ok(buildTrendingMessage(worst, '2026-08-01').length <= TELEGRAM_MAX_CHARS)
})

// Escaping expands text after the per-field caps run (`&` becomes `&amp;`), so
// the caps alone cannot guarantee the invariant — the trim guard does.
test('drops trailing repos rather than emitting a message Telegram will reject', () => {
const bomb = Array.from({ length: TRENDING_TOP_N }, () => repo({ summary: '&'.repeat(TRENDING_SUMMARY_MAX) }))
const message = buildTrendingMessage(bomb, '2026-08-01')

assert.ok(message.length <= TELEGRAM_MAX_CHARS, `message was ${message.length} chars`)
})
80 changes: 49 additions & 31 deletions src/tools/news-telegram.tool.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,54 @@
import { DynamicStructuredTool } from '@langchain/core/tools'
import { z } from 'zod'
import { escapeHtml } from '../agent/utils.js'
import { escapeHtml, truncate } from '../agent/utils.js'
import { sendTelegramMessage } from './telegram.js'
import { TELEGRAM_MAX_CHARS, TRENDING_SUMMARY_MAX, TRENDING_TAG_MAX, TRENDING_TAGS_MAX } from '../constants/index.js'
import type { CuratedRepo } from '../schemas/index.js'

const NUMBER_EMOJIS = ['1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣']

function repoEntry(repo: CuratedRepo, index: number): string {
const num = NUMBER_EMOJIS[index] ?? `${index + 1}.`
const summary = truncate(repo.summary, TRENDING_SUMMARY_MAX)
const tags = repo.tags
.slice(0, TRENDING_TAGS_MAX)
.map((t) => `#${escapeHtml(truncate(t, TRENDING_TAG_MAX))}`)
.join(' ')

const lines = [
`${num} <b>${escapeHtml(repo.repo_name)}</b>`,
`⭐ ${repo.stars.toLocaleString()} (+${repo.today_stars} today) · ${escapeHtml(repo.language)}`,
`<i>${escapeHtml(summary)}</i>`,
]

if (tags) lines.push(`🏷 ${tags}`)

lines.push(`🔗 <a href="${escapeHtml(repo.url)}">View on GitHub</a>`)

return lines.join('\n')
}

function assemble(repos: CuratedRepo[], today: string): string {
return [
`🔥 <b>GitHub Trending — Daily Digest</b>`,
`📅 ${escapeHtml(today)} · TypeScript / JavaScript`,
'',
'━━━━━━━━━━━━━━━━━━━━━━',
'',
repos.map(repoEntry).join('\n\n'),
'',
'━━━━━━━━━━━━━━━━━━━━━━',
`📊 ${repos.length} ${repos.length === 1 ? 'repo' : 'repos'} · Powered by GitHub Trending`,
].join('\n')
}

export function buildTrendingMessage(repos: CuratedRepo[], today: string): string {
let kept = repos

while (kept.length > 1 && assemble(kept, today).length > TELEGRAM_MAX_CHARS) kept = kept.slice(0, -1)

return assemble(kept, today)
}

export const trendingTelegramTool = new DynamicStructuredTool({
name: 'send_trending_telegram',
Expand All @@ -24,36 +71,7 @@ export const trendingTelegramTool = new DynamicStructuredTool({
}),
func: async ({ repos }) => {
const today = new Date().toISOString().split('T')[0]

const numberEmojis = ['1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣']

const repoLines = repos.map((r, i) => {
const num = numberEmojis[i] ?? `${i + 1}.`
// ponytail: the prompt asks for <140 chars, this is what actually guarantees it.
// Telegram hard-fails the whole message past 4096, so the bound lives in code.
const summary = r.summary.length > 140 ? `${r.summary.slice(0, 137)}...` : r.summary
return [
`${num} <b>${escapeHtml(r.repo_name)}</b>`,
`⭐ ${r.stars.toLocaleString()} (+${r.today_stars} today) · ${escapeHtml(r.language)}`,
`<i>${escapeHtml(summary)}</i>`,
`🏷 ${r.tags.map((t) => `#${escapeHtml(t)}`).join(' ')}`,
`🔗 <a href="${escapeHtml(r.url)}">View on GitHub</a>`,
].join('\n')
})

const message = [
`🔥 <b>GitHub Trending — Daily Digest</b>`,
`📅 ${escapeHtml(today)} · TypeScript / JavaScript`,
'',
'━━━━━━━━━━━━━━━━━━━━━━',
'',
repoLines.join('\n\n'),
'',
'━━━━━━━━━━━━━━━━━━━━━━',
`📊 ${repos.length} repos · Powered by GitHub Trending`,
].join('\n')

const chatId = await sendTelegramMessage(message, 'Trending repos')
const chatId = await sendTelegramMessage(buildTrendingMessage(repos, today), 'Trending repos')

return JSON.stringify({ success: true, chat_id: chatId, date: today })
},
Expand Down
Loading