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
7 changes: 0 additions & 7 deletions src/agent/kpi-record.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import { toKpiRecord } from './kpi-record.ts'

const NOW = '2026-07-31T00:00:00.000Z'

// The real tool hardcodes summary: '' and the agent is told not to touch it, so
// this is what every GitHub-only day actually looks like.
test('describes the activity when the agent supplied no summary', () => {
const record = toKpiRecord(JSON.stringify({ summary: '', commits: [1, 2, 3, 4], pullRequests: [1, 2, 3, 4, 5, 6] }), NOW)

Expand Down Expand Up @@ -36,9 +34,6 @@ test('maps a complete GitHub payload', () => {
assert.equal(record.updated_at, NOW)
})

// The GitHub tool output is relayed through an LLM, so any field can go missing.
// Zeros are the right answer here — but only if they come from genuinely absent
// data, which is what these cases pin down.
test('defaults every missing field rather than throwing', () => {
const record = toKpiRecord(JSON.stringify({}), NOW)

Expand Down Expand Up @@ -76,8 +71,6 @@ test('counts empty arrays as zero, not missing', () => {
assert.equal(record.prs_count, 0)
})

// A real 0-commit day and a broken payload both produce zeros, so the summary is
// the only thing distinguishing them downstream. Worth keeping honest.
test('keeps the summary when counts are legitimately zero', () => {
const record = toKpiRecord(JSON.stringify({ summary: 'no code today', commits: [], pullRequests: [] }), NOW)

Expand Down
2 changes: 0 additions & 2 deletions src/agent/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,6 @@ export const createLlm = (temperature = 0) => {
model,
apiKey,
temperature,
// OpenRouter routes to the next model when one errors or is at capacity.
// It rejects more than 3 entries, primary included.
modelKwargs: { models: [model, ...LLM_FALLBACK_MODELS.filter((m) => m !== model)].slice(0, 3) },
configuration: {
baseURL: process.env.LLM_BASE_URL || DEFAULT_LLM_BASE_URL,
Expand Down
5 changes: 0 additions & 5 deletions src/agent/notify-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,6 @@ afterEach(() => {
globalThis.fetch = realFetch
})

// Telegram's HTML mode rejects any tag outside its whitelist with a 400, and the
// alert is then never delivered. Real errors carry markup: gateway 502 bodies are
// HTML, and reasoning models emit <think>.
test('escapes markup in the error so Telegram can parse the alert', async () => {
await notifyError('AI news search', new Error('Tavily API error 502: <html><body>upstream connect error</body></html>'))

Expand Down Expand Up @@ -55,8 +52,6 @@ test('keeps an oversized error inside Telegram limit', async () => {
assert.ok(sent!.text.length <= 4096, `message was ${sent!.text.length} chars`)
})

// Cutting an escaped string can land mid-entity ("&qu"), which Telegram rejects.
// Cutting first and escaping after makes that impossible.
test('never emits a half-escaped entity', async () => {
await notifyError('AI news search', new Error('"'.repeat(5000)))

Expand Down
3 changes: 1 addition & 2 deletions src/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ 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
export const AI_NEWS_TOP_N = 4 // How many stories make the digest.
export const AI_NEWS_FETCH_N = 10
export const AI_NEWS_LOOKBACK_DAYS = 1
export const AI_NEWS_SNIPPET_MAX = 200
Expand Down
2 changes: 0 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,6 @@ async function main(): Promise<void> {
process.exit(0)
}

// No recognized args — scheduling is handled by GitHub Actions, not an
// in-process daemon. Point the user at the actual options instead.
logger.info('No job specified. Use --job=<name> to run one, or --list-jobs to see all.')
printJobs()
process.exit(0)
Expand Down
14 changes: 0 additions & 14 deletions src/tools/ai-news-search.tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ import type { AiNewsItem } from '../schemas/index.js'

const TAVILY_SEARCH_URL = 'https://api.tavily.com/search'

// Only the fields the digest actually uses. `score` and `raw_content` come back
// too and are deliberately dropped — see selectUnseen for why score is unused.
type TavilyResult = {
title?: string
url?: string
Expand All @@ -25,8 +23,6 @@ function hostnameOf(url: string): string {
}
}

// Tavily returns RFC-1123 ("Fri, 31 Jul 2026 04:00:00 GMT"). Postgres accepts it,
// but normalising here means the stored value and the message agree.
function toIsoDate(raw: string | null | undefined): string | null {
if (!raw) return null

Expand All @@ -35,9 +31,6 @@ function toIsoDate(raw: string | null | undefined): string | null {
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString()
}

// The HTTP half: credentials, the call, and the error surface. Throws rather
// than returning empty, so a dead key or a rate limit reaches notifyError
// instead of looking like a quiet news day.
async function searchTavily(body: Record<string, unknown>): Promise<TavilyResult[]> {
const apiKey = process.env.TAVILY_API_KEY ?? ''

Expand All @@ -58,17 +51,10 @@ async function searchTavily(body: Record<string, unknown>): Promise<TavilyResult
return data.results ?? []
}

// Exported for testing, and because the digest's whole selection policy is these
// three lines: drop what was already sent, keep Tavily's order, take the top N.
// Tavily's `score` is relevance to the query, not popularity — sorting by it
// promotes whatever best matches the words, not what matters. The query and the
// domain allowlist do the quality work instead.
export function selectUnseen(items: AiNewsItem[], seenUrls: Set<string>, topN: number): AiNewsItem[] {
return items.filter((item) => !seenUrls.has(item.url)).slice(0, topN)
}

// Tavily sometimes returns a blank or padded title; the url is the last resort
// so a story is never listed with no name at all.
function titleOf(r: TavilyResult): string {
return r.title?.trim() || String(r.url)
}
Expand Down
4 changes: 0 additions & 4 deletions src/tools/ai-news-telegram.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,6 @@ test('counts stories with the right plural', () => {
assert.match(buildAiNewsMessage([story(), story()], '2026-07-31'), /📊 2 stories /)
})

// Telegram rejects the whole message past 4096, so a digest that overflows is
// not truncated — it is lost. Title and snippet are both bounded now, so this
// really is the worst case, and it fails if TOP_N is raised past what the
// format can carry.
test('a worst-case digest stays inside the 4096-char limit Telegram hard-fails at', () => {
const worst = Array.from({ length: AI_NEWS_TOP_N }, () =>
story({
Expand Down
3 changes: 0 additions & 3 deletions src/tools/manual-kpi.tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,6 @@ async function collectActivities(): Promise<string[]> {
return activities
}

// Local mode: interactive readline prompt. These go straight to stdout — a
// timestamped, levelled log line would not read as a question, and pino's
// stream does not interleave predictably with readline's own output.
prompt('\n──────────────────────────────────────────')
prompt('📝 Anything else you did today?')
prompt('📝 (Enter each activity on a new line)')
Expand Down
5 changes: 0 additions & 5 deletions src/tools/news-telegram.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,6 @@ test('omits the tag line entirely when the curator returned none', () => {
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}`) })
Expand All @@ -87,8 +84,6 @@ test('a worst-case digest stays inside the limit Telegram hard-fails at', () =>
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')
Expand Down
4 changes: 0 additions & 4 deletions src/tools/telegram.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,6 @@ import { test } from 'node:test'
import assert from 'node:assert/strict'
import { sendTelegramMessage } from './telegram.ts'

// Both digests deliver through this, so its failure modes are the ones that
// decide whether a digest arrives or vanishes.
// Restores process.env wholesale rather than key by key — the branchier version
// of this helper was itself complex enough to trip the CRAP gate.
async function withEnvAndFetch(env: NodeJS.ProcessEnv, stub: typeof fetch, fn: () => Promise<void>): Promise<void> {
const savedEnv = { ...process.env }
const realFetch = globalThis.fetch
Expand Down
10 changes: 0 additions & 10 deletions src/tools/telegram.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,5 @@
import { logger } from '../utils/logger.js'

/**
* Posts an HTML message to the configured Telegram chat.
*
* Both digests reached Telegram through the same twenty lines — read two env
* vars, validate them, POST, check the status, log. This is that, once.
* Message building stays in the tools; only delivery lives here.
*
* Throws on missing credentials and on any non-2xx, so callers can report the
* failure through notifyError rather than guessing.
*/
export async function sendTelegramMessage(html: string, label: string): Promise<string> {
const botToken = process.env.TELEGRAM_BOT_TOKEN ?? ''
const chatId = process.env.TELEGRAM_CHAT_ID ?? ''
Expand Down
3 changes: 1 addition & 2 deletions src/tools/trending-scrape.tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,7 @@ function parseTrendingHtml(html: string): TrendingRepo[] {
const langMatch = block.match(/<span itemprop="programmingLanguage">([\s\S]*?)<\/span>/)
const language = langMatch ? langMatch[1].trim() : ''

// Total stars — the count sits after the star <svg> icon inside the stargazers link,
// so we have to skip the icon markup rather than read straight after the opening tag.
// Total stars
const starsMatch = block.match(/href="\/[^"]*\/stargazers"[\s\S]*?<\/svg>\s*([\d,]+)/)
const stars = starsMatch ? parseInt(starsMatch[1].replace(/,/g, ''), 10) : 0

Expand Down
Loading