diff --git a/.env.example b/.env.example index c990313..73349d6 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,7 @@ QUIZ_EMAIL_RECIPIENT=your_recipient_email_here # Telegram TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here # @BotFather on Telegram → /newbot TELEGRAM_CHAT_ID=your_telegram_chat_id_here # message @userinfobot on Telegram to get it +# LangSmith (optional — tracing for LangChain agent calls) +LANGSMITH_TRACING=true +LANGSMITH_API_KEY=your_langsmith_api_key_here +LANGSMITH_PROJECT=oh-my-workers diff --git a/.github/workflows/cleanup.yml b/.github/workflows/cleanup.yml index 0656b11..34de1bf 100644 --- a/.github/workflows/cleanup.yml +++ b/.github/workflows/cleanup.yml @@ -36,4 +36,7 @@ jobs: COMPANY_DB_URL: ${{ secrets.NEON_MOCK_COMPANY_DB_URL }} COMPANY_CLEANUP_TABLE: ${{ secrets.COMPANY_CLEANUP_TABLE }} COMPANY_CLEANUP_THRESHOLD_DAYS: ${{ secrets.COMPANY_CLEANUP_THRESHOLD_DAYS }} + LANGSMITH_TRACING: 'true' + LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }} + LANGSMITH_PROJECT: oh-my-workers run: pnpm cleanup diff --git a/.github/workflows/daily-kpi.yml b/.github/workflows/daily-kpi.yml index 10df088..9cb2b79 100644 --- a/.github/workflows/daily-kpi.yml +++ b/.github/workflows/daily-kpi.yml @@ -36,4 +36,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TARGET_GITHUB_USERNAME: ${{ secrets.TARGET_GITHUB_USERNAME }} MANUAL_ACTIVITIES: ${{ github.event.inputs.activities }} + LANGSMITH_TRACING: 'true' + LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }} + LANGSMITH_PROJECT: oh-my-workers run: pnpm start diff --git a/.github/workflows/morning-news.yml b/.github/workflows/morning-news.yml index ab5bf39..1d7d666 100644 --- a/.github/workflows/morning-news.yml +++ b/.github/workflows/morning-news.yml @@ -34,4 +34,7 @@ jobs: DATABASE_URL: ${{ secrets.NEON_WORK_COORDINATOR_DB_URL }} TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }} + LANGSMITH_TRACING: 'true' + LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }} + LANGSMITH_PROJECT: oh-my-workers run: pnpm news diff --git a/.gitignore b/.gitignore index 8e84dff..1029b0e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,7 @@ data/ wiki/ src/assets/slides_to_video/ src/scripts/test-registry.ts -dist/ \ No newline at end of file +dist/ +.claude +commands/ +hooks/ diff --git a/README.md b/README.md index 90c0789..ba7742f 100644 --- a/README.md +++ b/README.md @@ -3,100 +3,79 @@ A personal AI agent suite for software engineers. Runs daily jobs automatically via GitHub Actions: - **5pm Sydney** — fetches GitHub activity, asks what else you did, generates a KPI diary report -- **8am Sydney** — scrapes GitHub trending repos (TypeScript/JavaScript), deduplicates against recent sends, curates top picks with an LLM, delivers via Telegram +- **8am Sydney** — scrapes GitHub trending repos (TypeScript/JavaScript), deduplicates, curates top picks with an LLM, delivers via Telegram -Built with TypeScript, LangChain, and Claude. - - +Built with TypeScript, LangChain/LangGraph, and any OpenAI-compatible LLM (defaults to OpenRouter, free tier). More detail in the [wiki](https://github.com/DamengRandom/oh-my-workers/wiki). --- ## How it works -**KPI pipeline (5pm):** -``` -Phase 1 (parallel): cleanupAgent + githubAgent -Phase 2 (sequential): manualKpiAgent ← waits for your input -Phase 3 (sequential): diaryAgent ← writes report from Phase 1+2 data -``` +**KPI pipeline (5pm):** `cleanupAgent + githubAgent` (parallel) → `manualKpiAgent` (waits for your input) → `diaryAgent` (writes report) **GitHub Trending pipeline (8am):** ``` -Scrape GitHub trending (TS + JS) - ↓ -Dedup against DB (last 7 days) - ↓ -LLM curator → picks top 5-8, writes summaries + tags - ↓ -Telegram delivery → saved to github_trending table +Scrape (TS + JS) → Dedup (last 7 days) → LLM curator → Telegram → saved to DB ``` -The curator generates 3-5 tags per repo (e.g. `#ai`, `#framework`, `#bundler`) for future vector search classification. +The curator is a small LangGraph retry loop: if the LLM's output doesn't parse, it retries once with the parse error as feedback; if it still fails, the job alerts you via Telegram instead of failing silently. See [Curator Retry Graph](https://github.com/DamengRandom/oh-my-workers/wiki/Curator-Retry-Graph). --- ## Setup -### 1. Install - ```bash pnpm install -cp .env.example .env +cp .env.example .env # fill in your keys — see below +pnpm run setup # create database tables +pnpm news # test the GitHub trending pipeline +pnpm start # test the KPI pipeline +pnpm test # run the unit test suite ``` -### 2. Configure `.env` +Minimum required in `.env`: ```env -ANTHROPIC_API_KEY= -GITHUB_TOKEN= # github.com/settings/tokens (read:user, repo scopes) +LLM_API_KEY= # any OpenAI-compatible provider; defaults to OpenRouter +GITHUB_TOKEN= # github.com/settings/tokens (read:user, repo scopes) TARGET_GITHUB_USERNAME= - DATABASE_URL=postgresql://postgres:password@localhost:5432/work_coordinator COMPANY_DB_URL=postgresql://user:password@company-host:5432/company_db - -# Telegram -TELEGRAM_BOT_TOKEN= # @BotFather on Telegram → /newbot -TELEGRAM_CHAT_ID= # message @userinfobot to get your ID, then start your bot first +TELEGRAM_BOT_TOKEN= # @BotFather on Telegram → /newbot +TELEGRAM_CHAT_ID= # message @userinfobot, then start your bot first ``` -### 3. Initialize database +`LANGSMITH_TRACING` / `LANGSMITH_API_KEY` / `LANGSMITH_PROJECT` are optional (see below). Using Neon? Drop `&channel_binding=require` from the connection string — `pg` doesn't support it. -```bash -pnpm run setup -``` +--- -> Using Neon? Pass the URL inline: `DATABASE_URL="postgresql://...?sslmode=require" pnpm run setup` -> Remove `&channel_binding=require` from Neon URLs — the `pg` library doesn't support it. +## Observability (LangSmith) -### 4. Test +Optional tracing for every LangChain/LangGraph call — free tier (5,000 traces/month) comfortably covers this project. Set the three `LANGSMITH_*` vars in `.env` (get a key at [smith.langchain.com](https://smith.langchain.com)) — no code changes needed. -```bash -pnpm start # runs all KPI jobs now -pnpm news # runs GitHub trending pipeline now -``` +![LangSmith trace logs](/src/assets/images/langsmith-logs.png) --- ## Automate via GitHub Actions (recommended) -Push to GitHub, then add these secrets under **Settings → Secrets and variables → Actions**: +Push to GitHub, add these secrets under **Settings → Secrets and variables → Actions**: | Secret | Value | |---|---| | `LLM_API_KEY` | from openrouter.ai/keys (free tier works) | -| `NEON_WORK_COORDINATOR_DB_URL` | Neon connection string (remove `&channel_binding=require`) | +| `NEON_WORK_COORDINATOR_DB_URL` | Neon connection string (no `&channel_binding=require`) | | `NEON_MOCK_COMPANY_DB_URL` | Neon connection string for company DB | | `COMPANY_CLEANUP_TABLE` | table to clean, e.g. `mockTestUsers` | | `COMPANY_CLEANUP_THRESHOLD_DAYS` | stale threshold, e.g. `30` | | `TARGET_GITHUB_USERNAME` | your GitHub username | | `TELEGRAM_BOT_TOKEN` | from @BotFather | | `TELEGRAM_CHAT_ID` | from @userinfobot | +| `LANGSMITH_API_KEY` | optional | ![GitHub Actions secrets](/src/assets/images/github-actions-secrets.png) -Workflows run automatically. Trigger manually: **Actions** tab → select workflow → **Run workflow**. - -The **Daily KPI Report** workflow has a form — enter comma-separated activities before clicking Run. +Trigger manually: **Actions tab → select workflow → Run workflow**. The Daily KPI Report workflow takes comma-separated activities as input. --- @@ -105,15 +84,12 @@ The **Daily KPI Report** workflow has a form — enter comma-separated activitie ```bash crontab -e ``` - -Add (replace paths with your own): ``` PATH=/Users/yourname/.nvm/versions/node/v22.14.0/bin:/opt/homebrew/bin:/usr/bin:/bin TZ=Australia/Sydney 0 17 * * * /opt/homebrew/bin/pnpm --prefix /path/to/project cleanup >> /path/to/project/data/cron.log 2>&1 ``` - -> Mac must be awake at 5pm. GitHub Actions is more reliable for unattended runs. +> Mac must be awake at 5pm — GitHub Actions is more reliable for unattended runs. --- @@ -122,15 +98,16 @@ TZ=Australia/Sydney | Command | What it does | |---|---| | `pnpm run setup` | One-time DB table creation | -| `pnpm cleanup` | Stale data deletion only (used by crontab) — alias for `--job=cleanup` | +| `pnpm cleanup` | Stale data deletion only — alias for `--job=cleanup` | | `pnpm start` | GitHub fetch + manual KPI input + diary report — alias for `--job=daily-kpi` | -| `pnpm news` | Scrape GitHub trending, curate, send via Telegram — alias for `--job=news` | +| `pnpm news` | Scrape, curate, send via Telegram — alias for `--job=news` | | `pnpm jobs` | List every registered job with its cron schedule | -| `pnpm run dev --job=` | Run any registered job by name (generic dispatch) | -| `pnpm dev` | Long-running daemon (fires scheduled jobs via node-cron) | +| `pnpm run dev --job=` | Run any registered job by name | +| `pnpm dev` | Long-running daemon (node-cron) | | `pnpm seed-mock` | Seed expired mock users into company DB | -| `pnpm format` | Auto-format with Prettier | +| `pnpm test` | Run the unit test suite (`node:test` via `tsx`) | | `pnpm tsc` | TypeScript type check | +| `pnpm format` | Auto-format with Prettier | --- @@ -140,28 +117,18 @@ TZ=Australia/Sydney src/ ├── agent/ │ ├── index.ts # WorkCoordinator — orchestrates all agents -│ ├── prompt.ts # System prompts for all agents -│ ├── cleanup.agent.ts -│ ├── github.agent.ts -│ ├── manual-kpi.agent.ts -│ ├── diary.agent.ts -│ ├── news-curator.agent.ts # LLM curator for trending repos -│ └── news-telegram.agent.ts # Telegram delivery agent +│ ├── prompt.ts # System prompts for all agents +│ ├── llm.ts # Shared model factory (any OpenAI-compatible provider) +│ ├── utils.ts # Shared helpers: toolOutput, parseJson, notifyError +│ ├── curator.graph.ts # LangGraph: self-correcting retry loop for curation +│ ├── curator-graph.test.ts # Unit tests for the curator retry graph +│ └── *.agent.ts # One focused agent per task ├── tools/ # DynamicStructuredTool implementations -│ ├── trending-scrape.tool.ts # GitHub trending HTML scraper -│ ├── news-curator.tool.ts # Curate + tag trending repos -│ └── news-telegram.tool.ts # Format + send Telegram message -├── jobs/ -│ ├── registry.ts # Generic Job registry — add new cron jobs here -│ └── scheduler.ts # node-cron loop over registry +├── jobs/ # registry.ts (add jobs here) + scheduler.ts ├── storage/ # PostgreSQL queries (own-db + company-db) -├── schemas/index.ts # Zod schemas -└── index.ts # Entry point + CLI flags -.github/workflows/ -├── cleanup.yml # Daily 5pm cleanup -├── daily-kpi.yml # Manual KPI trigger (workflow_dispatch) -├── seed-mock-users.yml # Daily 4:30pm mock data seeding -└── morning-news.yml # Daily 8am GitHub trending digest +├── schemas/index.ts # Zod schemas + shared types (TrendingRepo, CuratedRepo, ...) +└── index.ts # Entry point + CLI flags +.github/workflows/ # cleanup, daily-kpi, seed-mock-users, morning-news ``` --- @@ -170,10 +137,10 @@ src/ | Table | Description | |---|---| -| `kpi` | Daily GitHub activity records (commits, PRs, manual activities) | +| `kpi` | Daily GitHub activity records | | `diary` | AI-generated daily KPI reports | -| `cleanup_log` | Company DB cleanup history (deleted count, errors) | -| `github_trending` | Trending repos with summaries, tags, and sent status | +| `cleanup_log` | Company DB cleanup history | +| `github_trending` | Trending repos with summaries, tags, sent status | --- diff --git a/package.json b/package.json index e2ad69e..87476bd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-workers", - "version": "1.1.0", + "version": "1.2.0", "description": "A personal AI agent that automates daily working tasks at workplace, eg: Cronjobs, KPI reporting and etc", "type": "module", "main": "index.js", @@ -10,6 +10,7 @@ "start": "pnpm run dev --job=daily-kpi", "cleanup": "pnpm run dev --job=cleanup", "tsc": "pnpm exec tsc --noEmit 2>&1", + "test": "node --import tsx --test \"src/**/*.test.ts\"", "news": "pnpm run dev --job=news", "jobs": "pnpm run dev --list-jobs", "seed-mock": "node --loader ts-node/esm src/scripts/seed-mock-users.ts", @@ -27,6 +28,9 @@ "automation", "AI agent", "langchain", + "langsmith", + "Nvidia", + "nemotron", "typescript", "neon", "postgresql", @@ -38,6 +42,7 @@ "packageManager": "pnpm@10.7.1", "dependencies": { "@langchain/core": "^1.2.3", + "@langchain/langgraph": "^1.4.8", "@langchain/openai": "^1.5.5", "@octokit/rest": "^22.0.1", "dotenv": "^17.3.1", @@ -52,6 +57,7 @@ "@types/pg": "^8.20.0", "prettier": "^3.8.1", "ts-node": "^10.9.2", + "tsx": "^4.23.1", "typescript": "^6.0.2" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95a5529..899ca0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@langchain/core': specifier: ^1.2.3 version: 1.2.3(openai@6.49.0(zod@4.4.3)) + '@langchain/langgraph': + specifier: ^1.4.8 + version: 1.4.8(@langchain/core@1.2.3(openai@6.49.0(zod@4.4.3)))(zod@4.4.3) '@langchain/openai': specifier: ^1.5.5 version: 1.5.5(@langchain/core@1.2.3(openai@6.49.0(zod@4.4.3))) @@ -48,6 +51,9 @@ importers: ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@25.9.2)(typescript@6.0.3) + tsx: + specifier: ^4.23.1 + version: 4.23.1 typescript: specifier: ^6.0.2 version: 6.0.3 @@ -61,6 +67,162 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -81,8 +243,14 @@ packages: peerDependencies: '@langchain/core': ^1.1.44 - '@langchain/langgraph-sdk@1.9.20': - resolution: {integrity: sha512-waZQNUN6apVg3jvDApy16wUGwrCvJB8WXHbIPvVrrUZHObqx0w5tk7P/Ite5Qvu4abWrvNtrmqk2zmcqCx7jVw==} + '@langchain/langgraph-checkpoint@1.1.3': + resolution: {integrity: sha512-wgzdQNeEsdw1e+4lvlj0tdq/RYR/k1vPin10g0ymGoehZDDgd9nvIllGXSXN4TFgF9sf5qQP/KTkOcLfeseIhA==} + engines: {node: '>=18'} + peerDependencies: + '@langchain/core': ^1.1.48 + + '@langchain/langgraph-sdk@1.9.28': + resolution: {integrity: sha512-4j3XuM0PvtmAbL8mPfBS99ez3+ytRfgbOpAR/nOeaejTRF3Q9dNw2QnaGLGng8wLPtGLoSj+SYgUOVxy9Bv9vg==} peerDependencies: '@langchain/core': ^1.1.48 react: ^18 || ^19 @@ -99,16 +267,12 @@ packages: vue: optional: true - '@langchain/langgraph@1.3.7': - resolution: {integrity: sha512-6WNskiu3bpy3XFwiD9cm4n2QapnFVX7dqYeJzuEAUGp9R3vbxngRVyb5+Myp5Rz4GCFXGXuhcFSjLVRHPhsNtQ==} + '@langchain/langgraph@1.4.8': + resolution: {integrity: sha512-DN1Np1XefdBEbp1qBKlt39cwoL743AAGpR5Ipja0gY2YbWvsoQnOTIrjnj/orSAhaUYsdTKS8VSWdFzsHZo6Ig==} engines: {node: '>=18'} peerDependencies: '@langchain/core': ^1.1.48 zod: ^3.25.32 || ^4.2.0 - zod-to-json-schema: ^3.x - peerDependenciesMeta: - zod-to-json-schema: - optional: true '@langchain/openai@1.5.5': resolution: {integrity: sha512-wX7dwb9z4nf5FHXlIl/X2mk08pzonvRHCt1D4+s1zXLP0duYDC95j7dulPIQJ6fmhbyYQc9Ki8mEhY/D1lB8kw==} @@ -116,8 +280,8 @@ packages: peerDependencies: '@langchain/core': ^1.2.2 - '@langchain/protocol@0.0.16': - resolution: {integrity: sha512-ws+J7MaHyhO5dG7f0vdyHQiUn9hoCnki0f3crJPa4MCTGzcRC39jYSCghyrGtBPYQnZbUQiGyRVpW3z3M8IpJg==} + '@langchain/protocol@0.0.18': + resolution: {integrity: sha512-XW1egQtPfsGI41w2AMZNFZrUIwFSQHTjVMZs0OaTpCAvht/QLoaPN8FQcsysMVypOhupG28J29yOorrc70otBQ==} '@octokit/auth-token@6.0.0': resolution: {integrity: sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==} @@ -231,12 +395,22 @@ packages: resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + is-network-error@1.3.2: resolution: {integrity: sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==} engines: {node: '>=16'} @@ -401,6 +575,11 @@ packages: '@swc/wasm': optional: true + tsx@4.23.1: + resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + engines: {node: '>=18.0.0'} + hasBin: true + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -438,6 +617,84 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -468,23 +725,25 @@ snapshots: '@langchain/core': 1.2.3(openai@6.49.0(zod@4.4.3)) uuid: 14.0.0 - '@langchain/langgraph-sdk@1.9.20(@langchain/core@1.2.3(openai@6.49.0(zod@4.4.3)))': + '@langchain/langgraph-checkpoint@1.1.3(@langchain/core@1.2.3(openai@6.49.0(zod@4.4.3)))': + dependencies: + '@langchain/core': 1.2.3(openai@6.49.0(zod@4.4.3)) + + '@langchain/langgraph-sdk@1.9.28(@langchain/core@1.2.3(openai@6.49.0(zod@4.4.3)))': dependencies: '@langchain/core': 1.2.3(openai@6.49.0(zod@4.4.3)) - '@langchain/protocol': 0.0.16 + '@langchain/protocol': 0.0.18 '@types/json-schema': 7.0.15 p-queue: 9.3.0 p-retry: 7.1.1 - uuid: 14.0.0 - '@langchain/langgraph@1.3.7(@langchain/core@1.2.3(openai@6.49.0(zod@4.4.3)))(zod@4.4.3)': + '@langchain/langgraph@1.4.8(@langchain/core@1.2.3(openai@6.49.0(zod@4.4.3)))(zod@4.4.3)': dependencies: '@langchain/core': 1.2.3(openai@6.49.0(zod@4.4.3)) - '@langchain/langgraph-checkpoint': 1.0.4(@langchain/core@1.2.3(openai@6.49.0(zod@4.4.3))) - '@langchain/langgraph-sdk': 1.9.20(@langchain/core@1.2.3(openai@6.49.0(zod@4.4.3))) - '@langchain/protocol': 0.0.16 + '@langchain/langgraph-checkpoint': 1.1.3(@langchain/core@1.2.3(openai@6.49.0(zod@4.4.3))) + '@langchain/langgraph-sdk': 1.9.28(@langchain/core@1.2.3(openai@6.49.0(zod@4.4.3))) + '@langchain/protocol': 0.0.18 '@standard-schema/spec': 1.1.0 - uuid: 14.0.0 zod: 4.4.3 transitivePeerDependencies: - react @@ -504,7 +763,7 @@ snapshots: - '@smithy/signature-v4' - ws - '@langchain/protocol@0.0.16': {} + '@langchain/protocol@0.0.18': {} '@octokit/auth-token@6.0.0': {} @@ -613,10 +872,42 @@ snapshots: dotenv@17.4.2: {} + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + eventemitter3@4.0.7: {} eventemitter3@5.0.4: {} + fsevents@2.3.3: + optional: true + is-network-error@1.3.2: {} js-tiktoken@1.0.21: @@ -628,7 +919,7 @@ snapshots: langchain@1.4.4(@langchain/core@1.2.3(openai@6.49.0(zod@4.4.3)))(openai@6.49.0(zod@4.4.3)): dependencies: '@langchain/core': 1.2.3(openai@6.49.0(zod@4.4.3)) - '@langchain/langgraph': 1.3.7(@langchain/core@1.2.3(openai@6.49.0(zod@4.4.3)))(zod@4.4.3) + '@langchain/langgraph': 1.4.8(@langchain/core@1.2.3(openai@6.49.0(zod@4.4.3)))(zod@4.4.3) '@langchain/langgraph-checkpoint': 1.0.4(@langchain/core@1.2.3(openai@6.49.0(zod@4.4.3))) langsmith: 0.7.5(openai@6.49.0(zod@4.4.3)) zod: 4.4.3 @@ -642,7 +933,6 @@ snapshots: - svelte - vue - ws - - zod-to-json-schema langsmith@0.7.5(openai@6.49.0(zod@4.4.3)): dependencies: @@ -749,6 +1039,12 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 + tsx@4.23.1: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + typescript@6.0.3: {} undici-types@7.24.6: {} diff --git a/src/agent/curator-graph.test.ts b/src/agent/curator-graph.test.ts new file mode 100644 index 0000000..878428a --- /dev/null +++ b/src/agent/curator-graph.test.ts @@ -0,0 +1,61 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { runCuratorGraph } from './curator.graph.ts' +import { TrendingRepo } from '../tools/trending-scrape.tool.ts' + +const sampleRepos: TrendingRepo[] = [ + { name: 'foo/bar', url: 'https://github.com/foo/bar', description: 'test repo', language: 'typescript', stars: 100, todayStars: 5 }, +] + +const validCuratorOutput = JSON.stringify({ + repos: [ + { + repo_name: 'foo/bar', + url: 'https://github.com/foo/bar', + description: 'test repo', + language: 'typescript', + stars: 100, + today_stars: 5, + summary: 'a test repo', + tags: ['typescript'], + }, + ], +}) + +test('returns curated repos when the curator succeeds on the first attempt', async () => { + const fakeCurate = async () => validCuratorOutput + + const result = await runCuratorGraph(sampleRepos, fakeCurate) + + assert.equal(result.curated?.length, 1) + assert.equal(result.curated?.[0].repo_name, 'foo/bar') + assert.equal(result.error, null) +}) + +test('retries once when the curator returns invalid JSON, then succeeds', async () => { + let calls = 0 + const fakeCurate = async () => { + calls++ + return calls === 1 ? 'not valid json {{{' : validCuratorOutput + } + + const result = await runCuratorGraph(sampleRepos, fakeCurate) + + assert.equal(calls, 2) + assert.equal(result.curated?.[0].repo_name, 'foo/bar') + assert.equal(result.error, null) +}) + +test('gives up after exhausting retries — curated is null, not an empty array, and error is set', async () => { + let calls = 0 + const fakeCurate = async () => { + calls++ + return 'always broken' + } + + const result = await runCuratorGraph(sampleRepos, fakeCurate) + + assert.equal(calls, 2) // 1 initial attempt + 1 retry, then give up + assert.equal(result.curated, null) + assert.ok(result.error, 'expected a validation error to be surfaced, not swallowed') +}) diff --git a/src/agent/curator.graph.ts b/src/agent/curator.graph.ts new file mode 100644 index 0000000..e99a66b --- /dev/null +++ b/src/agent/curator.graph.ts @@ -0,0 +1,39 @@ +import { StateGraph, StateSchema, START, END } from '@langchain/langgraph' +import { z } from 'zod' +import { TrendingRepoSchema, type CuratedRepo } from '../schemas/index.js' + +import { parseJson } from './utils.ts' +import { TrendingRepo } from '../tools/trending-scrape.tool.ts' + +const CuratedRepoOutputSchema = z.object({ repos: z.array(TrendingRepoSchema) }) + +export type CurateFn = (repos: TrendingRepo[], feedback?: string) => Promise +export type CuratorResult = { curated: CuratedRepo[] | null; error: string | null } + +const MAX_ATTEMPTS = 2 + +const CuratorState = new StateSchema({ + repos: z.custom(), + curated: z.custom(), + error: z.custom(), + attempts: z.custom(), +}) + +export async function runCuratorGraph(repos: TrendingRepo[], curate: CurateFn): Promise { + const graph = new StateGraph(CuratorState) + .addNode('curate', async (state) => { + const raw = await curate(state.repos, state.error || undefined) + const parsed = CuratedRepoOutputSchema.safeParse(parseJson(raw, null)) + + if (parsed.success) return { curated: parsed.data.repos, error: null } + + return { error: parsed.error.message, attempts: state.attempts + 1 } + }) + .addEdge(START, 'curate') + .addConditionalEdges('curate', (state) => (state.curated || state.attempts >= MAX_ATTEMPTS ? END : 'curate')) + .compile() + + const result = await graph.invoke({ repos, curated: null, error: null, attempts: 0 }) + + return { curated: result.curated, error: result.error } +} diff --git a/src/agent/index.ts b/src/agent/index.ts index 09ccaf5..2465709 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -7,72 +7,11 @@ import { trendingTelegramAgent } from './news-telegram.agent.js' import { trendingScrapeTool, type TrendingRepo } from '../tools/trending-scrape.tool.js' import { saveKpiRecord, saveTrendingRepos, getRecentRepoNames } from '../storage/own-db.js' import { sectionLogger } from '../utils/logger.js' - -type AgentResult = { messages: Array<{ _getType?: () => string; content: unknown }> } - -type CuratedRepo = { - repo_name: string - url: string - description: string - language: string - stars: number - today_stars: number - summary: string - tags: string[] -} +import { notifyError, parseJson, toolOutput } from './utils.ts' +import { AgentResult, CuratedRepo } from '../schemas/index.ts' +import { runCuratorGraph } from './curator.graph.ts' export class WorkCoordinator { - // ── Shared helpers ──────────────────────────────────────────────────────── - - private static toolOutput(result: AgentResult, toolName: string): string { - const msg = result.messages.find((m) => m._getType?.() === 'tool' && (m as { name?: string }).name === toolName) - if (!msg) return '' - - const content = msg.content - - // LangChain sometimes returns content as an array of content blocks - if (Array.isArray(content)) { - const block = content.find((c: unknown) => typeof c === 'object' && c !== null && (c as { type?: string }).type === 'text') - return block ? (block as { text: string }).text : JSON.stringify(content) - } - - return `${content ?? ''}` - } - - // Parse JSON, returning a fallback on any error instead of throwing. - private static parseJson(raw: string, fallback: T): T { - try { - return JSON.parse(raw) as T - } catch { - return fallback - } - } - - // Send a Telegram alert when an agent or job fails. - // Never throws — error notifications must not cause further errors. - private static async notifyError(context: string, error: unknown): Promise { - const token = process.env.TELEGRAM_BOT_TOKEN - const chatId = process.env.TELEGRAM_CHAT_ID - if (!token || !chatId) return - - const message = [ - `⚠️ Oh My Workers — Job Failed`, - ``, - `Where: ${context}`, - `Error: ${error instanceof Error ? error.message : String(error)}`, - ].join('\n') - - try { - await fetch(`https://api.telegram.org/bot${token}/sendMessage`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ chat_id: chatId, text: message, parse_mode: 'HTML' }), - }) - } catch { - // intentionally silent — notifyError must never throw - } - } - // ── Automated (crontab) — no human input required ───────────────────────── static async runCleanup(): Promise { @@ -87,7 +26,7 @@ export class WorkCoordinator { sectionLogger(`✅ Cleanup complete for ${today}`) } catch (err) { console.error('❌ Cleanup agent failed:', err instanceof Error ? err.message : err) - await WorkCoordinator.notifyError('Cleanup agent', err) + await notifyError('Cleanup agent', err) } } @@ -97,19 +36,21 @@ export class WorkCoordinator { // so this swallows errors and returns an empty activity list. private static async collectManualActivities(): Promise<{ manualResult: AgentResult | null; activities: string[] }> { let manualResult: AgentResult | null = null + try { manualResult = await manualKpiAgent.invoke({ messages: [{ role: 'user', content: 'Ask the engineer what else they did today.' }], }) } catch (err) { console.error('❌ Manual KPI agent failed:', err instanceof Error ? err.message : err) - await WorkCoordinator.notifyError('Manual KPI agent', err) + await notifyError('Manual KPI agent', err) // non-critical — continue with GitHub data only } let activities: string[] = [] + if (manualResult) { - const parsed = WorkCoordinator.parseJson<{ activities?: string[] }>(WorkCoordinator.toolOutput(manualResult, 'collect_manual_kpi_input'), {}) + const parsed = parseJson<{ activities?: string[] }>(toolOutput(manualResult, 'collect_manual_kpi_input'), {}) activities = parsed.activities ?? [] } @@ -120,8 +61,8 @@ export class WorkCoordinator { private static async saveGithubOnlyKpi(githubResult: AgentResult, now: string): Promise { console.log('\n⏭️ No manual activities provided — skipping diary, saving GitHub KPI only.\n') - const githubData = WorkCoordinator.parseJson<{ summary?: string; commits?: unknown[]; pullRequests?: unknown[] }>( - WorkCoordinator.toolOutput(githubResult, 'fetch_github_activity'), + const githubData = parseJson<{ summary?: string; commits?: unknown[]; pullRequests?: unknown[] }>( + toolOutput(githubResult, 'fetch_github_activity'), {} ) @@ -137,7 +78,8 @@ export class WorkCoordinator { console.log('✅ GitHub KPI record saved.') } catch (err) { console.error('❌ Failed to save KPI record:', err instanceof Error ? err.message : err) - await WorkCoordinator.notifyError('saveKpiRecord', err) + + await notifyError('saveKpiRecord', err) } } @@ -150,13 +92,14 @@ export class WorkCoordinator { messages: [ { role: 'user', - content: `Write and save today's KPI report using the data below.\n\nGitHub activity:\n${WorkCoordinator.toolOutput(githubResult, 'fetch_github_activity')}\n\nManual activities:\n${manualResult ? WorkCoordinator.toolOutput(manualResult, 'collect_manual_kpi_input') : ''}`, + content: `Write and save today's KPI report using the data below.\n\nGitHub activity:\n${toolOutput(githubResult, 'fetch_github_activity')}\n\nManual activities:\n${manualResult ? toolOutput(manualResult, 'collect_manual_kpi_input') : ''}`, }, ], }) } catch (err) { console.error('❌ Diary agent failed:', err instanceof Error ? err.message : err) - await WorkCoordinator.notifyError('Diary agent', err) + + await notifyError('Diary agent', err) } } @@ -171,7 +114,7 @@ export class WorkCoordinator { if (!username) { console.error('❌ GitHub username not set in environment variables.') - await WorkCoordinator.notifyError('Daily jobs startup', 'TARGET_GITHUB_USERNAME is not set') + await notifyError('Daily jobs startup', 'TARGET_GITHUB_USERNAME is not set') return } @@ -189,13 +132,16 @@ export class WorkCoordinator { if (cleanupSettled.status === 'rejected') { console.error('❌ Cleanup agent failed:', cleanupSettled.reason) - await WorkCoordinator.notifyError('Cleanup agent (daily jobs)', cleanupSettled.reason) + + await notifyError('Cleanup agent (daily jobs)', cleanupSettled.reason) // non-critical — continue with GitHub + diary } if (githubSettled.status === 'rejected') { console.error('❌ GitHub agent failed:', githubSettled.reason) - await WorkCoordinator.notifyError('GitHub agent', githubSettled.reason) + + await notifyError('GitHub agent', githubSettled.reason) + return // can't generate a meaningful KPI report without GitHub data } @@ -224,10 +170,13 @@ export class WorkCoordinator { try { const raw = await trendingScrapeTool.invoke({ languages: ['typescript', 'javascript'] }) + return JSON.parse(raw) as TrendingRepo[] } catch (err) { console.error('❌ Trending scrape failed:', err instanceof Error ? err.message : err) - await WorkCoordinator.notifyError('Trending scrape', err) + + await notifyError('Trending scrape', err) + return null } } @@ -247,25 +196,25 @@ export class WorkCoordinator { } } - // Step 3: curate + summarize via LLM. Returns null on failure (already notified). - private static async curateRepos(newRepos: TrendingRepo[]): Promise { + // Step 3: curate + summarize via LLM. One attempt — retries and final + // failure notification are handled by runCuratorGraph / runNewsAgent. + private static async curateRepos(newRepos: TrendingRepo[], feedback?: string): Promise { console.log('⚡️ Curating top repos...\n') + const content = feedback + ? `Curate the top trending GitHub repos from these results. Pick the top 5-8 most interesting ones:\n\n${JSON.stringify(newRepos)}\n\nYour previous response could not be parsed (${feedback}). Return valid JSON only, matching the curate_trending_repos tool schema exactly.` + : `Curate the top trending GitHub repos from these results. Pick the top 5-8 most interesting ones:\n\n${JSON.stringify(newRepos)}` + try { const curateResult = await trendingCuratorAgent.invoke({ - messages: [ - { - role: 'user', - content: `Curate the top trending GitHub repos from these results. Pick the top 5-8 most interesting ones:\n\n${JSON.stringify(newRepos)}`, - }, - ], + messages: [{ role: 'user', content }], }) - const curated = WorkCoordinator.parseJson<{ repos?: CuratedRepo[] }>(WorkCoordinator.toolOutput(curateResult, 'curate_trending_repos'), {}) - return curated.repos ?? [] + + return toolOutput(curateResult, 'curate_trending_repos') } catch (err) { - console.error('❌ Trending curation failed:', err instanceof Error ? err.message : err) - await WorkCoordinator.notifyError('Trending curator agent', err) - return null + console.error('❌ Trending curation attempt failed:', err instanceof Error ? err.message : err) + + return '' } } @@ -285,7 +234,7 @@ export class WorkCoordinator { return true } catch (err) { console.error('❌ Telegram delivery failed:', err instanceof Error ? err.message : err) - await WorkCoordinator.notifyError('Trending Telegram agent', err) + await notifyError('Trending Telegram agent', err) return false } } @@ -311,7 +260,7 @@ export class WorkCoordinator { console.log(`✅ Saved ${repos.length} trending repos to database.`) } catch (err) { console.error('❌ Failed to save trending repos:', err instanceof Error ? err.message : err) - await WorkCoordinator.notifyError('saveTrendingRepos', err) + await notifyError('saveTrendingRepos', err) } } @@ -341,19 +290,26 @@ export class WorkCoordinator { } // ── Step 3: Curate and summarize via LLM ──────────────────────────────── - const repos = await WorkCoordinator.curateRepos(newRepos) - if (!repos) return + const { curated, error } = await runCuratorGraph(newRepos, WorkCoordinator.curateRepos) + + if (!curated) { + console.error('❌ Trending curation failed after retries:', error) + + await notifyError('Trending curator agent', error ?? 'curation failed after retries') + + return + } - if (!repos.length) { + if (!curated.length) { console.log('⏭️ No repos curated — skipping send and save.\n') return } // ── Step 4: Send via Telegram ─────────────────────────────────────────── - const sent = await WorkCoordinator.sendTelegram(repos) + const sent = await WorkCoordinator.sendTelegram(curated) // ── Step 5: Save to DB ────────────────────────────────────────────────── - await WorkCoordinator.saveTrending(repos, sent, now) + await WorkCoordinator.saveTrending(curated, sent, now) sectionLogger(`✅ GitHub Trending job complete for ${today}`) } diff --git a/src/agent/llm.ts b/src/agent/llm.ts index bb78c13..660f3ba 100644 --- a/src/agent/llm.ts +++ b/src/agent/llm.ts @@ -1,11 +1,9 @@ import { ChatOpenAI } from '@langchain/openai' import { DEFAULT_LLM, DEFAULT_LLM_BASE_URL } from '../constants/index.js' -// ponytail: one model factory for every agent. Any OpenAI-compatible provider — -// OpenRouter (default), NVIDIA NIM, a local model — selected by LLM_BASE_URL. -// Named for the protocol rather than a vendor, since the base URL decides where it goes. export const createLlm = (temperature = 0) => { const apiKey = process.env.LLM_API_KEY + if (!apiKey) throw new Error('LLM_API_KEY is not set — the agents have no model to call') return new ChatOpenAI({ diff --git a/src/agent/utils.ts b/src/agent/utils.ts new file mode 100644 index 0000000..0c31c4f --- /dev/null +++ b/src/agent/utils.ts @@ -0,0 +1,48 @@ +import { AgentResult } from '../schemas/index.ts' + +export function toolOutput(result: AgentResult, toolName: string): string { + const msg = result.messages.find((m) => m._getType?.() === 'tool' && (m as { name?: string }).name === toolName) + if (!msg) return '' + + const content = msg.content + + // LangChain sometimes returns content as an array of content blocks + if (Array.isArray(content)) { + const block = content.find((c: unknown) => typeof c === 'object' && c !== null && (c as { type?: string }).type === 'text') + return block ? (block as { text: string }).text : JSON.stringify(content) + } + + return `${content ?? ''}` +} + +export function parseJson(raw: string, fallback: T): T { + try { + return JSON.parse(raw) as T + } catch { + return fallback + } +} + +export async function notifyError(context: string, error: unknown): Promise { + const token = process.env.TELEGRAM_BOT_TOKEN + const chatId = process.env.TELEGRAM_CHAT_ID + + if (!token || !chatId) return + + const message = [ + `⚠️ Oh My Workers — Job Failed`, + ``, + `Where: ${context}`, + `Error: ${error instanceof Error ? error.message : String(error)}`, + ].join('\n') + + try { + await fetch(`https://api.telegram.org/bot${token}/sendMessage`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ chat_id: chatId, text: message, parse_mode: 'HTML' }), + }) + } catch (error) { + console.error('Failed to notify error to Telegram', { message: error instanceof Error ? error.message : String(error) }) + } +} diff --git a/src/assets/images/github-actions-secrets.png b/src/assets/images/github-actions-secrets.png index 6d9339a..9553100 100644 Binary files a/src/assets/images/github-actions-secrets.png and b/src/assets/images/github-actions-secrets.png differ diff --git a/src/assets/images/langsmith-logs.png b/src/assets/images/langsmith-logs.png new file mode 100644 index 0000000..b0267b5 Binary files /dev/null and b/src/assets/images/langsmith-logs.png differ diff --git a/src/index.ts b/src/index.ts index c514895..8732de0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,8 @@ import { startScheduler } from './jobs/scheduler.js' import { jobs, findJobByCliArg } from './jobs/registry.js' function printJobs(): void { - console.log('Available jobs:') + console.log('Available jobs 📋:') + for (const j of jobs) { const scheduledJobs = j.schedule ? ` [${j.schedule}]` : ' [manual]' diff --git a/src/jobs/registry.ts b/src/jobs/registry.ts index 098c14b..afd0ba8 100644 --- a/src/jobs/registry.ts +++ b/src/jobs/registry.ts @@ -41,7 +41,7 @@ export const jobs: Job[] = [ }, ] -export function findJobByName(name: string): Job | undefined { +function findJobByName(name: string): Job | undefined { return jobs.find((j) => j.name === name) } diff --git a/src/schemas/index.ts b/src/schemas/index.ts index 9a5122f..005b786 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -87,3 +87,7 @@ export type DiaryEntry = z.infer export type CleanupResult = z.infer export type TrendingRepo = z.infer export type TrendingRepoLog = z.infer + +export type CuratedRepo = z.infer + +export type AgentResult = { messages: Array<{ _getType?: () => string; content: unknown }> } diff --git a/tsconfig.json b/tsconfig.json index b8a5b86..f8399fb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,9 @@ "strict": true, "esModuleInterop": true, "resolveJsonModule": true, - "skipLibCheck": true + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"]