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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Test, link and typecheck
name: Test, lint and typecheck

on:
pull_request:
Expand All @@ -8,8 +8,25 @@ on:

jobs:
test:
name: Test, link and typecheck
name: Test, lint and typecheck
runs-on: ubuntu-latest
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/agent_test

services:
postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_DB: agent_test
POSTGRES_PASSWORD: postgres
POSTGRES_USER: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres -d agent_test"
--health-interval 10s
--health-timeout 5s
--health-retries 5

steps:
- name: Checkout
Expand All @@ -29,9 +46,28 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Validate agent database migrations
run: pnpm --filter @labjm/agent db:check

- name: Verify agent schema migration coverage
run: |
pnpm --filter @labjm/agent db:generate --name ci-schema-drift
git diff --exit-code -- apps/agent/src/infrastructure/db/drizzle

- name: Apply agent database migrations
run: pnpm --filter @labjm/agent db:migrate

- name: Run tests
run: pnpm test

- name: Run agent database integration tests
env:
AGENT_DB_INTEGRATION_TESTS: '1'
run: >-
pnpm --filter @labjm/agent test --runInBand
src/infrastructure/db/services/agent-knowledge.integration.test.ts
src/infrastructure/db/services/agent-persistence.integration.test.ts

- name: Run typecheck
run: pnpm typecheck

