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
68 changes: 68 additions & 0 deletions .github/workflows/db.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# packages/db: the shared aafo database's three migration histories
# (identity, persona, cards). Proves on every change that they apply from an
# empty database and that each history's schema files and migrations agree. Never deploys anything: prod migrations are applied by a
# person (packages/db/README.md).
name: db

on:
pull_request:
paths: ['packages/db/**', '.github/workflows/db.yml']
push:
branches: [dev]
paths: ['packages/db/**', '.github/workflows/db.yml']

jobs:
migrations:
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/db
services:
postgres:
# Same major version as Supabase aafo (17), with pgvector for the cards migrations.
image: pgvector/pgvector:pg17
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: postgres
ports: ['5432:5432']
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s --health-timeout 5s --health-retries 10
env:
SCRATCH_URL: postgresql://postgres:postgres@localhost:5432/postgres
CI_DB_URL: postgresql://postgres:postgres@localhost:5432/ci
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: packages/db/package-lock.json
- run: npm ci
- run: npm run typecheck
- name: Snapshot/journal consistency (all histories)
run: npm run db:check
- name: Migration hygiene
run: npm run db:lint
env:
BASE_REF: ${{ github.event_name == 'pull_request' && format('origin/{0}', github.base_ref) || '' }}
- name: Apply every migration to an empty database
run: |
psql "$SCRATCH_URL" -v ON_ERROR_STOP=1 -c 'create database ci'
psql "$CI_DB_URL" -v ON_ERROR_STOP=1 -f test/supabase-stubs.sql
DATABASE_URL="$CI_DB_URL" npm run db:migrate -- --yes
- name: Schema files and migrations are in sync (every history)
run: |
for h in identity persona cards; do
npx drizzle-kit generate --config "$h/drizzle.config.ts" --name ci_should_be_empty
done
if [ -n "$(git status --porcelain -- '*/migrations')" ]; then
echo "A schema file changed with no migration. Run: npm run <history>:generate -- --name <owner>_<change>"
git status --porcelain -- '*/migrations'
exit 1
fi
- name: Drift check against a fresh build (self-consistency)
run: npm run db:drift -- --expected "$CI_DB_URL" --scratch "$SCRATCH_URL"
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
# Root directories and files to exclude
# The root package.json is a script runner, not a workspace: a root lockfile
# would change Next.js's project-root detection for apps/*. Install per app.
/package-lock.json
/.zyndai-agent/
/supabase/
/out.txt
Expand Down
33 changes: 26 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ If a task mentions either of those by name, it's the wrong repo.
| `services/memory` | Shared context/memory layer: ingest, matching, MCP server, OAuth for AI clients | FastAPI / Python | api.zynd.ai |
| `infra/persona-box` | pm2 configs for the box running persona-api + persona-web | — | — |
| `infra/api-box` | Caddy + docker-compose for the box running cards-api + memory | — | — |
| `packages/` | Shared code (DB migrations, contracts). **Planned, not built yet.** | — | — |
| `packages/db` | **All migrations** for the shared aafo database, Drizzle: one independent history per Postgres schema (identity / persona=`public` / cards) — read its `README.md` before any schema change | TypeScript / Drizzle | — |
| `packages/contracts` | Shared API contracts. **Planned, not built yet.** | — | — |
| `docs/plans` | Architecture and migration plans behind this repo — start with `docs/plans/README.md` | — | — |

**History is preserved.** Each service was merged in with `git filter-repo`,
Expand Down Expand Up @@ -73,10 +74,21 @@ its life as a standalone repo, not just the merge date.
It is reached **only** over its HTTP API — never open a direct DB
connection to it from persona-api or cards-api, and never assume its
tables live in the same database as persona/cards.
- There is no single migrations folder yet (`packages/db` is planned but not
built). Until it exists, **coordinate any schema change with whoever owns
the other services** before merging — don't assume your migration is the
only one in flight.
- **Postgres schemas are the boundary.** persona's tables are in `public`,
cards' in `cards`, and the one shared layer (future Zynd Account) in
`identity`. Each schema has its own independent migration history in
`packages/db/<history>/` (Drizzle), so a persona change never touches cards'
history and vice versa. Cross-schema FKs and joins still work.
- **Every schema change is a migration there.** Not the SQL editor, not a
`.sql` file inside a service: the old SQL folders are frozen. Follow
`packages/db/README.md`; `packages/db/OWNERS.md` says whose review a table
needs.
- **There is no staging database.** dev.persona.zynd.ai uses prod aafo, so a
migration applied anywhere is live everywhere. Rehearse locally, keep
migrations expand-only, and apply them only on a human's say-so (§6).
- `services/memory` also *reads* aafo (`persona_agents`,
`search_personas_fts`) with the service key. Changes to those need
memory's owner in the loop.

