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
-```
+
---
## 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 |

-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"]