Expand Down
14 changes: 9 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ pnpm --filter @labjm/web dev
pnpm --filter @labjm/api dev
pnpm --filter @labjm/agent dev
pnpm --filter @labjm/agent dev:server
pnpm --filter @labjm/agent db:push
pnpm --filter @labjm/agent db:generate
pnpm --filter @labjm/agent db:migrate
```

Use `pnpm` for dependency changes. Do not hand-edit `pnpm-lock.yaml`.
Expand All @@ -33,7 +34,7 @@ This is a pnpm + Turborepo monorepo. Packages are ESM TypeScript.

- `apps/web` — Next.js site and AI widget UI.
- `apps/api` — Hono API powering the web app.
- `apps/agent` — Hono + Chat SDK Telegram agent with AI SDK tools, memory, weather, and World Cup notifications.
- `apps/agent` — Hono + Chat SDK Telegram and iMessage agent with AI SDK tools, memory, weather, and World Cup notifications.
- `packages/ai` — AI widget tools and UI message types.
- `packages/schemas` — shared Zod schemas.
- `packages/types` — shared inferred types.
Expand All @@ -45,12 +46,13 @@ This is a pnpm + Turborepo monorepo. Packages are ESM TypeScript.
The Telegram agent is in `apps/agent`.

- Webhook entrypoint: `apps/agent/src/index.ts`.
- Chat SDK setup and Telegram handlers: `apps/agent/src/app/channels/index.ts`.
- Chat SDK setup and Telegram handlers: `apps/agent/src/app/bot/index.ts`.
- AI agent runtime and tool registration: `apps/agent/src/app/agent`.
- Memory services and context assembly: `apps/agent/src/app/memory`.
- Weather tools: `apps/agent/src/app/features/weather`.
- World Cup tools, polling, subscription, and notification delivery: `apps/agent/src/app/features/world-cup`.
- Drizzle schema and DB services: `apps/agent/src/infrastructure/db`.
- Google, OpenWeather, and World Cup provider clients: `apps/agent/src/infrastructure`.

Keep external systems behind service boundaries. Do not call provider SDKs, Telegram APIs, or database tables directly from unrelated application code.

Expand All @@ -62,7 +64,7 @@ Chat SDK normalizes platform events into `Thread` and `Message`.
- Use `message.author.userId` for Telegram allowlist checks and `message.userKey ?? message.author.userId` for the current memory identity convention.
- Use `thread.post({ markdown })` for Telegram responses.
- Keep webhook routes thin; place behavior in services where it can be tested without live Telegram.
- State tables owned by `@chat-adapter/state-pg` are excluded from Drizzle `db:push`. Do not add Drizzle ownership for `chat_state_*` tables.
- State tables owned by `@chat-adapter/state-pg` are excluded from Drizzle migrations. Do not add Drizzle ownership for `chat_state_*` tables.

## Environment

Expand All @@ -79,6 +81,8 @@ Important agent env vars:
- `DATABASE_URL` — Drizzle app tables and Chat SDK PostgreSQL state.
- `TELEGRAM_BOT_TOKEN`, `TELEGRAM_WEBHOOK_SECRET_TOKEN`, `TELEGRAM_BOT_USERNAME` — Telegram adapter config.
- `TELEGRAM_ALLOWED_USER_IDS` — optional comma-separated Telegram numeric user IDs allowed to use the bot. Leave unset to allow all users.
- `BLOOIO_API_KEY`, `BLOOIO_FROM_NUMBER`, `BLOOIO_WEBHOOK_SECRET` — Blooio-backed iMessage adapter config.
- `IMESSAGE_ALLOWED_NUMBERS` — optional comma-separated E.164 phone numbers allowed to use the iMessage agent. Leave unset to allow all numbers.
- `QSTASH_CURRENT_SIGNING_KEY`, `QSTASH_NEXT_SIGNING_KEY` — World Cup polling request verification.
- `OPENWEATHER_API_KEY` — weather and local-time tools.

Expand All @@ -93,7 +97,7 @@ Prefer tests around public module boundaries:
- World Cup subscription matching through `WorldCupSubscriptionService`.
- Memory context behavior through `AgentContextService` and `AgentMemoryService`.

Mock external boundaries: OpenAI/AI SDK calls, Telegram/Chat SDK posting, OpenWeather, World Cup API, QStash, and database services. Avoid live integration tests unless explicitly requested.
Mock external boundaries: OpenAI/AI SDK calls, Telegram/Chat SDK posting, OpenWeather, World Cup API, QStash, and database services. Database integration tests are gated by `AGENT_DB_INTEGRATION_TESTS=1` and should stay focused on persistence behavior that unit tests cannot prove.

## Code Style

Expand Down
7 changes: 5 additions & 2 deletions apps/agent/.env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@

OPENAI_API_KEY=""

AGENT_LOG_KNOWLEDGE_TOOL_CONTENT=""

TELEGRAM_BOT_TOKEN=""
TELEGRAM_WEBHOOK_SECRET_TOKEN=""
TELEGRAM_ALLOWED_USER_IDS=""

BLOOIO_API_KEY=""
BLOOIO_FROM_NUMBER=""
BLOOIO_WEBHOOK_SECRET=""
IMESSAGE_ALLOWED_NUMBERS=""

DATABASE_URL=""

QSTASH_CURRENT_SIGNING_KEY=""
Expand Down
95 changes: 59 additions & 36 deletions apps/agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ Custom AI agent. Provide Telegram bot credentials to deploy the agent and receiv
- **Local TUI** — terminal chat UI for testing the agent locally
- **Telegram bot** — webhook endpoint for direct messages, mentions, and subscribed threads
- **Memory** — PostgreSQL-backed chat state and agent memory
- **Knowledge** — hierarchical durable notes with hybrid retrieval and atomic corrections
- **Scheduling** — one-time and recurring reminders delivered through QStash
- **Google integration** — Calendar management and strictly read-only Gmail access through one OAuth connection
- **Nutrition tracking** — photo/text meal estimates, explicit confirmation, and daily calorie/macro progress

## How The Agent Works

Expand All @@ -34,7 +37,7 @@ flowchart LR
QStash --> Runner[schedule runner]
Runner --> Agent[scheduled AgentService call]
Agent --> Post[post to thread]
Post --> Advance[complete, reschedule, or fail]
Post --> Finalize[record sent and compare-and-set task state]
```

Core modules:
Expand All @@ -44,16 +47,21 @@ Core modules:
- `src/app/agent` owns the AI SDK agent, prompt, and tool registry.
- `src/app/memory` owns short-term transcripts, rolling summaries, and context assembly.
- `src/app/knowledge` owns durable tree notes, retrieval, and implicit ingestion.
- `src/app/features/nutrition` owns calorie goals, meal estimation workflows, and daily totals.
- `src/app/schedules` owns schedule creation, cancellation, execution, and recovery.
- `src/infrastructure/*` wraps AI, DB, QStash, logging, and app errors.
- `src/infrastructure/*` wraps provider HTTP clients, DB, QStash, logging, and app errors.

Incoming attachments are ephemeral. The agent accepts up to three files per message, with a 7 MB limit per file. JPEG, PNG, and WebP images are limited to 40 decoded megapixels, resized within 1536x1536, and stripped of metadata. PDFs, videos, and other files are passed through as current-turn model file inputs. Original attachment bytes are not persisted by the application.