## 5. Testing — before *and* after every change

Expand Down Expand Up @@ -122,8 +134,15 @@ Current state:
- ❌ **Nothing has been deployed from this checkout yet** — prod and dev
servers for cards and memory still run the old standalone repos. Don't
trust `infra/` as a description of what's currently live; it's the target.
- ❌ `packages/db`, `packages/contracts`, CI, and a shared Supabase project
for cards are not started.
- 🟡 `packages/db` built (2026-09-27): identity `0000`; persona `0000`
(baseline of prod aafo, verified) and `0001` (security fix); cards
`0000`–`0002`; CI workflow. **Nothing applied to prod yet**: persona's
baseline still has to be recorded there and the rest applied. See
`docs/plans/ZYND_DB_UNIFY_PLAN.md` §8.
- ✅ Root runner: `npm run setup`, `npm run dev`, `npm test` from the repo
root (`docs/LOCAL_DEV.md`).
- ❌ `packages/contracts` is not started. Cards still runs on the dashboard's
Supabase project (xmfj) until the cutover in that plan.

If a task depends on any of the above being finished, check with a
maintainer rather than assuming the repo layout matches the live servers.
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ against multiple server versions).
| `services/memory` | Shared context layer — ingest, matching, MCP server, OAuth for ChatGPT/Claude/Cursor | FastAPI / Python | https://api.zynd.ai |
| `infra/persona-box` | pm2 process configs for the persona server | — | — |
| `infra/api-box` | Caddy + Docker Compose for the cards/memory server | — | — |
| `packages/` | Shared DB migrations / API contracts. **Planned, not built yet.** | — | — |
| `packages/db` | Migrations for the shared aafo database (Drizzle), one history per Postgres schema: identity, persona (`public`), cards. See its README | — | — |
| `packages/contracts` | Shared API contracts. **Planned, not built yet.** | — | — |

