diff --git a/.env.example b/.env.example
index 95a7506..c990313 100644
--- a/.env.example
+++ b/.env.example
@@ -1,5 +1,9 @@
-# Anthropic
-ANTHROPIC_API_KEY=your_anthropic_api_key_here
+# LLM — any OpenAI-compatible provider. Only the key is required;
+# it defaults to OpenRouter + nvidia/nemotron-3-ultra-550b-a55b:free
+LLM_API_KEY=sk-or-v1-your_openrouter_key_here
+# Optional overrides, e.g. to use NVIDIA NIM instead:
+# LLM_BASE_URL=https://integrate.api.nvidia.com/v1
+# LLM_MODEL=nvidia/nemotron-3-ultra-550b-a55b
# GitHub
GITHUB_TOKEN=your_github_personal_access_token_here
TARGET_GITHUB_USERNAME=your_github_username_here
diff --git a/.github/workflows/cleanup.yml b/.github/workflows/cleanup.yml
index 3b65a4d..0656b11 100644
--- a/.github/workflows/cleanup.yml
+++ b/.github/workflows/cleanup.yml
@@ -31,7 +31,7 @@ jobs:
- name: Run cleanup
env:
- ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+ LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
DATABASE_URL: ${{ secrets.NEON_WORK_COORDINATOR_DB_URL }}
COMPANY_DB_URL: ${{ secrets.NEON_MOCK_COMPANY_DB_URL }}
COMPANY_CLEANUP_TABLE: ${{ secrets.COMPANY_CLEANUP_TABLE }}
diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml
index 1dad9cb..829f18c 100644
--- a/.github/workflows/code-quality.yml
+++ b/.github/workflows/code-quality.yml
@@ -71,7 +71,10 @@ jobs:
fetch-depth: 0 # full history so Fallow's change-hotspot analysis works
- name: Run Fallow analysis
- uses: fallow-rs/fallow@v2
+ # v3+: platform packages ship a single multicall `fallow` binary. The v2
+ # action still verified `fallow-lsp`/`fallow-mcp`, which no published CLI
+ # provides any more, so every run failed at binary verification.
+ uses: fallow-rs/fallow@v3
with:
# Advisory mode: still annotate the PR with findings, but don't fail
# the check on warnings/issues (default for this input is `true`).
diff --git a/.github/workflows/daily-kpi.yml b/.github/workflows/daily-kpi.yml
index 14cc4ca..10df088 100644
--- a/.github/workflows/daily-kpi.yml
+++ b/.github/workflows/daily-kpi.yml
@@ -31,7 +31,7 @@ jobs:
- name: Run daily KPI report
env:
- ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+ LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
DATABASE_URL: ${{ secrets.NEON_WORK_COORDINATOR_DB_URL }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TARGET_GITHUB_USERNAME: ${{ secrets.TARGET_GITHUB_USERNAME }}
diff --git a/.github/workflows/morning-news.yml b/.github/workflows/morning-news.yml
index 89d0514..ab5bf39 100644
--- a/.github/workflows/morning-news.yml
+++ b/.github/workflows/morning-news.yml
@@ -30,7 +30,7 @@ jobs:
- name: Run news agent
env:
- ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+ LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
DATABASE_URL: ${{ secrets.NEON_WORK_COORDINATOR_DB_URL }}
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
diff --git a/.github/workflows/seed-mock-users.yml b/.github/workflows/seed-mock-users.yml
index e18ac30..ed8498c 100644
--- a/.github/workflows/seed-mock-users.yml
+++ b/.github/workflows/seed-mock-users.yml
@@ -1,11 +1,13 @@
name: Daily Mock User Seeding
on:
- schedule:
- # 6:30 AM UTC = 4:30 PM AEST (UTC+10, Apr–Oct)
- # Note: during AEDT (Oct–Apr, UTC+11) this fires at 5:30 PM Sydney time
- - cron: '30 6 * * *'
- workflow_dispatch: # allows manual trigger from GitHub UI
+ # Disabled — mock user seeding no longer runs on a schedule.
+ # Uncomment to re-enable; the job itself is unchanged.
+ # schedule:
+ # # 6:30 AM UTC = 4:30 PM AEST (UTC+10, Apr–Oct)
+ # # Note: during AEDT (Oct–Apr, UTC+11) this fires at 5:30 PM Sydney time
+ # - cron: '30 6 * * *'
+ workflow_dispatch: # still runnable by hand from the GitHub UI
jobs:
seed:
diff --git a/README.md b/README.md
index 555ef19..90c0789 100644
--- a/README.md
+++ b/README.md
@@ -83,7 +83,7 @@ Push to GitHub, then add these secrets under **Settings → Secrets and variable
| Secret | Value |
|---|---|
-| `ANTHROPIC_API_KEY` | from console.anthropic.com |
+| `LLM_API_KEY` | from openrouter.ai/keys (free tier works) |
| `NEON_WORK_COORDINATOR_DB_URL` | Neon connection string (remove `&channel_binding=require`) |
| `NEON_MOCK_COMPANY_DB_URL` | Neon connection string for company DB |
| `COMPANY_CLEANUP_TABLE` | table to clean, e.g. `mockTestUsers` |
diff --git a/package.json b/package.json
index 0a42408..e2ad69e 100644
--- a/package.json
+++ b/package.json
@@ -37,8 +37,8 @@
"license": "ISC",
"packageManager": "pnpm@10.7.1",
"dependencies": {
- "@langchain/anthropic": "^1.3.25",
- "@langchain/core": "^1.1.36",
+ "@langchain/core": "^1.2.3",
+ "@langchain/openai": "^1.5.5",
"@octokit/rest": "^22.0.1",
"dotenv": "^17.3.1",
"langchain": "^1.2.37",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 0bd3041..95a5529 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,12 +8,12 @@ importers:
.:
dependencies:
- '@langchain/anthropic':
- specifier: ^1.3.25
- version: 1.4.0(@langchain/core@1.1.48)
'@langchain/core':
- specifier: ^1.1.36
- version: 1.1.48
+ specifier: ^1.2.3
+ version: 1.2.3(openai@6.49.0(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)))
'@octokit/rest':
specifier: ^22.0.1
version: 22.0.1
@@ -22,7 +22,7 @@ importers:
version: 17.4.2
langchain:
specifier: ^1.2.37
- version: 1.4.4(@langchain/core@1.1.48)
+ version: 1.4.4(@langchain/core@1.2.3(openai@6.49.0(zod@4.4.3)))(openai@6.49.0(zod@4.4.3))
node-cron:
specifier: ^4.2.1
version: 4.2.1
@@ -54,19 +54,6 @@ importers:
packages:
- '@anthropic-ai/sdk@0.95.2':
- resolution: {integrity: sha512-Egddwo3sheo1PzUrMkZnH6VkQYwS0h/b/i8vSK8Ta9M45UQipAMeDFH57dYuDAfXMEUUGeKw6CMlremgMZgrSQ==}
- hasBin: true
- peerDependencies:
- zod: ^3.25.0 || ^4.0.0
- peerDependenciesMeta:
- zod:
- optional: true
-
- '@babel/runtime@7.29.7':
- resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
- engines: {node: '>=6.9.0'}
-
'@cfworker/json-schema@4.1.1':
resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==}
@@ -84,14 +71,8 @@ packages:
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
- '@langchain/anthropic@1.4.0':
- resolution: {integrity: sha512-rs1yVydrHjyiD31uChdCnKZpmDuKa0Bpz8Raiy9GvqnqmfXPMe0oOrap/2paE+NRSinDbtax8mMpP/yv8EbO1A==}
- engines: {node: '>=20'}
- peerDependencies:
- '@langchain/core': ^1.1.47
-
- '@langchain/core@1.1.48':
- resolution: {integrity: sha512-fQU6Guyb1pwc2fEplmA8FPbKfOMAofjnyJzExevro0FxEiuGHE18Ov/ZHmT9trWCDTZRI9eW1VIc6aChxV8pAQ==}
+ '@langchain/core@1.2.3':
+ resolution: {integrity: sha512-F+L5SsciykwDl7eDxacnhDTcWe1IF6jetzfkvI5PPfq6ogWHO7xcjU90SGh/3lqbbS0tgun+qF01KIqxawrCsA==}
engines: {node: '>=20'}
'@langchain/langgraph-checkpoint@1.0.4':
@@ -129,6 +110,12 @@ packages:
zod-to-json-schema:
optional: true
+ '@langchain/openai@1.5.5':
+ resolution: {integrity: sha512-wX7dwb9z4nf5FHXlIl/X2mk08pzonvRHCt1D4+s1zXLP0duYDC95j7dulPIQJ6fmhbyYQc9Ki8mEhY/D1lB8kw==}
+ engines: {node: '>=20'}
+ peerDependencies:
+ '@langchain/core': ^1.2.2
+
'@langchain/protocol@0.0.16':
resolution: {integrity: sha512-ws+J7MaHyhO5dG7f0vdyHQiUn9hoCnki0f3crJPa4MCTGzcRC39jYSCghyrGtBPYQnZbUQiGyRVpW3z3M8IpJg==}
@@ -184,9 +171,6 @@ packages:
'@octokit/types@16.0.0':
resolution: {integrity: sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==}
- '@stablelib/base64@1.0.1':
- resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
-
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
@@ -253,9 +237,6 @@ packages:
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
- fast-sha256@1.3.0:
- resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
-
is-network-error@1.3.2:
resolution: {integrity: sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==}
engines: {node: '>=16'}
@@ -263,10 +244,6 @@ packages:
js-tiktoken@1.0.21:
resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==}
- json-schema-to-ts@3.1.1:
- resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==}
- engines: {node: '>=16'}
-
json-with-bigint@3.5.8:
resolution: {integrity: sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==}
@@ -307,6 +284,26 @@ packages:
resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==}
engines: {node: '>=6.0.0'}
+ openai@6.49.0:
+ resolution: {integrity: sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg==}
+ peerDependencies:
+ '@aws-sdk/credential-provider-node': '>=3.972.0 <4'
+ '@smithy/hash-node': '>=4.3.0 <5'
+ '@smithy/signature-v4': '>=5.4.0 <6'
+ ws: ^8.18.0
+ zod: ^3.25 || ^4.0
+ peerDependenciesMeta:
+ '@aws-sdk/credential-provider-node':
+ optional: true
+ '@smithy/hash-node':
+ optional: true
+ '@smithy/signature-v4':
+ optional: true
+ ws:
+ optional: true
+ zod:
+ optional: true
+
p-finally@1.0.0:
resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==}
engines: {node: '>=4'}
@@ -390,12 +387,6 @@ packages:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
- standardwebhooks@1.0.0:
- resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
-
- ts-algebra@2.0.0:
- resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==}
-
ts-node@10.9.2:
resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==}
hasBin: true
@@ -441,15 +432,6 @@ packages:
snapshots:
- '@anthropic-ai/sdk@0.95.2(zod@4.4.3)':
- dependencies:
- json-schema-to-ts: 3.1.1
- standardwebhooks: 1.0.0
- optionalDependencies:
- zod: 4.4.3
-
- '@babel/runtime@7.29.7': {}
-
'@cfworker/json-schema@4.1.1': {}
'@cspotcode/source-map-support@0.8.1':
@@ -465,18 +447,12 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
- '@langchain/anthropic@1.4.0(@langchain/core@1.1.48)':
- dependencies:
- '@anthropic-ai/sdk': 0.95.2(zod@4.4.3)
- '@langchain/core': 1.1.48
- zod: 4.4.3
-
- '@langchain/core@1.1.48':
+ '@langchain/core@1.2.3(openai@6.49.0(zod@4.4.3))':
dependencies:
'@cfworker/json-schema': 4.1.1
'@standard-schema/spec': 1.1.0
js-tiktoken: 1.0.21
- langsmith: 0.7.5
+ langsmith: 0.7.5(openai@6.49.0(zod@4.4.3))
mustache: 4.2.0
p-queue: 6.6.2
zod: 4.4.3
@@ -487,25 +463,25 @@ snapshots:
- openai
- ws
- '@langchain/langgraph-checkpoint@1.0.4(@langchain/core@1.1.48)':
+ '@langchain/langgraph-checkpoint@1.0.4(@langchain/core@1.2.3(openai@6.49.0(zod@4.4.3)))':
dependencies:
- '@langchain/core': 1.1.48
+ '@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.1.48)':
+ '@langchain/langgraph-sdk@1.9.20(@langchain/core@1.2.3(openai@6.49.0(zod@4.4.3)))':
dependencies:
- '@langchain/core': 1.1.48
+ '@langchain/core': 1.2.3(openai@6.49.0(zod@4.4.3))
'@langchain/protocol': 0.0.16
'@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.1.48)(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)':
dependencies:
- '@langchain/core': 1.1.48
- '@langchain/langgraph-checkpoint': 1.0.4(@langchain/core@1.1.48)
- '@langchain/langgraph-sdk': 1.9.20(@langchain/core@1.1.48)
+ '@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
'@standard-schema/spec': 1.1.0
uuid: 14.0.0
@@ -516,6 +492,18 @@ snapshots:
- svelte
- vue
+ '@langchain/openai@1.5.5(@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))
+ js-tiktoken: 1.0.21
+ openai: 6.49.0(zod@4.4.3)
+ zod: 4.4.3
+ transitivePeerDependencies:
+ - '@aws-sdk/credential-provider-node'
+ - '@smithy/hash-node'
+ - '@smithy/signature-v4'
+ - ws
+
'@langchain/protocol@0.0.16': {}
'@octokit/auth-token@6.0.0': {}
@@ -581,8 +569,6 @@ snapshots:
dependencies:
'@octokit/openapi-types': 27.0.0
- '@stablelib/base64@1.0.1': {}
-
'@standard-schema/spec@1.1.0': {}
'@tsconfig/node10@1.0.12': {}
@@ -631,27 +617,20 @@ snapshots:
eventemitter3@5.0.4: {}
- fast-sha256@1.3.0: {}
-
is-network-error@1.3.2: {}
js-tiktoken@1.0.21:
dependencies:
base64-js: 1.5.1
- json-schema-to-ts@3.1.1:
- dependencies:
- '@babel/runtime': 7.29.7
- ts-algebra: 2.0.0
-
json-with-bigint@3.5.8: {}
- langchain@1.4.4(@langchain/core@1.1.48):
+ 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.1.48
- '@langchain/langgraph': 1.3.7(@langchain/core@1.1.48)(zod@4.4.3)
- '@langchain/langgraph-checkpoint': 1.0.4(@langchain/core@1.1.48)
- langsmith: 0.7.5
+ '@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-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
transitivePeerDependencies:
- '@opentelemetry/api'
@@ -665,9 +644,11 @@ snapshots:
- ws
- zod-to-json-schema
- langsmith@0.7.5:
+ langsmith@0.7.5(openai@6.49.0(zod@4.4.3)):
dependencies:
p-queue: 6.6.2
+ optionalDependencies:
+ openai: 6.49.0(zod@4.4.3)
make-error@1.3.6: {}
@@ -675,6 +656,10 @@ snapshots:
node-cron@4.2.1: {}
+ openai@6.49.0(zod@4.4.3):
+ optionalDependencies:
+ zod: 4.4.3
+
p-finally@1.0.0: {}
p-queue@6.6.2:
@@ -746,13 +731,6 @@ snapshots:
split2@4.2.0: {}
- standardwebhooks@1.0.0:
- dependencies:
- '@stablelib/base64': 1.0.1
- fast-sha256: 1.3.0
-
- ts-algebra@2.0.0: {}
-
ts-node@10.9.2(@types/node@25.9.2)(typescript@6.0.3):
dependencies:
'@cspotcode/source-map-support': 0.8.1
diff --git a/src/agent/cleanup.agent.ts b/src/agent/cleanup.agent.ts
index b5a7f15..ba86014 100644
--- a/src/agent/cleanup.agent.ts
+++ b/src/agent/cleanup.agent.ts
@@ -1,13 +1,10 @@
import { createAgent, toolCallLimitMiddleware } from 'langchain'
-import { ChatAnthropic } from '@langchain/anthropic'
+import { createLlm } from './llm.js'
import { cleanupTool } from '../tools/cleanup.tool.js'
import { CLEANUP_PROMPT } from './prompt.js'
-import { DEFAULT_LLM } from '../constants/index.js'
-
-const llm = new ChatAnthropic({ model: DEFAULT_LLM, temperature: 0 })
export const cleanupAgent = createAgent({
- model: llm,
+ model: createLlm(),
tools: [cleanupTool],
systemPrompt: CLEANUP_PROMPT,
middleware: [toolCallLimitMiddleware({ runLimit: 1, exitBehavior: 'end' })],
diff --git a/src/agent/diary.agent.ts b/src/agent/diary.agent.ts
index 7266bb3..e535143 100644
--- a/src/agent/diary.agent.ts
+++ b/src/agent/diary.agent.ts
@@ -1,13 +1,10 @@
import { createAgent, toolCallLimitMiddleware } from 'langchain'
-import { ChatAnthropic } from '@langchain/anthropic'
+import { createLlm } from './llm.js'
import { diaryTool } from '../tools/diary.tool.js'
import { DIARY_PROMPT } from './prompt.js'
-import { DEFAULT_LLM } from '../constants/index.js'
-
-const llm = new ChatAnthropic({ model: DEFAULT_LLM, temperature: 0 })
export const diaryAgent = createAgent({
- model: llm,
+ model: createLlm(),
tools: [diaryTool],
systemPrompt: DIARY_PROMPT,
middleware: [toolCallLimitMiddleware({ runLimit: 1, exitBehavior: 'end' })],
diff --git a/src/agent/github.agent.ts b/src/agent/github.agent.ts
index 92cfa5f..82cfa6a 100644
--- a/src/agent/github.agent.ts
+++ b/src/agent/github.agent.ts
@@ -1,13 +1,10 @@
import { createAgent, toolCallLimitMiddleware } from 'langchain'
-import { ChatAnthropic } from '@langchain/anthropic'
+import { createLlm } from './llm.js'
import { githubTool } from '../tools/github.tool.js'
import { GITHUB_PROMPT } from './prompt.js'
-import { DEFAULT_LLM } from '../constants/index.js'
-
-const llm = new ChatAnthropic({ model: DEFAULT_LLM, temperature: 0 })
export const githubAgent = createAgent({
- model: llm,
+ model: createLlm(),
tools: [githubTool],
systemPrompt: GITHUB_PROMPT,
middleware: [toolCallLimitMiddleware({ runLimit: 1, exitBehavior: 'end' })],
diff --git a/src/agent/llm.ts b/src/agent/llm.ts
new file mode 100644
index 0000000..bb78c13
--- /dev/null
+++ b/src/agent/llm.ts
@@ -0,0 +1,17 @@
+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({
+ model: process.env.LLM_MODEL || DEFAULT_LLM,
+ apiKey,
+ temperature,
+ configuration: { baseURL: process.env.LLM_BASE_URL || DEFAULT_LLM_BASE_URL },
+ })
+}
diff --git a/src/agent/manual-kpi.agent.ts b/src/agent/manual-kpi.agent.ts
index d33ac15..a4db5d0 100644
--- a/src/agent/manual-kpi.agent.ts
+++ b/src/agent/manual-kpi.agent.ts
@@ -1,13 +1,10 @@
import { createAgent, toolCallLimitMiddleware } from 'langchain'
-import { ChatAnthropic } from '@langchain/anthropic'
+import { createLlm } from './llm.js'
import { manualKpiTool } from '../tools/manual-kpi.tool.js'
-import { DEFAULT_LLM } from '../constants/index.js'
import { MANUAL_PROMPT } from './prompt.js'
-const llm = new ChatAnthropic({ model: DEFAULT_LLM, temperature: 0 })
-
export const manualKpiAgent = createAgent({
- model: llm,
+ model: createLlm(),
tools: [manualKpiTool],
systemPrompt: MANUAL_PROMPT,
middleware: [toolCallLimitMiddleware({ runLimit: 1, exitBehavior: 'end' })],
diff --git a/src/agent/news-curator.agent.ts b/src/agent/news-curator.agent.ts
index 4966db8..e0a86af 100644
--- a/src/agent/news-curator.agent.ts
+++ b/src/agent/news-curator.agent.ts
@@ -1,13 +1,10 @@
import { createAgent, toolCallLimitMiddleware } from 'langchain'
-import { ChatAnthropic } from '@langchain/anthropic'
+import { createLlm } from './llm.js'
import { trendingCuratorTool } from '../tools/news-curator.tool.js'
import { TRENDING_CURATOR_PROMPT } from './prompt.js'
-import { DEFAULT_LLM } from '../constants/index.js'
-
-const llm = new ChatAnthropic({ model: DEFAULT_LLM, temperature: 0.5 })
export const trendingCuratorAgent = createAgent({
- model: llm,
+ model: createLlm(0.5),
tools: [trendingCuratorTool],
systemPrompt: TRENDING_CURATOR_PROMPT,
middleware: [toolCallLimitMiddleware({ runLimit: 1, exitBehavior: 'end' })],
diff --git a/src/agent/news-telegram.agent.ts b/src/agent/news-telegram.agent.ts
index 15f0a82..09d7c84 100644
--- a/src/agent/news-telegram.agent.ts
+++ b/src/agent/news-telegram.agent.ts
@@ -1,13 +1,10 @@
import { createAgent, toolCallLimitMiddleware } from 'langchain'
-import { ChatAnthropic } from '@langchain/anthropic'
+import { createLlm } from './llm.js'
import { trendingTelegramTool } from '../tools/news-telegram.tool.js'
import { TRENDING_TELEGRAM_PROMPT } from './prompt.js'
-import { DEFAULT_LLM } from '../constants/index.js'
-
-const llm = new ChatAnthropic({ model: DEFAULT_LLM, temperature: 0 })
export const trendingTelegramAgent = createAgent({
- model: llm,
+ model: createLlm(),
tools: [trendingTelegramTool],
systemPrompt: TRENDING_TELEGRAM_PROMPT,
middleware: [toolCallLimitMiddleware({ runLimit: 1, exitBehavior: 'end' })],
diff --git a/src/agent/prompt.ts b/src/agent/prompt.ts
index f36d7db..5e1a844 100644
--- a/src/agent/prompt.ts
+++ b/src/agent/prompt.ts
@@ -41,15 +41,16 @@ export const CLEANUP_PROMPT =
export const MANUAL_PROMPT =
'You are a manual input agent. Your only job is to call collect_manual_kpi_input once to ask the engineer what else they did today, then return the result as-is.'
-export const TRENDING_CURATOR_PROMPT = `You are a GitHub trending repos curator for a TypeScript/JavaScript/Node.js developer. You receive a list of scraped trending repos and your job is to select the top 1-3 most interesting ones.
+export const TRENDING_CURATOR_PROMPT = `You are a GitHub trending repos curator for a TypeScript/JavaScript/Node.js developer. You receive a list of scraped trending repos and your job is to select the top 5-8 most interesting ones.
When curating:
- Prioritize repos most relevant to TS/JS/Node developers: frameworks, libraries, dev tools, AI/LLM tooling, build tools, etc.
- Include repos in other languages ONLY if they have a direct impact on JS/TS workflows (e.g. a Rust-based bundler, a Go CLI tool for JS devs)
-- Write a 1-2 sentence summary for each repo explaining WHY it's interesting and what problem it solves
+- Write a 1-2 sentence summary for each repo explaining WHY it's interesting and what problem it solves. Keep it under 140 characters — it is read on a phone
+- Do not just restate the repo's own description; say what problem it solves for a TS/JS engineer
- Rank by relevance — the most useful repo for a TS/JS engineer should be first
-- Preserve all original fields (repo_name, url, description, language, stars, today_stars) and add the summary
-- Add 3-5 lowercase tags per repo for classification (e.g. "ai", "framework", "typescript", "bundler", "devtools", "testing", "cli", "database", "ui", "api"). Tags should be consistent across repos — reuse the same tag when the category matches
+- Preserve all original fields (repo_name, url, description, language, stars, today_stars) exactly as given — never alter a URL or star count
+- Add 3-5 tags per repo, chosen from EXACTLY this list: ai, framework, library, devtools, bundler, testing, cli, database, ui, api, runtime, security, typescript. Do not invent tags outside this list — they are rendered as searchable hashtags, so they must be identical day to day
Call curate_trending_repos with the result immediately.`
diff --git a/src/constants/index.ts b/src/constants/index.ts
index f1076f4..7210f2e 100644
--- a/src/constants/index.ts
+++ b/src/constants/index.ts
@@ -1,4 +1,5 @@
-export const DEFAULT_LLM = 'claude-haiku-4-5'
+export const DEFAULT_LLM = 'nvidia/nemotron-3-ultra-550b-a55b:free'
+export const DEFAULT_LLM_BASE_URL = 'https://openrouter.ai/api/v1'
export const COMPANY_CLEANUP_TABLE = 'mockTestUsers'
export const COMPANY_CLEANUP_THRESHOLD_DAYS = '30'
export const DEFAULT_CRONJOB_TIME = '0 17 * * *'
diff --git a/src/tools/news-curator.tool.ts b/src/tools/news-curator.tool.ts
index 52cad1e..664fa75 100644
--- a/src/tools/news-curator.tool.ts
+++ b/src/tools/news-curator.tool.ts
@@ -14,8 +14,12 @@ export const trendingCuratorTool = new DynamicStructuredTool({
language: z.string().describe('Primary language'),
stars: z.number().describe('Total star count'),
today_stars: z.number().describe('Stars gained today'),
- summary: z.string().describe('1-2 sentence summary of why this repo is interesting for a TS/JS developer'),
- tags: z.array(z.string()).describe('3-5 lowercase tags for classification e.g. ["ai", "framework", "typescript", "bundler", "devtools"]'),
+ summary: z.string().describe('1-2 sentence summary, under 140 characters, of why this repo is interesting for a TS/JS developer'),
+ tags: z
+ .array(z.string())
+ .describe(
+ '3-5 tags chosen only from: ai, framework, library, devtools, bundler, testing, cli, database, ui, api, runtime, security, typescript'
+ ),
})
)
.describe('Top 5-8 curated trending repos, ranked by relevance'),
diff --git a/src/tools/news-telegram.tool.ts b/src/tools/news-telegram.tool.ts
index 1131855..af79305 100644
--- a/src/tools/news-telegram.tool.ts
+++ b/src/tools/news-telegram.tool.ts
@@ -36,12 +36,15 @@ export const trendingTelegramTool = new DynamicStructuredTool({
const repoLines = repos.map((r, i) => {
const num = numberEmojis[i] ?? `${i + 1}.`
+ // ponytail: the prompt asks for <140 chars, this is what actually guarantees it.
+ // Telegram hard-fails the whole message past 4096, so the bound lives in code.
+ const summary = r.summary.length > 140 ? `${r.summary.slice(0, 137)}...` : r.summary
return [
`${num} ${escapeHtml(r.repo_name)}`,
`⭐ ${r.stars.toLocaleString()} (+${r.today_stars} today) · ${escapeHtml(r.language)}`,
- `${escapeHtml(r.summary)}`,
- `🏷 ${r.tags.map((t) => `#${t}`).join(' ')}`,
- `🔗 View on GitHub`,
+ `${escapeHtml(summary)}`,
+ `🏷 ${r.tags.map((t) => `#${escapeHtml(t)}`).join(' ')}`,
+ `🔗 View on GitHub`,
].join('\n')
})
diff --git a/src/tools/trending-scrape.tool.ts b/src/tools/trending-scrape.tool.ts
index b3c942c..a1ea9c8 100644
--- a/src/tools/trending-scrape.tool.ts
+++ b/src/tools/trending-scrape.tool.ts
@@ -31,8 +31,9 @@ function parseTrendingHtml(html: string): TrendingRepo[] {
const langMatch = block.match(/([\s\S]*?)<\/span>/)
const language = langMatch ? langMatch[1].trim() : ''
- // Total stars — matches the first standalone star count (not the "stars today" one)
- const starsMatch = block.match(/href="\/[^"]*\/stargazers"[^>]*>\s*([\d,]+)\s*<\/a>/)
+ // Total stars — the count sits after the star