Nutrition estimates follow `photo/text -> draft -> explicit confirmation -> daily totals`. PostgreSQL is the source of truth for goals and confirmed meals; conversational memory is not used as the nutrition ledger. Corrections replace the structured meal estimate, and deletion is soft so totals remain auditable.

Scheduling states:

- Tasks are `active`, `paused`, `completed`, `cancelled`, or `failed`.
- Runs are claimed as `running`, then marked `sent` or `failed`; an occurrence completed early by the user is marked `satisfied`.
- Runs are claimed as `running`, then marked `sent`, `failed`, or `skipped`; an occurrence completed early by the user is marked `satisfied`.
- Recurring active tasks advance `nextRunAt`; one-time active tasks complete after a sent run.
- The runner regenerates same-occurrence edits against the latest revision, skips cancelled or rescheduled occurrences, and fences stale workers with per-attempt claim tokens.
- Post-send reconciliation advances the delivered occurrence without overwriting a newer cancellation or reschedule.
- A satisfied one-time occurrence completes without delivery. A satisfied recurring occurrence skips only that delivery and advances normally when QStash invokes it.
- Paused tasks keep their metadata but have no active QStash trigger until resumed.
- QStash owns delivery timing. Postgres owns task metadata, limits, and cancellation state.
Expand All @@ -76,7 +84,11 @@ Fill the provider and integration keys:
- `OPENAI_API_KEY`
- `TELEGRAM_BOT_TOKEN`
- `TELEGRAM_WEBHOOK_SECRET_TOKEN`
- `TELEGRAM_ALLOWED_USER_IDS` — [TEMP] optional comma-separated Telegram numeric user IDs allowed to use the bot
- `TELEGRAM_ALLOWED_USER_IDS` — optional comma-separated Telegram numeric user IDs allowed to use the bot
- `BLOOIO_API_KEY`
- `BLOOIO_FROM_NUMBER`
- `BLOOIO_WEBHOOK_SECRET`
- `IMESSAGE_ALLOWED_NUMBERS` — optional comma-separated E.164 phone numbers allowed to use the iMessage agent
- `DATABASE_URL`
- `QSTASH_CURRENT_SIGNING_KEY`
- `QSTASH_NEXT_SIGNING_KEY`
Expand Down Expand Up @@ -115,6 +127,23 @@ Telegram webhook endpoint:
POST /webhooks/telegram
```

Blooio iMessage webhook endpoint:

```txt
POST /webhooks/imessage
```

Configure the Blooio webhook to send signed events to this endpoint. The adapter verifies them
with `BLOOIO_WEBHOOK_SECRET`.

To restrict iMessage access, set `IMESSAGE_ALLOWED_NUMBERS`:

```sh
IMESSAGE_ALLOWED_NUMBERS="+48123456789,+48987654321"
```

Leave it empty to allow all iMessage numbers.

To restrict bot usage during development, set `TELEGRAM_ALLOWED_USER_IDS`:

```sh
Expand All @@ -129,7 +158,7 @@ World Cup polling endpoint, called by QStash schedules:
GET /jobs/world-cup/events
```

The route verifies the `upstash-signature` header with `QSTASH_CURRENT_SIGNING_KEY` and `QSTASH_NEXT_SIGNING_KEY`.
The shared QStash infrastructure adapter verifies the raw request body and `upstash-signature` header with `QSTASH_CURRENT_SIGNING_KEY` and `QSTASH_NEXT_SIGNING_KEY`.

The schedule window is every minute from 17:45 through 09:59 the next day in `Europe/Warsaw`:

Expand Down Expand Up @@ -157,17 +186,19 @@ Create a Neon Postgres project for the agent and use its connection string as `D
Recommended setup:

- Use the Neon pooled connection string for Vercel runtime.
- Use the direct/unpooled connection string while applying migrations. If the URL uses
`sslmode=require`, change it to `sslmode=verify-full` to preserve certificate verification and
avoid the upcoming `pg` compatibility change.
- Keep all app tables in the `public` schema.
- Do not rely on `search_path` connection options; Neon pooled connections can reject unsupported startup parameters.
- If a local `db:push` ever has issues with the pooled URL, temporarily use Neon’s direct/unpooled URL locally for the push, then keep Vercel runtime on the pooled URL.