Each service came from its own repo (`agent-persona`, `zynd-cards`,
`memory-layer`) and was merged in with full git history — `git log --follow
Expand Down Expand Up @@ -152,3 +153,7 @@ side effect of an unrelated change:
which plans are active vs. superseded.
- Per-service `CLAUDE.md`/`AGENTS.md`/`README.md` inside `apps/*` and
`services/*` — stack-specific conventions.

## Running locally

`npm run setup`, then `npm run dev` from the repo root. Ports, env files and where the data lives: [`docs/LOCAL_DEV.md`](docs/LOCAL_DEV.md).
1 change: 1 addition & 0 deletions apps/cards-web/.env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ SUPABASE_SERVICE_ROLE_KEY=

# Cards API — unchanged, still serving from xmfj until the cards-move P4
# cutover switches it to aafo.
# Local cards-api: http://localhost:8002 (npm run dev:cards-api at the repo root)
NEXT_PUBLIC_API_URL=https://api.zynd.ai
# Memory API, used by lib/memory.ts (ProfileChatWidget calls the cards API's
# own /chat/profile proxy, not this directly).
Expand Down
9 changes: 9 additions & 0 deletions apps/cards-web/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->

# This is NOT the Next.js you know

This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.

This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.

<!-- END:nextjs-agent-rules -->
1 change: 1 addition & 0 deletions apps/cards-web/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@AGENTS.md
11 changes: 11 additions & 0 deletions apps/persona-web/.env.local.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# persona-web local env. Copy to apps/persona-web/.env.local (gitignored).
# The anon key is a PUBLIC key by design (it ships in the browser bundle);
# RLS is what protects data. Never put the service-role key in a NEXT_PUBLIC_ var.
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=

# Local backends (ports from the root package.json)
NEXT_PUBLIC_API_URL=http://localhost:8000
NEXT_PUBLIC_MEMORY_API_URL=http://localhost:8001

NEXT_PUBLIC_GA_ID=
1 change: 1 addition & 0 deletions apps/persona-web/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ yarn-error.log*

# env files (can opt-in for committing if needed)
.env*
!.env.local.example

# vercel
.vercel
Expand Down
9 changes: 9 additions & 0 deletions apps/persona-web/db/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Frozen — do not add or apply anything here

Schema changes to the shared aafo database now go through the tracked
migration histories in [`packages/db`](../../../packages/db/README.md) (Drizzle).

The SQL in this folder is history. It was applied to prod by hand at various
times, and nothing records which files ran, so **never re-run it**. The
current, verified state of the schema is `packages/db/persona/migrations/0000_baseline_persona.sql`
plus the migrations after it.
3 changes: 1 addition & 2 deletions apps/persona-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@
"dev": "next dev -H 127.0.0.1",
"build": "next build",
"start": "next start",
"lint": "eslint",
"db:policies": "psql \"$DIRECT_URL\" -f db/sql/policies.sql"
"lint": "eslint"
},
"dependencies": {
"@dicebear/collection": "^9.4.2",
Expand Down
150 changes: 150 additions & 0 deletions docs/LOCAL_DEV.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# Local development

Everything runs from the repo root.

## One-time setup

Needs Node 22+, [uv](https://docs.astral.sh/uv/) (Python 3.12 venvs), and
Docker only if you run memory locally.

```bash
npm run setup # npm ci in apps/persona-web, apps/cards-web, packages/db
# + a .venv in each Python service (scripts/setup-python.sh)
```

Then create the env files from their templates (all gitignored, never
committed; no key is hard-coded anywhere in the code):

| Copy | To |
|---|---|
| `apps/persona-web/.env.local.example` | `apps/persona-web/.env.local` |
| `apps/cards-web/.env.local.example` | `apps/cards-web/.env.local` |
| `services/persona-api/.env.example` | `services/persona-api/.env` |
| `services/cards-api/.env.example` | `services/cards-api/.env` |
| `services/memory/.env.example` | `services/memory/.env` |

## Env vars

"Supabase" below means one Supabase project's values (Settings → API):
the project URL, the anon key (public) and the service-role key (secret,
backend only). Today that is aafo (prod); see the warning further down.

### Minimum to boot everything

| Where | Variable | Value locally |
|---|---|---|
| `apps/persona-web/.env.local` | `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase URL + anon key |
| | `NEXT_PUBLIC_API_URL` | `http://localhost:8000` |
| | `NEXT_PUBLIC_MEMORY_API_URL` | `http://localhost:8001` |
| `apps/cards-web/.env.local` | `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Supabase URL + anon key |
| | `SUPABASE_SERVICE_ROLE_KEY` | service-role key (server-side only) |
| | `NEXT_PUBLIC_API_URL` | `http://localhost:8002` (cards-api) |
| | `NEXT_PUBLIC_ZYND_API_URL` | `http://localhost:8001` (memory: token exchange, findability) |
| | `NEXT_PUBLIC_SITE_URL` | `http://localhost:3002` |
| `services/persona-api/.env` | `SUPABASE_URL`, `SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_KEY` | Supabase URL + both keys |
| | `FRONTEND_URL`, `PUBLIC_PAGE_BASE_URL` | `http://localhost:3000` |
| | `MEMORY_LAYER_URL` | `http://localhost:8001` |
| | `MEMORY_LAYER_JWT_SECRET` | **same value as memory's `JWT_SECRET`** |
| | one LLM: `LLM_PROVIDER` + its key (`OPENAI_API_KEY`, or `OPENROUTER_API_KEY` + `OPENROUTER_MODEL`, or `GEMINI_API_KEY`, …) | your key |
| `services/cards-api/.env` | `SUPABASE_URL`, `SUPABASE_SERVICE_KEY` | Supabase URL + service-role key |
| | `SUPABASE_DB_SCHEMA` | `cards` on aafo (once the cards migrations are applied); `public` on the old xmfj project |
| | `SUPABASE_JWT_SECRET` | the project's legacy JWT secret (only for old HS256 tokens; can stay empty) |
| | `OPENROUTER_API_KEY` (+ optional `OPENROUTER_MODEL`), `OPENAI_API_KEY` (embeddings for search) | your keys |
| | `CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_AI_KEY` | only for the profile chat widget (Workers AI) |
| | `FRONTEND_URL`, `SITE_BASE_URL`, `API_BASE_URL` | `http://localhost:3002`, `http://localhost:3002`, `http://localhost:8002` |
| | `MEMORY_LAYER_URL`, `MEMORY_SERVICE_TOKEN` | `http://localhost:8001`, **same value as memory's `MEMORY_SERVICE_TOKEN`** |
| `services/memory/.env` | `DATABASE_URL`, `REDIS_URL` | defaults already match `npm run dev:infra` (`localhost:5433`, `localhost:6380`) |
| | `JWT_SECRET`, `MEMORY_SERVICE_TOKEN` | any random strings, shared with persona-api / cards-api as above |
| | `SUPABASE_URL`, `SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_KEY` | Supabase values: memory verifies Supabase logins (`/token/exchange`) and reads `persona_agents` |
| | `PERSONA_ENABLED` | `true` to turn on the persona-network features (link, connect, message); off by default |
| | `OPENAI_API_KEY`, `DEEPSEEK_API_KEY` | embeddings + fact extraction (or `MOCK_LLM=true` to skip both) |
| | `PUBLIC_BASE_URL`, `MCP_PUBLIC_BASE_URL` | `http://localhost:8001`, `http://localhost:8090` |
| | `CORS_ORIGINS` | add `http://localhost:3002`: the default only allows `:3000` |
| | `ENABLE_DEV_BEARER=true`, `DEV_BEARER_TOKEN` | optional: call memory's API with a static token while developing |
| `packages/db` | `DATABASE_URL` | only when running migrations; session-pooler URL, port 5432 |
| `infra/local` | none | Postgres/Redis credentials are fixed (`zynd`/`zynd`) |

**Values that must match across services:** persona-api `MEMORY_LAYER_JWT_SECRET` = memory `JWT_SECRET`;
cards-api `MEMORY_SERVICE_TOKEN` = memory `MEMORY_SERVICE_TOKEN`.

### Optional, per feature

Everything else in the `.env.example` files turns on one integration and
can stay empty until you work on it: Google/LinkedIn/Twitter/GitHub/Notion
OAuth apps (persona-api, memory), Telegram (`TELEGRAM_BOT_TOKEN`,
`TELEGRAM_WEBHOOK_SECRET`), enrichment (`QUICKENRICH_*`, `APIFY_*`), web
search (`EXA_API_KEY`, `TAVILY_API_KEY`, `FIRECRAWL_API_KEY`), the X bot
(`X_*`), SEO pings (`INDEXNOW_KEY`, `BING_*`), analytics
(`NEXT_PUBLIC_GA_ID`, `NEXT_PUBLIC_ANALYTICS_ID`) and the Zynd network
(`ZYND_*`, `NGROK_AUTH_TOKEN`). OAuth logins in the browser also need
`http://localhost:3000/**` and `http://localhost:3002/**` in the Supabase
project's Auth redirect URLs.

## Run

```bash
npm run dev # persona-web, cards-web, persona-api, cards-api together
npm run dev:infra # memory's Postgres + Redis in Docker (infra/local/docker-compose.yml)
npm run dev:memory # memory API + MCP server + worker (needs dev:infra)
```

Each piece also runs alone: `npm run dev:persona-web`, `dev:cards-web`,
`dev:persona-api`, `dev:cards-api`.

| Service | Local URL |
|---|---|
| persona-web | http://localhost:3000 |
| cards-web | http://localhost:3002 |
| persona-api | http://localhost:8000 |
| memory API | http://localhost:8001 |
| memory MCP | http://localhost:8090 |
| cards-api | http://localhost:8002 |
| memory Postgres / Redis (Docker) | localhost:5433 / localhost:6380 |

## Test

```bash
npm test # all Python suites + lint/typecheck of both web apps
npm run test:persona-api # or test:cards-api, test:memory, check:web
```

Known baseline failures are listed in `AGENTS.md` §5.

## Where the data lives

- **memory** uses plain Postgres + Redis, so it runs fully locally in Docker.
- **persona and cards** use Supabase: Postgres plus Auth (Google/LinkedIn
OAuth), RLS, Storage and Realtime. Emulating Auth locally is the painful
part, so local dev points `SUPABASE_URL` and the keys at a **hosted**
Supabase project instead of a local one.

> **Warning: today that hosted project is prod.** There is no separate dev
> Supabase project yet, and dev.persona.zynd.ai already runs on prod aafo.
> Anything you do locally with aafo's keys reads and writes real user data.

**Recommended next step: a dev Supabase project.** It costs a small
always-on project, and in return local and dev stop touching prod. It can be
built entirely from the migrations:

1. Create a new Supabase project (e.g. `zynd-dev`, Postgres 17).
2. `cd packages/db && DATABASE_URL=<dev project session-pooler URL> npm run db:migrate -- --yes`.
On an empty project the baseline (0000) runs for real, then every later
migration. This is also a full rehearsal of every migration.
3. In the dev project's Auth settings, enable LinkedIn (OIDC) and add
`http://localhost:3000/**` and `http://localhost:3002/**` as redirect URLs.
4. Put the dev project's URL and keys in the env files above, and in
dev.persona.zynd.ai's env.

If fully-offline development is ever needed, `supabase start` (Supabase CLI)
runs the whole stack in Docker and the same migrations apply to it. It's
heavier, and OAuth redirects need extra setup.

## Why the root isn't an npm workspace

`package.json` at the root only runs scripts. Each app keeps its own lockfile
and installs in its own folder, because that is how they are built in prod:
pm2 on the persona box runs `npm` inside `apps/persona-web`, and Vercel
builds `apps/cards-web` as its root directory. An npm workspace moves every
lockfile to the root, which would change both of those builds. Don't run
`npm install` at the root; `/package-lock.json` is gitignored so a stray one
can't be committed.
Loading
Loading