Before deploying the app, push the Drizzle schema to Neon:
Before deploying the app, apply the committed Drizzle migrations to a new database:

```sh
pnpm --filter @labjm/agent db:push
pnpm --filter @labjm/agent db:migrate
```

Review the generated statements before accepting them. The expected output should not drop `chat_state_*` tables or their sequences.
The initial migration enables `pgvector` before creating the agent tables and vector index.

### 2. Configure Vercel environment variables

Expand All @@ -181,6 +212,10 @@ Required:
- `TELEGRAM_WEBHOOK_SECRET_TOKEN`
- `TELEGRAM_ALLOWED_USER_IDS` — optional comma-separated allowlist while the agent is private
- `TELEGRAM_BOT_USERNAME` — optional, defaults to `labjm_assistant_bot`
- `BLOOIO_API_KEY` — Blooio API key used by the iMessage provider
- `BLOOIO_FROM_NUMBER` — default Blooio sending number in E.164 format
- `BLOOIO_WEBHOOK_SECRET` — verifies signed Blooio webhook deliveries
- `IMESSAGE_ALLOWED_NUMBERS` — optional comma-separated E.164 allowlist while the agent is private
- `OPENWEATHER_API_KEY` — required for weather and local-time tools
- `QSTASH_CURRENT_SIGNING_KEY` — required for QStash-signed World Cup polling and scheduled-task execution
- `QSTASH_NEXT_SIGNING_KEY` — required for QStash-signed World Cup polling and scheduled-task execution
Expand Down Expand Up @@ -223,7 +258,17 @@ curl -X POST "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" \

The `secret_token` must match `TELEGRAM_WEBHOOK_SECRET_TOKEN` in Vercel.

### 5. Configure QStash schedules, if World Cup polling is enabled
### 5. Configure Blooio webhook

Point the Blooio signed webhook at:

```txt
POST https://<agent-domain>/webhooks/imessage
```

The webhook secret must match `BLOOIO_WEBHOOK_SECRET` in Vercel.

### 6. Configure QStash schedules, if World Cup polling is enabled

Use QStash schedules that call:

Expand Down Expand Up @@ -251,36 +296,14 @@ The route verifies QStash signatures with `QSTASH_CURRENT_SIGNING_KEY` and `QSTA

## Database

Drizzle-managed app tables live in the `public` PostgreSQL schema, including the temporary `world_cup_2026_*` tables.

Chat SDK state tables also live in `public`, but `db:push` excludes `chat_state_*` through `tablesFilter` because those tables are owned by `@chat-adapter/state-pg`. The two Chat SDK `bigserial` backing sequences are declared in Drizzle so they are not treated as orphaned public sequences.

If Chat SDK state tables were moved to a temporary `chat_state` schema, move them back before deploying:

```sql
ALTER TABLE IF EXISTS chat_state.chat_state_subscriptions SET SCHEMA public;
ALTER TABLE IF EXISTS chat_state.chat_state_locks SET SCHEMA public;
ALTER TABLE IF EXISTS chat_state.chat_state_cache SET SCHEMA public;
ALTER TABLE IF EXISTS chat_state.chat_state_lists SET SCHEMA public;
ALTER TABLE IF EXISTS chat_state.chat_state_queues SET SCHEMA public;

ALTER SEQUENCE IF EXISTS chat_state.chat_state_lists_seq_seq SET SCHEMA public;
ALTER SEQUENCE IF EXISTS chat_state.chat_state_queues_seq_seq SET SCHEMA public;

DROP SCHEMA IF EXISTS chat_state;
```

If temporary World Cup tables were previously created in the old `world_cup` schema, remove that duplicate schema after confirming `public.world_cup_2026_*` has the desired data:

```sql
DROP SCHEMA IF EXISTS world_cup CASCADE;
```
Drizzle-managed app tables live in the `public` PostgreSQL schema, including the temporary `world_cup_2026_*` tables. Schema changes use the checked-in migration workflow:

```sh
pnpm --filter @labjm/agent db:push
pnpm --filter @labjm/agent db:generate
pnpm --filter @labjm/agent db:migrate
```

Expected `db:push` output should not drop `chat_state_*` tables or sequences.
Review every generated SQL file before committing it. CI migrates a fresh pgvector-enabled PostgreSQL database and runs the gated persistence suites. Chat SDK state tables remain owned by `@chat-adapter/state-pg` and are excluded through `tablesFilter`; do not add Drizzle ownership for `chat_state_*`.

## Stack

Expand Down
22 changes: 10 additions & 12 deletions apps/agent/docs/personal-assistant-design.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Personal Assistant Design

This note captures the agreed direction for evolving `@labjm/agent` from a showcase Telegram bot into a practical personal assistant. Current implementation is useful context, but not sacred; preserve what helps and redesign boundaries where the product needs it.
This note captures the direction for evolving `@labjm/agent` into a practical personal assistant. It is future-looking; the current architecture is documented in `apps/agent/README.md`.

## Product Direction

Expand All @@ -12,12 +12,11 @@ This note captures the agreed direction for evolving `@labjm/agent` from a showc
## Ownership And Identity

- Postgres is the operational source of truth for chat state, personal context, profiles, and scheduled items.
- The app should support multiple separated user accounts, even while product behavior is optimized for the owner first.
- Use an internal `User` entity as the canonical owner.
- Link Telegram and future channels through `ExternalIdentity` records. Channel identities must not become the core owner ID.
- `UserProfile` is required for operational defaults such as timezone, default location, locale, units, and channel preferences.
- Keep all persisted data scoped by the current Chat SDK identity.
- Do not add an internal identity layer while Telegram is the only channel and no account-linking behavior exists.
- When a second channel or account linking becomes real, introduce an internal `User` plus `ExternalIdentity` records and migrate provider identities behind that boundary.
- Add a `UserProfile` when operational defaults such as timezone, location, locale, units, or channel preferences need independent lifecycle and editing.
- Onboarding should be progressive. Do not push an upfront setup flow; ask for missing profile fields only when needed.
- First implementation should route Telegram messages through internal user resolution before expanding assistant capabilities.

## Knowledge Model

Expand Down Expand Up @@ -88,13 +87,12 @@ This note captures the agreed direction for evolving `@labjm/agent` from a showc
- `KnowledgeService` should return structured retrieval results plus metadata. `AgentContextService` formats those results into model context.
- Store multilingual content as-is. Do not normalize or translate all knowledge into English.

## First Milestone
## Current Foundation

- The first implementation milestone is identity plus knowledge foundation only, with no reminders yet.
- Introduce `User`, `ExternalIdentity`, `UserProfile`, `KnowledgeNode`, `KnowledgeLink`, and `KnowledgeEvent`.
- Replace noted-memory creation/retrieval with knowledge-backed behavior.
- Keep `AgentContextService` as the context orchestration layer, but swap durable memory internals to knowledge retrieval.
- Add the explicit `manage-knowledge` tool for user-requested saves and edits.
- `KnowledgeNode` hierarchy, retrieval, implicit ingestion, and explicit knowledge management are implemented.
- Rolling conversation summaries remain separate from curated knowledge.
- One-time and recurring reminders are implemented through QStash and PostgreSQL.
- Internal users, cross-channel identity linking, knowledge links/events, and a standalone user profile remain deferred until product behavior needs them.

## Scheduled Items

Expand Down
8 changes: 5 additions & 3 deletions apps/agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
"type": "module",
"scripts": {
"build": "tsup",
"db:check": "drizzle-kit check",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:migrate": "node scripts/migrate.mjs",
"dev:server": "vercel dev --listen 2000 --yes",
"dev": "mkdir -p logs && LOG_LEVEL=info AGENT_LOG_FILE=logs/agent.log tsup --watch --onSuccess \"node dist/app/tui/index.js\"",
"lint": "eslint .",
Expand All @@ -21,9 +21,11 @@
"@chat-adapter/state-pg": "4.33.0",
"@chat-adapter/telegram": "4.33.0",
"@fontsource/inter": "^5.2.8",
"@imessage-sdk/blooio": "^0.1.1",
"@imessage-sdk/chat-adapter": "0.1.0-beta.2",
"@imessage-sdk/photon": "^0.1.0",
"@labjm/utilities": "workspace:*",
"@message-ui/components": "^0.1.0",
"@neondatabase/serverless": "^1.1.0",
"@resvg/resvg-js": "^2.6.2",
"@upstash/qstash": "^2.11.1",
"@vercel/functions": "3.7.1",
Expand Down
Loading
Loading