Browse Source

Convert to an npm-workspaces monorepo (packages/core, packages/humanoid, apps/template)

Splits the SvelteKit app into three workspaces per docs/MONOREPO_MIGRATION_BRIEF.md:

- packages/core (@webavatar/webavatar-core): session management, auth tokens,
  provider adapters, and the WS relay, with curated entry points (., ./client,
  ./server, ./vite) and a browser-stub guard on the server-only ones.
- packages/humanoid (@webavatar/webavatar-humanoid): avatar-state derivation
  and animation-channel composition, depending on core only via type-only
  imports (no runtime reference to core in its built output).
- apps/template: the SvelteKit application template, now importing both
  packages by name, routed through a server-only wrapper and a build-time
  guard (server-only-guard.ts) for the server/vite entries.

Also collapses the duplicated server.js/vite-plugin composition wiring into a
single createRealtimeRuntime factory (exported from packages/core/server),
adds ESLint boundary-enforcement rules and Gitea Actions workflows
(verify.yml, publish.yml), externalizes both packages in the production SSR
build for artifact parity with a real client install, and rewrites the
project documentation to match.

No runtime behavior or wire protocol changes. Registry publication stays
blocked on Gitea host/org placeholders not yet resolved — see the brief's
status banner for exactly what remains.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
main
Jonathan Hinkel 1 month ago
parent
commit
3421198537
  1. 2
      .claude/launch.json
  2. 30
      .gitea/workflows/publish.yml
  3. 29
      .gitea/workflows/verify.yml
  4. 9
      .gitignore
  5. 1
      .npmrc
  6. 2
      .prettierignore
  7. 22
      AGENTS.md
  8. 37
      README.md
  9. 0
      apps/template/.env.example
  10. 49
      apps/template/README.md
  11. 49
      apps/template/package.json
  12. 0
      apps/template/playwright.config.ts
  13. 27
      apps/template/server-only-guard.ts
  14. 23
      apps/template/server.js
  15. 0
      apps/template/src/app.d.ts
  16. 0
      apps/template/src/app.html
  17. 0
      apps/template/src/lib/assets/favicon.svg
  18. 17
      apps/template/src/lib/client/DebugPanel.svelte
  19. 2
      apps/template/src/lib/client/DebugPanel.svelte.spec.ts
  20. 2
      apps/template/src/lib/client/project/tools/flash-page.ts
  21. 2
      apps/template/src/lib/client/project/tools/index.ts
  22. 0
      apps/template/src/lib/index.ts
  23. 14
      apps/template/src/lib/project/persona.ts
  24. 2
      apps/template/src/lib/project/tools.ts
  25. 2
      apps/template/src/lib/server/project/tools/get-current-time.ts
  26. 2
      apps/template/src/lib/server/project/tools/index.ts
  27. 1
      apps/template/src/lib/server/webavatar-core.ts
  28. 0
      apps/template/src/routes/+layout.svelte
  29. 0
      apps/template/src/routes/+page.svelte
  30. 3
      apps/template/src/routes/api/realtime/session/+server.ts
  31. 0
      apps/template/src/routes/demo/+layout.server.ts
  32. 0
      apps/template/src/routes/demo/humanoid/+page.svelte
  33. 16
      apps/template/src/routes/demo/humanoid/AvatarStateDemo.svelte
  34. 19
      apps/template/src/routes/demo/humanoid/EyeSystemDemo.svelte
  35. 5
      apps/template/src/routes/demo/humanoid/eye-demo/GazeDebugPanel.svelte
  36. 0
      apps/template/src/routes/demo/humanoid/eye-demo/GazePlaneVisualizer.svelte
  37. 2
      apps/template/src/routes/demo/humanoid/eye-demo/ThreeAvatarViewer.svelte
  38. 14
      apps/template/src/routes/demo/realtime/+page.svelte
  39. 0
      apps/template/static/models/avatar.glb
  40. 0
      apps/template/static/robots.txt
  41. 18
      apps/template/tsconfig.json
  42. 7
      apps/template/tsconfig.server.json
  43. 34
      apps/template/vite.config.ts
  44. 28
      docs/ANALYTICS.md
  45. 38
      docs/ARCHITECTURE.md
  46. 14
      docs/CONFIGURATION.md
  47. 30
      docs/HUMANOID.md
  48. 18
      docs/LOGGING.md
  49. 92
      docs/MONOREPO_MIGRATION_BRIEF.md
  50. 44
      docs/REALTIME_SESSION.md
  51. 26
      docs/TOOLS.md
  52. 80
      eslint.config.js
  53. 458
      package-lock.json
  54. 52
      package.json
  55. 40
      packages/core/README.md
  56. 60
      packages/core/package.json
  57. 2
      packages/core/src/client/client.ts
  58. 22
      packages/core/src/client/index.ts
  59. 0
      packages/core/src/client/logger.spec.ts
  60. 2
      packages/core/src/client/logger.ts
  61. 2
      packages/core/src/client/resilient-client.spec.ts
  62. 2
      packages/core/src/client/resilient-client.ts
  63. 2
      packages/core/src/client/server-log-forwarder.spec.ts
  64. 2
      packages/core/src/client/server-log-forwarder.ts
  65. 2
      packages/core/src/client/tool-dispatcher.spec.ts
  66. 4
      packages/core/src/client/tool-dispatcher.ts
  67. 16
      packages/core/src/index.ts
  68. 0
      packages/core/src/logger.ts
  69. 11
      packages/core/src/persona.ts
  70. 0
      packages/core/src/protocol.ts
  71. 0
      packages/core/src/server/analytics.spec.ts
  72. 0
      packages/core/src/server/analytics.ts
  73. 3
      packages/core/src/server/browser-stub.ts
  74. 0
      packages/core/src/server/config.spec.ts
  75. 0
      packages/core/src/server/config.ts
  76. 0
      packages/core/src/server/defaults.spec.ts
  77. 2
      packages/core/src/server/defaults.ts
  78. 96
      packages/core/src/server/index.ts
  79. 0
      packages/core/src/server/logger.spec.ts
  80. 2
      packages/core/src/server/logger.ts
  81. 4
      packages/core/src/server/provider.ts
  82. 0
      packages/core/src/server/providers/fixtures/default.ts
  83. 0
      packages/core/src/server/providers/index.ts
  84. 2
      packages/core/src/server/providers/mock.spec.ts
  85. 2
      packages/core/src/server/providers/mock.ts
  86. 2
      packages/core/src/server/providers/openai.spec.ts
  87. 4
      packages/core/src/server/providers/openai.ts
  88. 4
      packages/core/src/server/session.spec.ts
  89. 6
      packages/core/src/server/session.ts
  90. 0
      packages/core/src/server/tokens.spec.ts
  91. 0
      packages/core/src/server/tokens.ts
  92. 2
      packages/core/src/server/tools.spec.ts
  93. 2
      packages/core/src/server/tools.ts
  94. 2
      packages/core/src/server/ws-handler.ts
  95. 0
      packages/core/src/tools.ts
  96. 57
      packages/core/src/vite/index.ts
  97. 12
      packages/core/tsconfig.build.json
  98. 11
      packages/core/tsconfig.json
  99. 9
      packages/core/vitest.config.ts
  100. 28
      packages/humanoid/README.md

2
.claude/launch.json

@ -4,7 +4,7 @@
{
"name": "webavatar-dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"runtimeArgs": ["run", "dev", "--workspace", "apps/template"],
"port": 5173
}
]

30
.gitea/workflows/publish.yml

@ -0,0 +1,30 @@
name: publish
on:
push:
tags: ['core-v*', 'humanoid-v*']
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
registry-url: https://<gitea-host>/api/packages/<owner>/npm/
scope: '@webavatar'
- run: npm ci
- run: npm run build:packages
- name: Resolve the workspace from the tag and check its version
run: |
case "$GITHUB_REF_NAME" in
core-v*) WS=packages/core; VERSION="${GITHUB_REF_NAME#core-v}" ;;
humanoid-v*) WS=packages/humanoid; VERSION="${GITHUB_REF_NAME#humanoid-v}" ;;
*) echo "unexpected tag $GITHUB_REF_NAME"; exit 1 ;;
esac
test "$(node -p "require('./$WS/package.json').version")" = "$VERSION"
echo "WS=$WS" >> "$GITHUB_ENV"
- run: npm test -w "$WS"
- run: npm publish -w "$WS"
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}

29
.gitea/workflows/verify.yml

@ -0,0 +1,29 @@
name: verify
on:
push:
pull_request:
jobs:
verify:
# A runner (or container image) with Node 22+, git-lfs, and Chromium's system
# dependencies for the browser-mode Vitest project.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
lfs: true
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci # no registry token on purpose — see "Versions, ranges, and the 0.x window"
- run: npx playwright install --with-deps chromium
- run: npm run lint
- run: npm run check
- run: npm test
- run: npm run build
- run: npm publish --dry-run -w packages/core -w packages/humanoid
- name: Humanoid ships no runtime reference to core
run: if grep -rl "webavatar-core" packages/humanoid/dist --include="*.js"; then exit 1; fi
- name: The SvelteKit server build imports core by name
run: grep -rl "@webavatar/webavatar-core/server" apps/template/build/server

9
.gitignore

@ -5,12 +5,13 @@ node_modules
.vercel
.netlify
.wrangler
/.svelte-kit
/build
/build-server
apps/*/.svelte-kit
apps/*/build
apps/*/build-server
packages/*/dist
# Analytics (local SQLite db — see docs/ANALYTICS.md)
/data
apps/*/data
# OS
.DS_Store

1
.npmrc

@ -1 +1,2 @@
engine-strict=true
@webavatar:registry=https://<gitea-host>/api/packages/<owner>/npm/

2
.prettierignore

@ -6,4 +6,4 @@ bun.lock
bun.lockb
# Miscellaneous
/static/
apps/*/static/

22
AGENTS.md

@ -1,24 +1,24 @@
# webavatar-core
Base SvelteKit project meant to be branched off for future webavatar client projects. It provides reusable core infrastructure — session management, auth tokens, realtime model connections — so client projects don't reimplement the basics from scratch. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full picture; `docs/` holds per-subsystem deep-dives beyond that.
An npm-workspaces monorepo providing reusable core infrastructure — session management, auth tokens, realtime model connections — for future webavatar client projects, plus a SvelteKit template to branch those projects from. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full picture; `docs/` holds per-subsystem deep-dives beyond that.
## Project structure
- `src/lib/core/` — shared client+server contracts (types, protocols). No SvelteKit-specific imports (`$env`, `$app`, etc.) — must stay portable.
- `src/lib/server/core/` — server-only implementation. SvelteKit enforces this boundary at build time; browser code cannot import from here, even transitively.
- `src/lib/client/core/` — thin browser-side wrappers around the shared contracts. Transport only, no business logic.
- `src/lib/project/`, `src/lib/server/project/`, `src/lib/client/project/` — mirrors the three directories above, but for code specific to whichever client project branches from this template (tool implementations, custom instructions, etc.), instead of reusable template infrastructure. **Core never imports from project, only the reverse** — see [docs/TOOLS.md](docs/TOOLS.md) for the concrete example this convention was introduced for.
- `src/lib/humanoid/`, `src/lib/client/humanoid/` — an **optional package** (not `core`, not `project`; see "Module layering" in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)) providing avatar-state derivation and animation-channel composition for 3D humanoid avatars. Nothing in `core`/`project` imports from it — a project only pulls it in if it's building a humanoid avatar. See [docs/HUMANOID.md](docs/HUMANOID.md).
- `src/routes/` — thin route glue; delegates into the above rather than holding logic itself.
- `src/routes/demo/` — core-owned verification scaffolding (the live smoke-test surface referenced under "Verifying changes" below), not a client project's UI. Gated to dev-only (404s in production) so a branched project never needs to delete it to keep it out of a shipped build.
- `docs/` — architecture and subsystem documentation (see "Documentation" below).
- `packages/core/` — `@webavatar/webavatar-core`, published-package infrastructure every client project needs: session management, auth tokens, the realtime provider adapters, and the Vite dev-server integration. Curated entry points (`.`, `./client`, `./server`, `./vite`) — see the package's own README for what each exports. No SvelteKit-specific imports (`$env`, `$app`, etc.) and no dependency on `apps/template` — it's a portable package, not app code.
- `packages/humanoid/` — `@webavatar/webavatar-humanoid`, an **optional package** (not every client project needs it) providing avatar-state derivation and animation-channel composition for 3D humanoid avatars. Depends on core's public contracts only as types (`import type`), never at runtime — its built JavaScript contains no reference to core at all. See [docs/HUMANOID.md](docs/HUMANOID.md) and the package's own README.
- `apps/template/` — the SvelteKit application template client projects branch from: routes, project-owned persona/tools, deployment configuration (`server.js`, `vite.config.ts`), and the demo pages. This is what a client project copies out and installs the packages into — see its README's "Starting a client project" procedure. Within it:
- `src/lib/project/`, `src/lib/server/project/`, `src/lib/client/project/` — code specific to whichever client project branches from the template (tool implementations, custom instructions, etc.). **Neither package imports from project code, only the reverse** — see [docs/TOOLS.md](docs/TOOLS.md) for the concrete example this convention was introduced for.
- `src/lib/server/webavatar-core.ts` — the server-only wrapper (`export * from '@webavatar/webavatar-core/server'`) that SvelteKit routes and server modules import through, so SvelteKit's own `$lib/server` build-time guard covers them; `server.js` and `vite.config.ts` are the only two files allowed to import the package's `/server` and `/vite` entries directly.
- `src/routes/` — thin route glue; delegates into the packages and project code rather than holding logic itself.
- `src/routes/demo/` — verification scaffolding (the live smoke-test surface referenced under "Verifying changes" below), not a client project's own UI. Gated to dev-only (404s in production) so a branched project never needs to delete it to keep it out of a shipped build.
- `docs/` — architecture and subsystem documentation (see "Documentation" below), at the repo root and shared across both packages and the template.
## Development
- `npm run dev` / `npm run build` / `npm run preview` — standard SvelteKit commands. `build` also compiles `src/lib/core/**` + `src/lib/server/core/**` standalone (`tsc -p tsconfig.server.json`) for production's custom `server.js` to import — see [docs/REALTIME_SESSION.md](docs/REALTIME_SESSION.md) for why that's needed.
- `npm run dev` / `npm run build` / `npm run preview` — standard SvelteKit commands, run from `apps/template` (or via `--workspace apps/template` from the root). `build` also compiles `src/lib/project/**` + `src/lib/server/project/**` standalone (`tsc -p tsconfig.server.json`) for production's custom `server.js` to import.
- `npm run check` — type-check (`svelte-check`). `npm run lint` / `npm run format` — prettier + eslint. `npm run test:unit` — vitest.
- Copy `.env.example` to `.env` and fill in `OPENAI_API_KEY` / `REALTIME_TOKEN_SECRET` before running anything that touches the realtime session layer.
- **Gotcha**: `src/lib/server/core/**` is only loaded via `vite.config.ts`'s plugin list, not through the app's normal module graph — so it's **not** part of Vite's HMR. Restart `npm run dev` after editing anything there; a hot-reload will silently keep running the old code, which reads as a mysterious bug if you don't know this.
- **Gotcha (verified live)**: `apps/template`'s own `npm run dev` alone does **not** hot-reload `@webavatar/webavatar-core`/`@webavatar/webavatar-humanoid` source changes — those packages resolve through their built `dist/`, and nothing recompiles it unless the package's own `tsc --watch` is also running. Use the root `npm run dev` (builds once, then watches both packages alongside `apps/template`'s dev server) for working hot reload. Separately, the realtime WS relay's dev-mode plugin wiring (`@webavatar/webavatar-core/vite`) loads once at dev-server startup and is never hot-reloaded regardless — a full restart is required after editing it. See [docs/REALTIME_SESSION.md](docs/REALTIME_SESSION.md)'s "Dev-mode reload behavior" for the full picture.
## Documentation

37
README.md

@ -1,34 +1,47 @@
# webavatar-core
# webavatar
A base SvelteKit project meant to be branched off for future webavatar client projects. It provides the reusable core infrastructure those projects would otherwise reimplement from scratch — session management, auth tokens, and the realtime model connection — as a foundation to branch from, not a finished product.
An npm-workspaces monorepo providing reusable core infrastructure for webavatar client projects — session management, auth tokens, and the realtime model connection — as independently-versioned packages, plus a SvelteKit template to branch client projects from.
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full architecture map, and [AGENTS.md](AGENTS.md) for project conventions and structure. `docs/` holds a deep-dive per subsystem.
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full architecture map, [AGENTS.md](AGENTS.md) for project conventions, and `docs/` for a deep-dive per subsystem.
## Workspaces
| Workspace | What it is |
| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`packages/core`](packages/core) | `@webavatar/webavatar-core` — session management, auth tokens, provider adapters, the WS relay. Every client project needs this. |
| [`packages/humanoid`](packages/humanoid) | `@webavatar/webavatar-humanoid` — avatar-state derivation and animation-channel composition for 3D humanoid avatars. Optional — only projects building a humanoid avatar need it. |
| [`apps/template`](apps/template) | The SvelteKit application template client projects branch from. See its own README for the client-project bootstrap procedure. |
Both packages are privately distributed (not published to the public npm registry) and ESM-only.
## Getting started
```sh
npm install
cp .env.example .env
# fill in OPENAI_API_KEY and REALTIME_TOKEN_SECRET in .env
cp apps/template/.env.example apps/template/.env
# fill in OPENAI_API_KEY and REALTIME_TOKEN_SECRET in apps/template/.env
npm run dev
```
Open [`/demo/realtime`](http://localhost:5173/demo/realtime) for the realtime session reference wiring, or [`/demo/humanoid`](http://localhost:5173/demo/humanoid) for the humanoid avatar-state demo (works offline via `REALTIME_PROVIDER=mock`, no API key needed).
The root `npm run dev` builds both packages once, then watches them alongside the template app's own dev server — this is what makes package-source hot reload work (see [docs/REALTIME_SESSION.md](docs/REALTIME_SESSION.md)'s "Dev-mode reload behavior"). Open [`/demo/realtime`](http://localhost:5173/demo/realtime) or [`/demo/humanoid`](http://localhost:5173/demo/humanoid) (works offline via `REALTIME_PROVIDER=mock`, no API key needed).
## Scripts
- `npm run dev` / `npm run build` / `npm run preview` — standard SvelteKit commands.
- `npm run check` — type-check. `npm run lint` / `npm run format` — prettier + eslint.
- `npm run test:unit` — vitest. `npm run test:e2e` — Playwright.
- `npm start` — run the production build via `server.js` (the persistent Node process the realtime WebSocket relay requires — see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)).
Run from the repo root, these orchestrate all three workspaces:
- `npm run dev` / `npm run build` / `npm run check` / `npm test` — build the packages first, then run the equivalent command across every workspace.
- `npm run lint` / `npm run format` — prettier + eslint across the whole repo.
Run a single workspace's own scripts with `-w`, e.g. `npm run dev -w apps/template`, or `npm run test -w packages/core`.
## Documentation
| Subsystem | Doc |
| -------------------------------------------------------- | ---------------------------------------------------- |
| --------------------------------------------------------------------- | -------------------------------------------------------------------- |
| Architecture overview and module layering | [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) |
| Realtime session management | [docs/REALTIME_SESSION.md](docs/REALTIME_SESSION.md) |
| Tool calling (server- and client-executed) | [docs/TOOLS.md](docs/TOOLS.md) |
| Analytics recording | [docs/ANALYTICS.md](docs/ANALYTICS.md) |
| Configuration (env vars, persona) | [docs/CONFIGURATION.md](docs/CONFIGURATION.md) |
| `humanoid` package (avatar-state, animation composition) | [docs/HUMANOID.md](docs/HUMANOID.md) |
| `@webavatar/webavatar-humanoid` (avatar-state, animation composition) | [docs/HUMANOID.md](docs/HUMANOID.md) |
| The monorepo migration itself — rationale, decisions, verification | [docs/MONOREPO_MIGRATION_BRIEF.md](docs/MONOREPO_MIGRATION_BRIEF.md) |

0
.env.example → apps/template/.env.example

49
apps/template/README.md

@ -0,0 +1,49 @@
# webavatar-template
The SvelteKit application template webavatar client projects branch from. It composes `@webavatar/webavatar-core` and `@webavatar/webavatar-humanoid` with project-owned persona, tools, and deployment configuration — see the monorepo root's [docs/ARCHITECTURE.md](../../docs/ARCHITECTURE.md) for the full architecture map, and [AGENTS.md](../../AGENTS.md) for project conventions. `docs/` holds a deep-dive per subsystem.
## Getting started (inside this monorepo)
```sh
npm install # from the monorepo root
cp apps/template/.env.example apps/template/.env
# fill in OPENAI_API_KEY and REALTIME_TOKEN_SECRET in apps/template/.env
npm run dev # from the monorepo root — builds the packages, then watches them alongside this app
```
Open [`/demo/realtime`](http://localhost:5173/demo/realtime) for the realtime session reference wiring, or [`/demo/humanoid`](http://localhost:5173/demo/humanoid) for the humanoid avatar-state demo (works offline via `REALTIME_PROVIDER=mock`, no API key needed).
Running `npm run dev` from _this_ directory alone starts the app but does **not** hot-reload package source changes — see [docs/REALTIME_SESSION.md](../../docs/REALTIME_SESSION.md)'s "Dev-mode reload behavior" for why, and use the root `npm run dev` instead when iterating on `packages/core` or `packages/humanoid`.
## Scripts
- `npm run dev` / `npm run build` / `npm run preview` — standard SvelteKit commands.
- `npm run check` — type-check. `npm run lint` / `npm run format` — prettier + eslint.
- `npm run test:unit` — vitest. `npm run test:e2e` — Playwright.
- `npm start` — run the production build via `server.js` (the persistent Node process the realtime WebSocket relay requires — see [docs/ARCHITECTURE.md](../../docs/ARCHITECTURE.md)).
## Documentation
| Subsystem | Doc |
| --------------------------------------------------------------------- | ---------------------------------------------------------- |
| Architecture overview and module layering | [docs/ARCHITECTURE.md](../../docs/ARCHITECTURE.md) |
| Realtime session management | [docs/REALTIME_SESSION.md](../../docs/REALTIME_SESSION.md) |
| Tool calling (server- and client-executed) | [docs/TOOLS.md](../../docs/TOOLS.md) |
| Analytics recording | [docs/ANALYTICS.md](../../docs/ANALYTICS.md) |
| Configuration (env vars, persona) | [docs/CONFIGURATION.md](../../docs/CONFIGURATION.md) |
| `@webavatar/webavatar-humanoid` (avatar-state, animation composition) | [docs/HUMANOID.md](../../docs/HUMANOID.md) |
## Starting a client project from this template
The packages (`@webavatar/webavatar-core`, `@webavatar/webavatar-humanoid`) are what a client project upgrades; this template is what it starts from. A project installs versions of the packages — it does not merge package source from this repository.
1. Copy this directory (`apps/template`) into a new repository. Do not copy the monorepo root, `packages/`, or the root lockfile.
2. The copied `package.json` becomes the project's own root manifest. It stays `"private": true`. Its `@webavatar/*` ranges no longer resolve to workspace links, so pin them to published versions.
3. Add the committed `.npmrc` scope mapping (`@webavatar:registry=...`), configure registry authentication, and run `npm install`.
4. Copy `.env.example` to `.env`, fill in the secrets, and run `npm run dev`. The demo routes stay dev-only gated and can be kept or deleted.
5. Project code goes under `src/lib/**/project`, exactly as in this template.
6. To upgrade, bump the package versions; a peer-range conflict between core and humanoid surfaces as an `npm install` error — that's the intended signal, not a bug.
Changes to this template shell itself (`server.js`, `vite.config.ts`, routes, deployment files) don't arrive through package upgrades; a project applies them by hand. The composition-root factory (`createRealtimeRuntime`, from `@webavatar/webavatar-core/server`) is what keeps the shell thin enough that this stays rare — a change in how the relay is assembled ships inside a core release instead.
**Current status:** steps 3 and 6 (installing from the registry, and the peer-range upgrade signal) require the private Gitea package registry, which isn't provisioned yet — see `docs/MONOREPO_MIGRATION_BRIEF.md`'s "Decisions required before handoff". Steps 1, 2, 4, and 5 work today; the packages themselves have been verified installable from packed tarballs in place of the registry (see the migration brief's verification requirements).

49
apps/template/package.json

@ -0,0 +1,49 @@
{
"name": "webavatar-template",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build && tsc -p tsconfig.server.json",
"preview": "vite preview",
"start": "node server.js",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write .",
"test:unit": "vitest",
"test": "npm run test:unit -- --run && npm run test:e2e",
"test:e2e": "playwright install && playwright test"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.60.0",
"@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@types/node": "^24",
"@types/three": "^0.185.4",
"@vitest/browser-playwright": "^4.1.8",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.19.0",
"globals": "^17.6.0",
"playwright": "^1.60.0",
"prettier": "^3.8.3",
"prettier-plugin-svelte": "^4.1.0",
"svelte": "^5.56.1",
"svelte-check": "^4.6.0",
"three": "^0.185.1",
"typescript": "^6.0.3",
"typescript-eslint": "^8.60.1",
"vite": "^8.0.16",
"vitest": "^4.1.8",
"vitest-browser-svelte": "^2.1.1"
},
"dependencies": {
"@webavatar/webavatar-core": "^0.1.0",
"@webavatar/webavatar-humanoid": "^0.1.0"
}
}

0
playwright.config.ts → apps/template/playwright.config.ts

27
apps/template/server-only-guard.ts

@ -0,0 +1,27 @@
import type { Plugin } from 'vite';
const SERVER_ONLY = /^@webavatar\/webavatar-core\/(server|vite)(\/|$)/;
/**
* Build-time backstop for the server-only boundary: SvelteKit's own
* `$lib/server` guard is path-based and doesn't reach into `node_modules`, so
* once core's server code is a package this is what makes a client-side
* import of `@webavatar/webavatar-core/server` (or `/vite`) fail the build
* rather than only failing at runtime via the package's `browser` stub. See
* docs/ARCHITECTURE.md's "Server-only safety" section.
*/
export function serverOnlyGuard(): Plugin {
return {
name: 'webavatar-server-only-guard',
enforce: 'pre',
resolveId(id, importer, options) {
const isClient = this.environment ? this.environment.name === 'client' : !options?.ssr;
if (isClient && SERVER_ONLY.test(id)) {
throw new Error(
`${id} is server-only; import it through $lib/server instead (importer: ${importer}).`
);
}
return null;
}
};
}

23
apps/template/server.js

@ -0,0 +1,23 @@
// Custom production server: wraps adapter-node's generated request handler in
// a plain http.Server so we can also intercept WebSocket upgrade requests,
// which SvelteKit itself has no hook for (see @webavatar/webavatar-core's
// server/ws-handler.ts).
//
// `.env` is not auto-loaded here (SvelteKit's own adapter-node doesn't in
// production either) — run with `node --env-file=.env server.js` locally, or
// provide OPENAI_API_KEY / REALTIME_TOKEN_SECRET via your deployment's real
// secret mechanism.
import { createServer } from 'node:http';
import { createRealtimeRuntime } from '@webavatar/webavatar-core/server';
import { handler } from './build/handler.js';
import { SESSION_PERSONA } from './build-server/project/persona.js';
import { toolRegistry } from './build-server/server/project/tools/index.js';
const runtime = createRealtimeRuntime({ persona: SESSION_PERSONA, toolRegistry, isDev: false });
const server = createServer(handler);
server.on('upgrade', runtime.handleUpgrade);
server.listen(runtime.config.port, () => {
console.log(`listening on port ${runtime.config.port}`);
});

0
src/app.d.ts → apps/template/src/app.d.ts

0
src/app.html → apps/template/src/app.html

0
src/lib/assets/favicon.svg → apps/template/src/lib/assets/favicon.svg

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

17
src/lib/client/core/DebugPanel.svelte → apps/template/src/lib/client/DebugPanel.svelte

@ -1,12 +1,17 @@
<script lang="ts">
import type { LogLevel } from '../../core/logger.js';
import { clearLogs, debugLogStore, type DisplayLogEntry } from './logger.js';
import type { ServerLogForwarder, ServerLogForwarderState } from './server-log-forwarder.js';
import type { LogLevel } from '@webavatar/webavatar-core';
import {
clearLogs,
debugLogStore,
type DisplayLogEntry,
type ServerLogForwarder,
type ServerLogForwarderState
} from '@webavatar/webavatar-core/client';
/**
* Production-shippable debug panel — lives in `$lib/client/core` (not
* `/demo`, which is dev-only-gated scaffolding) since the key-based auth
* on `forwarder`, not route removal, is what protects it in prod. See
* Production-shippable debug panel — lives in `$lib/client` (not `/demo`,
* which is dev-only-gated scaffolding) since the key-based auth on
* `forwarder`, not route removal, is what protects it in prod. See
* docs/LOGGING.md.
*
* `forwarder` is optional: with none given (or on a page with no realtime

2
src/lib/client/core/DebugPanel.svelte.spec.ts → apps/template/src/lib/client/DebugPanel.svelte.spec.ts

@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { render } from 'vitest-browser-svelte';
import { clearLogs, clientLogger } from './logger.js';
import { clearLogs, clientLogger } from '@webavatar/webavatar-core/client';
import DebugPanel from './DebugPanel.svelte';
beforeEach(() => {

2
src/lib/client/project/tools/flash-page.ts → apps/template/src/lib/client/project/tools/flash-page.ts

@ -1,4 +1,4 @@
import type { ClientToolHandler } from '../../core/tool-dispatcher.js';
import type { ClientToolHandler } from '@webavatar/webavatar-core/client';
/** Briefly flashes the page background — proves the client tool relay round-trip visibly. */
export const flashPage: ClientToolHandler = async () => {

2
src/lib/client/project/tools/index.ts → apps/template/src/lib/client/project/tools/index.ts

@ -1,4 +1,4 @@
import type { ClientToolHandler } from '../../core/tool-dispatcher.js';
import type { ClientToolHandler } from '@webavatar/webavatar-core/client';
import { flashPage } from './flash-page.js';
/** This project's client-side tool handlers, keyed by tool name. */

0
src/lib/index.ts → apps/template/src/lib/index.ts

14
src/lib/project/persona.ts → apps/template/src/lib/project/persona.ts

@ -3,20 +3,18 @@
* branched client project actually wants. Isomorphic (no server/client-only
* imports), mirroring `$lib/project/tools.ts`: core never imports this file
* directly (see docs/TOOLS.md) — `resolveSessionDefaults` in
* `$lib/server/core/defaults.ts` takes it as a parameter instead, and the
* composition roots (server.js, vite.config.ts) are what wire it in.
* `$lib/server/core/defaults.ts` takes a `SessionPersona` as a parameter
* instead, and the composition roots (server.js, vite.config.ts) are what
* wire it in.
*
* A branched project edits this file freely. `model` can also be overridden
* per-deployment via the `SESSION_MODEL` env var (see
* $lib/server/core/config.ts); `instructions` deliberately has no env
* override — prose doesn't belong in a .env value, and this file is its home.
*/
export interface SessionPersona {
model: string;
instructions: string;
}
import type { SessionPersona } from '@webavatar/webavatar-core';
export const SESSION_PERSONA: SessionPersona = {
export const SESSION_PERSONA = {
model: 'gpt-realtime',
instructions: 'You are a helpful voice assistant for a webavatar demo. Keep responses concise.'
};
} satisfies SessionPersona;

2
src/lib/project/tools.ts → apps/template/src/lib/project/tools.ts

@ -1,4 +1,4 @@
import type { ToolDefinition } from '../core/tools.js';
import type { ToolDefinition } from '@webavatar/webavatar-core';
/**
* Project-owned tool definitions — isomorphic (needed by the server at

2
src/lib/server/project/tools/get-current-time.ts → apps/template/src/lib/server/project/tools/get-current-time.ts

@ -1,4 +1,4 @@
import type { ToolExecutor } from '../../core/tools.js';
import type { ToolExecutor } from '../../webavatar-core.js';
export const getCurrentTime: ToolExecutor = async () => {
return { iso: new Date().toISOString() };

2
src/lib/server/project/tools/index.ts → apps/template/src/lib/server/project/tools/index.ts

@ -1,4 +1,4 @@
import { createToolRegistry } from '../../core/tools.js';
import { createToolRegistry } from '../../webavatar-core.js';
import { TOOL_DEFINITIONS } from '../../../project/tools.js';
import { getCurrentTime } from './get-current-time.js';

1
apps/template/src/lib/server/webavatar-core.ts

@ -0,0 +1 @@
export * from '@webavatar/webavatar-core/server';

0
src/routes/+layout.svelte → apps/template/src/routes/+layout.svelte

0
src/routes/+page.svelte → apps/template/src/routes/+page.svelte

3
src/routes/api/realtime/session/+server.ts → apps/template/src/routes/api/realtime/session/+server.ts

@ -1,6 +1,5 @@
import { json } from '@sveltejs/kit';
import { loadConfig } from '$lib/server/core/config.js';
import { mintSessionToken } from '$lib/server/core/tokens.js';
import { loadConfig, mintSessionToken } from '$lib/server/webavatar-core.js';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = () => {

0
src/routes/demo/+layout.server.ts → apps/template/src/routes/demo/+layout.server.ts

0
src/routes/demo/humanoid/+page.svelte → apps/template/src/routes/demo/humanoid/+page.svelte

16
src/routes/demo/humanoid/AvatarStateDemo.svelte → apps/template/src/routes/demo/humanoid/AvatarStateDemo.svelte

@ -1,11 +1,8 @@
<script lang="ts">
import {
ResilientRealtimeClient,
type ConnectionState
} from '$lib/client/core/resilient-client.js';
import { deriveAvatarState, type AvatarStateStore } from '$lib/client/humanoid/avatar-state.js';
import type { ServerEvent } from '$lib/core/protocol.js';
import type { AvatarState } from '$lib/humanoid/state.js';
import { ResilientRealtimeClient, type ConnectionState } from '@webavatar/webavatar-core/client';
import { deriveAvatarState, type AvatarStateStore } from '@webavatar/webavatar-humanoid/client';
import type { ServerEvent } from '@webavatar/webavatar-core';
import type { AvatarState } from '@webavatar/webavatar-humanoid';
type Status = 'idle' | 'connecting' | 'reconnecting' | 'ready' | 'error' | 'closed';
@ -35,7 +32,7 @@
await nextClient.connect();
client = nextClient;
// See $lib/humanoid — this is the "humanoid" optional package's
// See @webavatar/webavatar-humanoid — this is the "humanoid" optional package's
// contract layer, not tied to any renderer/UI. deriveAvatarState
// takes the client structurally (RealtimeClient or
// ResilientRealtimeClient both work).
@ -91,7 +88,8 @@
<h2>Avatar-state demo</h2>
<p>
Exercises <code>$lib/humanoid</code>'s state derivation without a live API key: run
Exercises <code>@webavatar/webavatar-humanoid</code>'s state derivation without a live API key:
run
<code>REALTIME_PROVIDER=mock npm run dev</code> (dev-server restart required — see AGENTS.md's HMR
gotcha) and connect below to watch <code>avatarState</code> move through the mock provider's default
fixture.

19
src/routes/demo/humanoid/EyeSystemDemo.svelte → apps/template/src/routes/demo/humanoid/EyeSystemDemo.svelte

@ -3,11 +3,14 @@
import {
createEyeGazeController,
type EyeGazeStore
} from '$lib/client/humanoid/eye/gaze-controller.js';
import { gazeOffsetToAngleRad, gazeOffsetToBlendShapes } from '$lib/humanoid/eye/blend-shapes.js';
import { DEFAULT_EYE_CONFIG } from '$lib/humanoid/eye/config.js';
import type { EyeGazeState } from '$lib/humanoid/eye/gaze.js';
import type { AvatarState } from '$lib/humanoid/state.js';
} from '@webavatar/webavatar-humanoid/eye/client';
import {
gazeOffsetToAngleRad,
gazeOffsetToBlendShapes,
DEFAULT_EYE_CONFIG,
type EyeGazeState
} from '@webavatar/webavatar-humanoid/eye';
import type { AvatarState } from '@webavatar/webavatar-humanoid';
import ThreeAvatarViewer from './eye-demo/ThreeAvatarViewer.svelte';
import GazeDebugPanel from './eye-demo/GazeDebugPanel.svelte';
import GazePlaneVisualizer from './eye-demo/GazePlaneVisualizer.svelte';
@ -68,9 +71,9 @@
<h2>Eye system demo</h2>
<p>
Exercises <code>$lib/humanoid/eye</code>'s Macro/Micro/Nano gaze layers live, against the bundled
ARKit-rigged test avatar — see <code>docs/HUMANOID.md</code> and
<code>src/lib/humanoid/eye/DESIGN_NOTES.md</code>.
Exercises <code>@webavatar/webavatar-humanoid/eye</code>'s Macro/Micro/Nano gaze layers live,
against the bundled ARKit-rigged test avatar — see <code>docs/HUMANOID.md</code> and
<code>packages/humanoid/src/eye/DESIGN_NOTES.md</code>.
</p>
{#if gazeState}

5
src/routes/demo/humanoid/eye-demo/GazeDebugPanel.svelte → apps/template/src/routes/demo/humanoid/eye-demo/GazeDebugPanel.svelte

@ -1,7 +1,6 @@
<script lang="ts">
import type { AvatarState } from '$lib/humanoid/state.js';
import type { AnimationFrame } from '$lib/humanoid/animation.js';
import type { EyeGazeState } from '$lib/humanoid/eye/gaze.js';
import type { AvatarState, AnimationFrame } from '@webavatar/webavatar-humanoid';
import type { EyeGazeState } from '@webavatar/webavatar-humanoid/eye';
interface Vec2 {
x: number;

0
src/routes/demo/humanoid/eye-demo/GazePlaneVisualizer.svelte → apps/template/src/routes/demo/humanoid/eye-demo/GazePlaneVisualizer.svelte

2
src/routes/demo/humanoid/eye-demo/ThreeAvatarViewer.svelte → apps/template/src/routes/demo/humanoid/eye-demo/ThreeAvatarViewer.svelte

@ -3,7 +3,7 @@
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import type { Attachment } from 'svelte/attachments';
import type { AnimationFrame } from '$lib/humanoid/animation.js';
import type { AnimationFrame } from '@webavatar/webavatar-humanoid';
interface Props {
/** The composited eye-system output for the current tick — applied to the model's morph targets every time it changes. */

14
src/routes/demo/realtime/+page.svelte → apps/template/src/routes/demo/realtime/+page.svelte

@ -2,13 +2,13 @@
import { dev } from '$app/environment';
import {
ResilientRealtimeClient,
type ConnectionState
} from '$lib/client/core/resilient-client.js';
import { createToolDispatcher } from '$lib/client/core/tool-dispatcher.js';
import DebugPanel from '$lib/client/core/DebugPanel.svelte';
import { attachServerLogForwarding } from '$lib/client/core/server-log-forwarder.js';
import type { ServerLogForwarder } from '$lib/client/core/server-log-forwarder.js';
import type { ServerEvent } from '$lib/core/protocol.js';
type ConnectionState,
createToolDispatcher,
attachServerLogForwarding,
type ServerLogForwarder
} from '@webavatar/webavatar-core/client';
import DebugPanel from '$lib/client/DebugPanel.svelte';
import type { ServerEvent } from '@webavatar/webavatar-core';
import { CLIENT_TOOL_HANDLERS } from '$lib/client/project/tools/index.js';
type Status = 'idle' | 'connecting' | 'reconnecting' | 'ready' | 'error' | 'closed';

0
static/models/avatar.glb → apps/template/static/models/avatar.glb

0
static/robots.txt → apps/template/static/robots.txt

18
tsconfig.json → apps/template/tsconfig.json

@ -1,15 +1,25 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
// Inlined rather than extending the monorepo's tsconfig.base.json: this
// app is meant to be copied out and started as a standalone client
// project (see "Starting a client project from the template" in the
// template README), which explicitly does not bring the monorepo root
// along — a relative `extends` up into it would break the moment it's
// copied. packages/core and packages/humanoid don't have this
// constraint (they're consumed via npm, never copied) and keep
// extending tsconfig.base.json normally.
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias

7
tsconfig.server.json → apps/template/tsconfig.server.json

@ -12,11 +12,6 @@
"declaration": false,
"types": ["node"]
},
"include": [
"src/lib/core/**/*.ts",
"src/lib/server/core/**/*.ts",
"src/lib/project/**/*.ts",
"src/lib/server/project/**/*.ts"
],
"include": ["src/lib/project/**/*.ts", "src/lib/server/project/**/*.ts"],
"exclude": ["**/*.spec.ts"]
}

34
vite.config.ts → apps/template/vite.config.ts

@ -2,11 +2,12 @@ import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright';
import adapter from '@sveltejs/adapter-node';
import { sveltekit } from '@sveltejs/kit/vite';
import { realtimeDevWsPlugin } from './src/lib/server/core/dev-plugin.js';
import { realtimeDevWsPlugin } from '@webavatar/webavatar-core/vite';
import { toolRegistry } from './src/lib/server/project/tools/index.js';
import { SESSION_PERSONA } from './src/lib/project/persona.js';
import { serverOnlyGuard } from './server-only-guard.js';
export default defineConfig({
export default defineConfig(({ command }) => ({
plugins: [
sveltekit({
compilerOptions: {
@ -16,8 +17,9 @@ export default defineConfig({
},
// adapter-node: the realtime WS relay needs a persistent Node process
// (see src/lib/server/core/ws-handler.ts) — no serverless adapter can
// hold an upgraded WebSocket connection the way this can.
// (see @webavatar/webavatar-core's server/ws-handler.ts) — no
// serverless adapter can hold an upgraded WebSocket connection the
// way this can.
adapter: adapter()
}),
// Attaches the same WS upgrade handling used in production (server.js)
@ -25,10 +27,26 @@ export default defineConfig({
// Passes only raw, env-independent pieces (persona + tool registry) —
// NOT a fully-resolved `defaults` object — because this plugin's config
// gets evaluated under `npm run test:unit` too (see the VITEST guard in
// dev-plugin.ts's configureServer); resolving env-driven config here at
// module scope would run it there as well, with no env vars available.
realtimeDevWsPlugin({ persona: SESSION_PERSONA, toolRegistry })
// @webavatar/webavatar-core/vite's configureServer); resolving
// env-driven config here at module scope would run it there as well,
// with no env vars available.
realtimeDevWsPlugin({ persona: SESSION_PERSONA, toolRegistry }),
serverOnlyGuard()
],
// Verified against the installed Vite: for SSR, Vite externalizes a
// dependency only if it resolves inside `node_modules` — a workspace
// package is a symlink whose real path is `packages/core/...`, so without
// this the monorepo would bundle core's server code into the SvelteKit
// server build while a client project that installed the published
// package gets it externalized, running a different artifact than this
// template does. Deliberately not applied in `dev`: externalizing would
// load the package through Node's module cache, losing hot reload of
// package changes for SSR routes during development. See
// docs/ARCHITECTURE.md's "Artifact parity" section.
ssr:
command === 'build'
? { external: ['@webavatar/webavatar-core', '@webavatar/webavatar-humanoid'] }
: undefined,
// three/addons/* (GLTFLoader, OrbitControls — see the eye-demo Three.js
// viewer) are deep imports Vite's dependency scanner doesn't always
// discover on its own; without this, dev mode can end up serving two
@ -70,4 +88,4 @@ export default defineConfig({
}
]
}
});
}));

28
docs/ANALYTICS.md

@ -4,7 +4,7 @@ Server-side recording of session volume, conversation content, tool usage, and f
## The hook point
`RealtimeSession` (`session.ts`) is the one place every session event in both directions already funnels through. Analytics attaches at choke points that already exist there for other reasons — `start()`, `close()`, `resolveToolCall()`, `sendToBrowser()` — rather than adding new plumbing. See `src/lib/server/core/analytics.ts` for the `AnalyticsRecorder` interface and its `better-sqlite3`-backed implementation.
`RealtimeSession` (`packages/core/src/server/session.ts`) is the one place every session event in both directions already funnels through. Analytics attaches at choke points that already exist there for other reasons — `start()`, `close()`, `resolveToolCall()`, `sendToBrowser()` — rather than adding new plumbing. See `packages/core/src/server/analytics.ts` (exported from `@webavatar/webavatar-core/server`) for the `AnalyticsRecorder` interface and its `better-sqlite3`-backed implementation.
## Why `better-sqlite3`, not `node:sqlite`
@ -12,17 +12,17 @@ Node has an experimental built-in SQLite module, but `better-sqlite3` was chosen
## Failure isolation
Analytics is best-effort telemetry, not part of session correctness. Every `AnalyticsRecorder` call in `session.ts` runs through one non-throwing boundary: a full disk, locked database, or other recorder failure is reported through the server logger under the `analytics` category, then normal provider traffic, browser delivery, and teardown continue. Failure reporting is itself guarded so a broken logger cannot turn a telemetry failure back into a session failure.
Analytics is best-effort telemetry, not part of session correctness. Every `AnalyticsRecorder` call in `packages/core/src/server/session.ts` runs through one non-throwing boundary: a full disk, locked database, or other recorder failure is reported through the server logger under the `analytics` category, then normal provider traffic, browser delivery, and teardown continue. Failure reporting is itself guarded so a broken logger cannot turn a telemetry failure back into a session failure.
This boundary covers writes after the recorder has been constructed. Recorder construction still happens eagerly at process startup; an invalid/unwritable database path is a configuration or boot failure rather than a mid-session degradation case.
## The transcript-buffering gotcha
`transcript.done` (see `protocol.ts`) carries only `{ role }` — no text. Full message text has to be assembled server-side:
`transcript.done` (see `packages/core/src/protocol.ts`) carries only `{ role }` — no text. Full message text has to be assembled server-side:
- **Typed user text** (`input_text.send`): already a complete string — logged directly, no buffering.
- **Assistant voice replies**: stream as many `transcript.delta{role:'assistant'}` chunks as OpenAI cares to send, followed by one text-less `transcript.done{role:'assistant'}`. `RealtimeSession` accumulates deltas into `assistantTranscriptBuffer` and flushes+logs the concatenated text on `done`.
- **User voice transcripts**: today, OpenAI's `conversation.item.input_audio_transcription.completed` maps (in `providers/openai.ts`) to a single `transcript.delta{role:'user'}` event that already carries the _entire_ transcript — no corresponding `transcript.done{role:'user'}` is ever emitted by that mapping. So a user-role delta is logged immediately as a complete message, not buffered.
- **User voice transcripts**: today, OpenAI's `conversation.item.input_audio_transcription.completed` maps (in `packages/core/src/server/providers/openai.ts`) to a single `transcript.delta{role:'user'}` event that already carries the _entire_ transcript — no corresponding `transcript.done{role:'user'}` is ever emitted by that mapping. So a user-role delta is logged immediately as a complete message, not buffered.
**This rests on unverified live behavior** — the same caveat class as REALTIME_SESSION.md's "Still unverified" note on this exact OpenAI event. If a live session ever produces more than one `messages` row for a single spoken user turn, that assumption has broken and this logic needs revisiting (see "Verifying changes" below for how to check).
@ -43,9 +43,9 @@ Foreign keys are declared for documentation but explicitly not enforced (`PRAGMA
## Configuration
Env vars are documented in full in [docs/CONFIGURATION.md](CONFIGURATION.md) — `ANALYTICS_DB_PATH` (parent directory created automatically if missing; `/data` is gitignored), plus `ANALYTICS_ENABLED` and three per-category toggles, described here since the selection logic is analytics-specific:
Env vars are documented in full in [docs/CONFIGURATION.md](CONFIGURATION.md) — `ANALYTICS_DB_PATH` (parent directory created automatically if missing; `apps/*/data` is gitignored), plus `ANALYTICS_ENABLED` and three per-category toggles, described here since the selection logic is analytics-specific:
`ANALYTICS_ENABLED=false` selects `NoopAnalyticsRecorder` instead of `SqliteAnalyticsRecorder` at the composition root (`server.js` / `dev-plugin.ts`) — every method is a no-op, so no `data/analytics.db` file is created at all, not just left empty.
`ANALYTICS_ENABLED=false` selects `NoopAnalyticsRecorder` instead of `SqliteAnalyticsRecorder` inside the shared `createRealtimeRuntime` factory (`packages/core/src/server/index.ts`), which both composition roots — `apps/template/server.js` and the `./vite` dev plugin — call into rather than each duplicating the choice — every method is a no-op, so no `apps/template/data/analytics.db` file is created at all, not just left empty.
With analytics enabled, three independent toggles gate what `SqliteAnalyticsRecorder` actually writes: `ANALYTICS_LOG_MESSAGES` (the `messages` table), `ANALYTICS_LOG_TOOL_CALLS` (the `tool_calls` table), `ANALYTICS_LOG_EVENTS` (the `events` table). Each is a guard at the top of the corresponding recorder method — `session.ts`'s call sites are unaffected and still call every method unconditionally at the same lifecycle hook points described above. There is no toggle for the `sessions` row (start/end, model, voice, timing): that base volume signal is always recorded whenever the recorder is real, regardless of the three category toggles.
@ -63,14 +63,14 @@ With analytics enabled, three independent toggles gate what `SqliteAnalyticsReco
## Verifying changes
- `npm run check` / `npm run lint` — type/lint correctness. Note `check` uses `bundler` module resolution and will **not** catch a missing `.js` extension on a relative import inside `src/lib/server/core/**`.
- `npm run build` — also runs the standalone `tsc -p tsconfig.server.json` compile (`nodenext` resolution) that `server.js` depends on; this is the step that catches a missing `.js` extension or a file placed outside that config's `include` globs.
- `npm run check` / `npm run lint` — type/lint correctness. Note `check` uses `bundler` module resolution and will **not** catch a missing `.js` extension on a relative import inside `packages/core/src/server/**`.
- `npm run build` — also runs the standalone `tsc -p tsconfig.server.json` compile (`nodenext` resolution) that `apps/template/server.js` depends on; this is the step that catches a missing `.js` extension or a file placed outside that config's `include` globs.
- `npm run test:unit` — `analytics.spec.ts` (schema + each recorder method against `:memory:`, plus a WAL-mode check against a real temp file) and the extended `session.spec.ts` (asserts `AnalyticsRecorder` calls happen at the right lifecycle points, using a hand-written `FakeAnalyticsRecorder` in the same no-`vi.fn()` style as this file's other fakes).
- **Live smoke test**: set `.env`, `npm run dev`, open `/demo/realtime`, and hold a real conversation — a typed message, a full voice turn in both directions, and a tool call (the demo project registers `get_current_time` (server) and `flash_page` (client) out of the box). Then inspect `data/analytics.db` directly:
- **Live smoke test**: set `.env`, `npm run dev`, open `/demo/realtime`, and hold a real conversation — a typed message, a full voice turn in both directions, and a tool call (the demo project registers `get_current_time` (server) and `flash_page` (client) out of the box). Then inspect `apps/template/data/analytics.db` directly:
```bash
sqlite3 data/analytics.db "select * from sessions;"
sqlite3 data/analytics.db "select id, role, substr(text,1,80), created_at from messages order by id;"
sqlite3 data/analytics.db "select call_id, name, duration_ms from tool_calls;"
sqlite3 data/analytics.db "select type, detail from events;"
sqlite3 apps/template/data/analytics.db "select * from sessions;"
sqlite3 apps/template/data/analytics.db "select id, role, substr(text,1,80), created_at from messages order by id;"
sqlite3 apps/template/data/analytics.db "select call_id, name, duration_ms from tool_calls;"
sqlite3 apps/template/data/analytics.db "select type, detail from events;"
```
Confirm exactly one assistant `messages` row per spoken AI turn with the **full** assembled text (not fragments), exactly one user `messages` row per spoken user turn (the direct check on the unverified single-chunk assumption above), and — kill the dev server or close the tab mid-session once — that `sessions.ended_at`/`end_reason` get populated (the direct check on the bug fix above). Remember `src/lib/server/core/**` isn't part of Vite's HMR — restart `npm run dev` after editing anything here.
Confirm exactly one assistant `messages` row per spoken AI turn with the **full** assembled text (not fragments), exactly one user `messages` row per spoken user turn (the direct check on the unverified single-chunk assumption above), and — kill the dev server or close the tab mid-session once — that `sessions.ended_at`/`end_reason` get populated (the direct check on the bug fix above). See [REALTIME_SESSION.md](REALTIME_SESSION.md)'s "Dev-mode reload behavior" table for what actually hot-reloads vs. needs a restart when editing package source — and note you need the root `npm run dev`, not `apps/template`'s own, for edits under `packages/core/src/server/**` to take effect at all.

38
docs/ARCHITECTURE.md

@ -1,33 +1,37 @@
# Architecture
webavatar-core is a base SvelteKit project meant to be branched off for future webavatar client projects. Its job is to provide the core infrastructure those projects would otherwise reimplement from scratch — session management, auth tokens, and the realtime model connection — as a clean, extensible foundation rather than a finished product.
webavatar is an npm-workspaces monorepo providing the core infrastructure future webavatar client projects would otherwise reimplement from scratch — session management, auth tokens, and the realtime model connection — as independently-versioned packages, plus a SvelteKit template to branch client projects from. See `docs/MONOREPO_MIGRATION_BRIEF.md` for the rationale and history behind this structure (converted from a single-app template where the same code lived under the app's own `src/lib/`).
## Module layering
Code that's part of this core infrastructure lives under `src/lib/` in three places, split by where it's allowed to run:
Three top-level directories, each a separate npm workspace:
- **`src/lib/core/`** — shared, isomorphic contracts (types, protocols, constants). Importable by both browser and server code. Must stay free of SvelteKit-specific magic (no `$env/*`, no server-only imports) so it can also be compiled standalone outside Vite when needed (see [REALTIME_SESSION.md](REALTIME_SESSION.md) for why that matters).
- **`src/lib/server/core/`** — server-only implementations: session orchestration, provider adapters, token minting/verification, raw HTTP upgrade handling. SvelteKit enforces this boundary at build time — browser code cannot import from here, even transitively.
- **`src/lib/client/core/`** — thin browser-side wrappers around the shared contracts. No business logic; just transport.
- **`packages/core/`** (`@webavatar/webavatar-core`) — infrastructure every branched client project needs: session orchestration, provider adapters, token minting/verification, the WS relay, and the Vite dev-server integration. Published as a normal (privately-distributed) npm package with curated entry points:
- **`.`** (root) — shared, isomorphic contracts (types, protocols, constants: `SessionPersona`, the `ClientEvent`/`ServerEvent` protocol, `Logger`, `ToolDefinition`). Free of SvelteKit-specific magic (no `$env/*`, no server-only imports) and no Node-only APIs, so it's safe for both browser and server code to import.
- **`./client`** — thin browser-side transports and wrappers (`RealtimeClient`, `ResilientRealtimeClient`, client logging, tool dispatch). No business logic; just transport.
- **`./server`** — server-only implementations (session orchestration, provider adapters, token handling, `handleRealtimeUpgrade`) plus the `createRealtimeRuntime` composition-root factory (see "Composition roots" below). Maps to a throwing stub for any consumer's browser bundle (the package's `browser` export condition) — see "Server-only safety" below.
- **`./vite`** — the dev-server integration (`realtimeDevWsPlugin`), kept separate from `./server` so importing the package root never pulls in Vite.
- **`packages/humanoid/`** (`@webavatar/webavatar-humanoid`) — an **optional package**: infrastructure only _some_ client projects need (one building a 2D avatar has no reason to even look at it). A renderer-agnostic avatar-state and animation-channel-composition contract for 3D humanoid avatars rigged with ARKit blend shapes. See [HUMANOID.md](HUMANOID.md). May depend on core's public contracts, but **only as types** (`import type`) — never a runtime value, never core's internal file layout — so its built JavaScript contains no reference to core at all; core is declared as a peer dependency purely so npm can report a version-compatibility error at install time. Entry points: **`.`** (state/animation contracts), **`./client`** (the browser wrapper deriving live state), **`./eye`** (the first animation library built as a consumer of the root contract — gaze animation), **`./eye/client`** (its browser wrapper). Future animation libraries (blink, lip-sync, body/gesture) are expected to nest the same way, as `./<name>` / `./<name>/client` sub-packages, rather than becoming further top-level packages.
- **`apps/template/`** — the SvelteKit application template client projects branch from. Depends on both packages; nothing in either package imports from it. Within it, a further split mirrors what used to be the whole app:
- `src/lib/project/`, `src/lib/server/project/`, `src/lib/client/project/` — code specific to whichever client project branches from the template (tool implementations, custom instructions, persona content). **Neither package imports from project code, only the reverse** — that one-way dependency is what let this code stay branch-friendly even before the package split, and still does. Where the app needs to merge something from a package with project content (e.g. core session defaults with a project's tool registry), that merge happens at a composition root (`server.js`, `vite.config.ts`) — never inside a package. See [TOOLS.md](TOOLS.md) for a concrete example of this split in practice.
- `src/lib/server/webavatar-core.ts` — the server-only wrapper (`export * from '@webavatar/webavatar-core/server'`) that SvelteKit routes and server modules import through instead of the package directly, so SvelteKit's own `$lib/server` build-time guard (which is path-based and doesn't reach into `node_modules`) still covers them. See "Server-only safety" below for the full picture.
- `src/routes/` — thin route glue; delegates into the packages and project code rather than holding logic itself.
- `src/routes/demo/` — verification scaffolding (the `/demo/realtime` and `/demo/humanoid` smoke-test surfaces referenced throughout these docs and in [AGENTS.md](../AGENTS.md)'s "Verifying changes"), not a client project's own UI, and a branched project has no reason to touch it. Rather than needing to be deleted per fork, it's gated to dev-only via `src/routes/demo/+layout.server.ts` (404s when `!dev`) — it stays available for `npm run dev` verification but never ships in a production build.
- Three.js, the bundled test avatar (`static/models/avatar.glb`, tracked via Git LFS), and all rig/morph-target binding stay entirely app-owned — neither package has any renderer knowledge.
This repo is a base template meant to be branched per client project, so there's a second, parallel set of directories for code that's specific to whichever project branches from it — `src/lib/project/`, `src/lib/server/project/`, `src/lib/client/project/`, mirroring the three-way split above. **Core never imports from project, only the reverse** — that one-way dependency is what lets a branched project pull template updates without merge conflicts in core files. Where a project needs something from both (e.g. merging core session defaults with a project's tool registry), that merge happens at a composition root — a repo-root file like `server.js` or `vite.config.ts`, not under `src/lib` and not bound by the core/project layering — never inside a core file. See [TOOLS.md](TOOLS.md) for a concrete example of this split in practice.
## Server-only safety
### Optional packages
Now that core's server code is a package rather than a path SvelteKit's own build-time guard can see, that protection is rebuilt in layers, all required together: (1) `packages/core`'s `browser` export condition maps `./server` and `./vite` to a stub that throws when evaluated, for every consumer, in any project, automatically; (2) `apps/template/src/lib/server/webavatar-core.ts`, so SvelteKit's own `$lib/server` guard covers all app code that goes through it; (3) an ESLint `@typescript-eslint/no-restricted-imports` rule forbidding `@webavatar/webavatar-core/server`/`/vite` everywhere in the app except `src/lib/server/**`, `server.js`, and `vite.config.ts`; (4) a build-time `serverOnlyGuard()` Vite plugin (`apps/template/server-only-guard.ts`) that fails a client-side import at build time rather than only at runtime in the browser.
A third kind of top-level directory, alongside `core` and `project`: a **package** — a named directory mirrored across only the scopes it actually needs (`src/lib/<name>/` for isomorphic code, `src/lib/server/<name>/`, `src/lib/client/<name>/`). Where `core` is infrastructure every branched project needs and `project` is that project's own content, a package is infrastructure only _some_ projects need — one a 2D avatar project, say, has no reason to even look at.
## Composition roots
Same dependency rule as core/project, extended: **a package may import from `core`; `core` and `project` never import from a package; a project opts in by importing directly from the package** — no registration step, no runtime flag, no manifest. If nothing imports it, it's never bundled — that's what makes it free for a project that doesn't use it.
First (and, as of this writing, only) package: **`humanoid`** (`src/lib/humanoid/`, `src/lib/client/humanoid/`) — a renderer-agnostic avatar-state and animation-channel-composition contract for 3D humanoid avatars rigged with ARKit blend shapes. See [HUMANOID.md](HUMANOID.md). Shared animation libraries built as consumers of that contract live as sub-packages nested inside it (`$lib/humanoid/<name>`, `$lib/client/humanoid/<name>`) rather than as further top-level packages — `eye` (gaze animation) is the first; blink, lip-sync, and body/gesture are expected to follow the same way, not to be folded into `core`.
Routes (`src/routes/`) stay thin and delegate into these — a route file is glue, not where logic lives.
`src/routes/demo/` is the one exception worth calling out: it's core-owned verification scaffolding (the `/demo/realtime` and `/demo/humanoid` smoke-test surfaces referenced throughout these docs and in [AGENTS.md](../AGENTS.md)'s "Verifying changes"), not a client project's own UI, and a branched project has no reason to touch it. Rather than needing to be deleted per fork, it's gated to dev-only via `src/routes/demo/+layout.server.ts` (404s when `!dev`) — it stays available for `npm run dev` verification but never ships in a production build.
`server.js` (production) and the `./vite` plugin (dev) both need the same wiring: load config, select the provider, choose the analytics recorder, resolve session defaults from the persona and tool registry, then hand everything to the WS upgrade handler. Both call `createRealtimeRuntime` (exported from `@webavatar/webavatar-core/server`) — a factory, not a service locator: it returns a fresh instance per call, keeps no module state of its own, and is called once per process by whichever composition root is running. This is what keeps `server.js` and the Vite plugin thin: a core change to how the relay is assembled ships inside a core package release, not as a shell edit every client project has to apply by hand.
## Foundational infrastructure decisions
- **This app runs as a persistent Node process, not serverless.** The adapter is `@sveltejs/adapter-node` — configured inline in [`vite.config.ts`](../vite.config.ts)'s `sveltekit({ adapter: ... })` option, since this project has no separate `svelte.config.js` — started via a custom [`server.js`](../server.js) rather than adapter-node's default generated entrypoint. This is a hard requirement, not a preference: the realtime session relay holds a WebSocket connection to a browser and a second WebSocket connection to the model provider simultaneously, for the lifetime of a conversation. No serverless/edge adapter can hold connections like that. Any client project branching from this base inherits that constraint — plan hosting accordingly.
- **Auth tokens are generic infrastructure, not tied to any one feature.** `src/lib/server/core/tokens.ts` mints/verifies short-lived HMAC-signed tokens as a reusable primitive, currently used to gate the realtime WebSocket handshake but intentionally not coupled to it.
- **This app runs as a persistent Node process, not serverless.** The adapter is `@sveltejs/adapter-node` — configured inline in [`apps/template/vite.config.ts`](../apps/template/vite.config.ts)'s `sveltekit({ adapter: ... })` option, since the template has no separate `svelte.config.js` — started via a custom [`server.js`](../apps/template/server.js) rather than adapter-node's default generated entrypoint. This is a hard requirement, not a preference: the realtime session relay holds a WebSocket connection to a browser and a second WebSocket connection to the model provider simultaneously, for the lifetime of a conversation. No serverless/edge adapter can hold connections like that. Any client project branching from this template inherits that constraint — plan hosting accordingly.
- **Auth tokens are generic infrastructure, not tied to any one feature.** `packages/core/src/server/tokens.ts` mints/verifies short-lived HMAC-signed tokens as a reusable primitive, currently used to gate the realtime WebSocket handshake but intentionally not coupled to it.
- **Artifact parity.** Vite externalizes an SSR dependency only if it resolves inside `node_modules` — a workspace package is a symlink whose real path is `packages/core/...`, so without `ssr.external` in `apps/template/vite.config.ts` (production builds only), the monorepo would bundle core's server code into the SvelteKit server build while a client project that installed the published package gets it externalized, running a different artifact than this template does. Not applied in `dev`, since externalizing there would load the package through Node's module cache and lose hot reload of package changes for SSR routes.
## Subsystems

14
docs/CONFIGURATION.md

@ -1,6 +1,6 @@
# Configuration
Every env var this app reads is parsed and validated in one place: [`src/lib/server/core/config.ts`](../src/lib/server/core/config.ts)'s `loadConfig()`. It reads `process.env` directly (not `$env/static/private`) for the same reason `tokens.ts` and `dev-plugin.ts` already do — this module tree is compiled standalone (`tsc -p tsconfig.server.json`) for `server.js` to import outside Vite's module graph. See [`.env.example`](../.env.example) for the full list with inline comments.
Every env var this app reads is parsed and validated in one place: [`packages/core/src/server/config.ts`](../packages/core/src/server/config.ts)'s `loadConfig()`. It reads `process.env` directly (not `$env/static/private`) for the same reason the rest of `packages/core/src/server/**` already does — this module tree is compiled standalone (`tsc -p tsconfig.build.json`) for `server.js` to import outside Vite's module graph. See [`.env.example`](../apps/template/.env.example) for the full list with inline comments.
## `AppConfig` shape
@ -41,26 +41,26 @@ Booleans accept exactly `"true"`/`"false"`; anything else (if the var is set at
## Fail-fast, in both dev and prod
`loadConfig()` collects every problem — missing required var, malformed boolean/integer, out-of-range integer — into a single thrown `Error` listing all of them, rather than failing on the first one found. It's called once, eagerly, at both composition roots (`server.js`'s top level; `dev-plugin.ts`'s `configureServer`, right after `.env` is loaded), and memoized after that (env vars don't change mid-process, and this gets called again from the session-token route to read `tokenTtlMs`).
`loadConfig()` collects every problem — missing required var, malformed boolean/integer, out-of-range integer — into a single thrown `Error` listing all of them, rather than failing on the first one found. It's called once, eagerly, at both composition roots (`server.js`'s top level, via `createRealtimeRuntime`; the `./vite` plugin's `configureServer`, via the same factory, right after `.env` is loaded), and memoized after that (env vars don't change mid-process, and this gets called again from the session-token route to read `tokenTtlMs`).
This means `npm run dev` now requires a fully-populated `.env` to start at all — previously, a missing `OPENAI_API_KEY` only produced a console warning in dev and the failure surfaced later, confusingly, inside a WS handshake attempt. Prod already threw eagerly; this just makes dev match it, consistent with this repo's existing "fail loud at boot" precedent (see `createToolRegistry`'s doc comment in `server/core/tools.ts`).
This means `npm run dev` now requires a fully-populated `.env` to start at all — previously, a missing `OPENAI_API_KEY` only produced a console warning in dev and the failure surfaced later, confusingly, inside a WS handshake attempt. Prod already threw eagerly; this just makes dev match it, consistent with this repo's existing "fail loud at boot" precedent (see `createToolRegistry`'s doc comment in `packages/core/src/server/tools.ts`).
`REALTIME_TOKEN_SECRET` is a deliberate partial exception: `loadConfig()` checks it's _present_ (so a missing secret is caught at the same startup point as everything else) but does not store its value on `AppConfig` — `tokens.ts` remains the sole reader of the actual secret, reading `process.env` directly and lazily, exactly as before. This is what keeps `tokens.spec.ts`'s existing pattern of mutating `process.env.REALTIME_TOKEN_SECRET` per test working unchanged.
`DEBUG_ACCESS_KEY` goes further still: it isn't even presence-checked here. Unlike every other var in the table above, leaving it unset is a valid, intentional deployment state — the debug panel's server-log forwarding is simply off for everyone in prod — not a misconfiguration worth failing loudly about. `src/lib/server/core/logger.ts`'s `verifyDebugAccessKey` is the sole reader, same lazy-`process.env` pattern as `REALTIME_TOKEN_SECRET`, but a deliberately separate secret — see [LOGGING.md](LOGGING.md).
`DEBUG_ACCESS_KEY` goes further still: it isn't even presence-checked here. Unlike every other var in the table above, leaving it unset is a valid, intentional deployment state — the debug panel's server-log forwarding is simply off for everyone in prod — not a misconfiguration worth failing loudly about. `packages/core/src/server/logger.ts`'s `verifyDebugAccessKey` is the sole reader, same lazy-`process.env` pattern as `REALTIME_TOKEN_SECRET`, but a deliberately separate secret — see [LOGGING.md](LOGGING.md).
`DEBUG_LOG_MESSAGES` is not a secret, so it doesn't follow either of those two exceptions — it's presence-checked and validated like any other boolean (`ANALYTICS_ENABLED`, etc.) and its value (`config.debug.messages`) flows through `AppConfig` normally. It gates a narrower thing than `DEBUG_ACCESS_KEY`: not whether the debug panel can see a session's logs at all, but whether the `messages` category (full conversation content) additionally gets mirrored to server console/stdout unconditionally. See [LOGGING.md](LOGGING.md).
## The `vite.config.ts` / Vitest gotcha
**Do not** move a throwing, env-validating call to `vite.config.ts`'s top level, and don't have it build an already-resolved session-defaults object either. Both Vitest projects in `vite.config.ts`'s `test.projects` use `extends: './vite.config.ts'`, so this file's top-level code — and any plugin `configureServer` hook — runs on every `npm run test:unit`, not just `npm run dev`. `dev-plugin.ts`'s `configureServer` already has to guard itself with `if (process.env.VITEST) return` for exactly this reason.
**Do not** move a throwing, env-validating call to `vite.config.ts`'s top level, and don't have it build an already-resolved session-defaults object either. Both Vitest projects in `vite.config.ts`'s `test.projects` use `extends: './vite.config.ts'`, so this file's top-level code — and any plugin `configureServer` hook — runs on every `npm run test:unit`, not just `npm run dev`. The `./vite` plugin's `configureServer` already has to guard itself with `if (process.env.VITEST) return` for exactly this reason.
Concretely: `vite.config.ts` passes only raw, env-independent pieces into the dev plugin — `realtimeDevWsPlugin({ persona: SESSION_PERSONA, toolRegistry })` — never a pre-resolved `defaults`/`config` object. The `loadConfig()` call, `resolveSessionDefaults(...)`, and the `NoopAnalyticsRecorder`/`SqliteAnalyticsRecorder` choice all happen _inside_ `configureServer`, below the `VITEST` guard. If a future change needs another env-driven value in dev, thread it through the same way — resolve it inside `configureServer`, not at `vite.config.ts` module scope — or `npm run test:unit` will start requiring a populated `.env` for no reason, and may pick up a developer's real local secrets into `process.env` during test runs.
## Session persona vs. runtime overrides
The actual persona — model choice and system instructions — is project-owned content in [`src/lib/project/persona.ts`](../src/lib/project/persona.ts), not part of this config system (see [`docs/TOOLS.md`](TOOLS.md) for why core/project content is split this way). `config.ts`'s `session.modelOverride` (from `SESSION_MODEL`) lets a deployment swap the model without touching that file; `instructions` deliberately has **no** env override — it's prose, and belongs in a commented TypeScript file, not a `.env` value.
The actual persona — model choice and system instructions — is project-owned content in [`apps/template/src/lib/project/persona.ts`](../apps/template/src/lib/project/persona.ts), not part of this config system (see [`docs/TOOLS.md`](TOOLS.md) for why core/project content is split this way). `config.ts`'s `session.modelOverride` (from `SESSION_MODEL`) lets a deployment swap the model without touching that file; `instructions` deliberately has **no** env override — it's prose, and belongs in a commented TypeScript file, not a `.env` value.
## What's not configurable (on purpose)
`src/lib/server/core/providers/openai.ts`'s hardcoded upstream URL, its always-`['audio']` `output_modalities`, and its `tool_choice: 'auto'` are deliberately out of scope here — these are provider-tuning knobs nobody asked to change yet, and adding config surface for them now would be speculative.
`packages/core/src/server/providers/openai.ts`'s hardcoded upstream URL, its always-`['audio']` `output_modalities`, and its `tool_choice: 'auto'` are deliberately out of scope here — these are provider-tuning knobs nobody asked to change yet, and adding config surface for them now would be speculative.

30
docs/HUMANOID.md

@ -1,10 +1,10 @@
# Humanoid package
`humanoid` is the first instance of the **optional package** convention (see "Module layering" in [ARCHITECTURE.md](ARCHITECTURE.md)) — a renderer-agnostic contract layer for 3D humanoid avatars rigged with ARKit blend shapes, scoped out of `core` because a 2D or non-humanoid project has no use for it. Nothing in `core` or `project` imports from it; a project opts in by importing directly from `$lib/humanoid` / `$lib/client/humanoid`, and if nothing imports it, it's simply not part of that project's bundle.
`humanoid` — published as `@webavatar/webavatar-humanoid` (source in `packages/humanoid/src/`) — is the first instance of the **optional package** convention (see "Module layering" in [ARCHITECTURE.md](ARCHITECTURE.md)) — a renderer-agnostic contract layer for 3D humanoid avatars rigged with ARKit blend shapes, scoped out of `core` because a 2D or non-humanoid project has no use for it. Nothing in `core` or the app depends on it by default; a project opts in by installing the package and importing directly from `@webavatar/webavatar-humanoid` / `@webavatar/webavatar-humanoid/client`, and if a project doesn't import it, it's simply not part of that project's bundle.
This package ships the **contract** animation libraries plug into — `$lib/humanoid/eye` (below) is the first one actually built, with blink/lip-sync/body-gesture still to come — not a renderer. It has no idea what's rendering the avatar (Three.js, Babylon, a game engine WebGL bridge, anything else) — that stays entirely project-owned, per the base project's founding constraint that appearance/UI is never core's job. `src/routes/demo/humanoid` is where a project-owned renderer (Three.js, in the bundled demo) actually exists — see "`/demo/humanoid`" below.
This package ships the **contract** animation libraries plug into — `@webavatar/webavatar-humanoid/eye` (below) is the first one actually built, with blink/lip-sync/body-gesture still to come — not a renderer. It has no idea what's rendering the avatar (Three.js, Babylon, a game engine WebGL bridge, anything else) — that stays entirely project-owned, per the base project's founding constraint that appearance/UI is never core's job. `apps/template/src/routes/demo/humanoid` is where a project-owned renderer (Three.js, in the bundled demo) actually exists — see "`/demo/humanoid`" below.
## `$lib/humanoid/state.ts` — avatar state
## `@webavatar/webavatar-humanoid` — avatar state (`state.ts`)
```ts
type AvatarState = 'idleActive' | 'idlePassive' | 'listening' | 'thinking' | 'talking';
@ -16,7 +16,7 @@ type AvatarState = 'idleActive' | 'idlePassive' | 'listening' | 'thinking' | 'ta
Note `listening_stopped` moves to `thinking`, not back to idle — ending listening almost always means a turn just completed and a response is expected next, so the avatar shouldn't visibly relax for the split second before the model's response starts arriving. `talking_stopped` and `reset` both land on `idleActive`, never straight to `idlePassive` — passive idle is only ever reached via `idle_timeout`.
## `$lib/humanoid/config.ts` — shared tuning knobs
## `@webavatar/webavatar-humanoid` — shared tuning knobs (`config.ts`)
```ts
interface AnimationConfig {
@ -26,11 +26,11 @@ interface AnimationConfig {
Every value this package's own state derivation exposes for tuning lives in this one `AnimationConfig` interface, not as a bespoke option on whichever function first needed it — `idlePassiveTimeoutMs` (consumed by `deriveAvatarState`, below) is the first entry, and it's where the next one goes too as more of the Unreal Engine avatar app's animation systems get ported over (further idle-escalation thresholds, turn-taking heuristics, etc.).
`resolveAnimationConfig(overrides?)` merges a project's partial overrides onto `DEFAULT_ANIMATION_CONFIG` — a project only names the knobs it wants to change. Every `$lib/humanoid`/`$lib/client/humanoid` function that reads a tunable value takes `Partial<AnimationConfig>` (default `{}`) and resolves it internally, so passing the same override object to several such functions keeps them in sync without each needing the whole config spelled out.
`resolveAnimationConfig(overrides?)` merges a project's partial overrides onto `DEFAULT_ANIMATION_CONFIG` — a project only names the knobs it wants to change. Every `@webavatar/webavatar-humanoid`/`@webavatar/webavatar-humanoid/client` function that reads a tunable value takes `Partial<AnimationConfig>` (default `{}`) and resolves it internally, so passing the same override object to several such functions keeps them in sync without each needing the whole config spelled out.
This interface is scoped to `humanoid`'s own contract layer. An animation library built as a _consumer_ of this package (eye look-at, blink, lip-sync, body/gesture) is a sub-package under `$lib/humanoid/<name>` / `$lib/client/humanoid/<name>` — `eye` (below) is the first — and gets its own config surface rather than growing this interface: follow this same three-piece shape there (`XConfig` interface, `DEFAULT_X_CONFIG`, `resolveXConfig`). This mirrors the base project's existing convention of config living beside the subsystem it configures rather than in one shared blob (see [CONFIGURATION.md](CONFIGURATION.md)'s note on `AnalyticsConfig`/`SessionDefaults`).
This interface is scoped to `humanoid`'s own contract layer. An animation library built as a _consumer_ of this package (eye look-at, blink, lip-sync, body/gesture) nests inside the package at `packages/humanoid/src/<name>/` (exported as `@webavatar/webavatar-humanoid/<name>`, with a browser wrapper at `<name>/client` when it needs one) rather than becoming a separate top-level package — `eye` (below) is the first — and gets its own config surface rather than growing this interface: follow this same three-piece shape there (`XConfig` interface, `DEFAULT_X_CONFIG`, `resolveXConfig`). This mirrors the base project's existing convention of config living beside the subsystem it configures rather than in one shared blob (see [CONFIGURATION.md](CONFIGURATION.md)'s note on `AnalyticsConfig`/`SessionDefaults`).
## `$lib/client/humanoid/avatar-state.ts` — deriving it live
## `@webavatar/webavatar-humanoid/client` — deriving it live (`client/avatar-state.ts`)
`deriveAvatarState(source, animationConfig?)` takes a `RealtimeClient` or `ResilientRealtimeClient` (accepted structurally, via an `AvatarStateSource` interface — this package doesn't care which transport variant is in use) and folds its `ServerEvent`s through `nextAvatarState`:
@ -44,19 +44,19 @@ This interface is scoped to `humanoid`'s own contract layer. An animation librar
Two signals can't come from the event stream alone, so the returned store exposes them imperatively:
- **`setListening(active)`** — call from whatever mic-capture code a project uses. That pipeline doesn't exist in `core` yet (see "Known limitations" below), and the client is the only thing that currently knows when the user starts/stops talking: the protocol doesn't surface OpenAI's `input_audio_buffer.speech_*` VAD events — see the "not part of our normalized vocabulary" comment in `server/core/providers/openai.ts`'s `fromOpenAiEvent`.
- **`setListening(active)`** — call from whatever mic-capture code a project uses. That pipeline doesn't exist in `core` yet (see "Known limitations" below), and the client is the only thing that currently knows when the user starts/stops talking: the protocol doesn't surface OpenAI's `input_audio_buffer.speech_*` VAD events — see the "not part of our normalized vocabulary" comment in `packages/core/src/server/providers/openai.ts`'s `fromOpenAiEvent`.
- **`notifyResponseRequested()`** — call right when sending a typed `input_text.send` (the voice path already gets this for free from `setListening(false)`).
This wrapper also owns the `idleActive` → `idlePassive` clock: the store starts (and every `reset`/`talking_stopped` returns to) `idleActive`, and a timer — `animationConfig.idlePassiveTimeoutMs`, see `config.ts` above — fires `idle_timeout` if nothing else happens first. Any other signal re-arms it on the next `idleActive` entry; `destroy()` cancels it.
The returned `AvatarStateStore.subscribe()` follows the Svelte store contract (calls `run` immediately with the current value, then on every change), so it's usable directly as `$store` in a `.svelte` file without a hard Svelte dependency in this package itself. See `/demo/humanoid` (`src/routes/demo/humanoid/+page.svelte`) for the reference wiring, including the manual "listening" toggle standing in for real mic-capture UI.
The returned `AvatarStateStore.subscribe()` follows the Svelte store contract (calls `run` immediately with the current value, then on every change), so it's usable directly as `$store` in a `.svelte` file without a hard Svelte dependency in this package itself. See `/demo/humanoid` (`apps/template/src/routes/demo/humanoid/+page.svelte`) for the reference wiring, including the manual "listening" toggle standing in for real mic-capture UI.
### Known limitations
- **`thinking` is a heuristic**, not a precise reflection of server-side activity — the time between a request and the assistant's first output. The browser never sees server-executed tool calls at all (see [TOOLS.md](TOOLS.md)), so "the model is running a tool" isn't observable from here.
- **The idle split is timer-based, not presence-based** — `idlePassive` is reached purely by elapsed time with no other signal, not by knowing whether the user actually left. A project with webcam/face-tracking data can layer a real presence signal on top (e.g. calling back into `idleActive` early, or suppressing the timeout) — that stays project-owned.
## `$lib/humanoid/animation.ts` — animation-channel composition
## `@webavatar/webavatar-humanoid` — animation-channel composition (`animation.ts`)
```ts
type ARKitBlendShape = 'jawOpen' | 'eyeBlinkLeft' | /* ...all 52 ARFaceAnchor.BlendShapeLocation names */;
@ -67,9 +67,9 @@ type AnimationFrame = Partial<Record<ARKitBlendShape, number>>; // sparse, 0–1
This is pure, isomorphic, and has no idea how the resulting `AnimationFrame` gets applied to a mesh — that binding (which bone/morph target each `ARKitBlendShape` name maps to in a given rig) is entirely the project's job.
## `$lib/humanoid/eye` — gaze animation
## `@webavatar/webavatar-humanoid/eye` — gaze animation
The first animation library built as a consumer of this package's contract, ported from an Unreal Engine avatar app's eye-gaze design doc. Three independent, continuously-ticking, additive 2D cm-offset layers — Macro (periodic look-aways, sometimes chaining "secondary" glances nearby before returning to center), Micro (small saccades simulating scanning a conversation partner's eyes/eyebrows), Nano (tiny constant jitter) — summed into one `combinedOffsetCm` on a plane in front of the avatar's face. Each layer's `current` value exponentially decays toward a randomly-redrawn `goal` (the doc's "SpringInterpolate"). Macro's look-at duration is keyed by the live `AvatarState` (`$lib/humanoid/state.ts`) — this is the concrete use case `AnimationConfig`'s doc comment anticipated when it said future ported systems would key off exactly that distinction. See `src/lib/humanoid/eye/DESIGN_NOTES.md` for what's a faithful port of the source doc's variables/logic versus a documented approximation (the three easing curves, reconstructed from screenshots of hand-authored Unreal splines with no exact keyframe data) versus a wholly new web-port adaptation (turning a cm-offset into ARKit `eyeLook*` blend-shape weights — the original drove real eye bones, not blend shapes).
The first animation library built as a consumer of this package's contract, ported from an Unreal Engine avatar app's eye-gaze design doc. Three independent, continuously-ticking, additive 2D cm-offset layers — Macro (periodic look-aways, sometimes chaining "secondary" glances nearby before returning to center), Micro (small saccades simulating scanning a conversation partner's eyes/eyebrows), Nano (tiny constant jitter) — summed into one `combinedOffsetCm` on a plane in front of the avatar's face. Each layer's `current` value exponentially decays toward a randomly-redrawn `goal` (the doc's "SpringInterpolate"). Macro's look-at duration is keyed by the live `AvatarState` (`@webavatar/webavatar-humanoid`'s `state.ts`) — this is the concrete use case `AnimationConfig`'s doc comment anticipated when it said future ported systems would key off exactly that distinction. See `packages/humanoid/src/eye/DESIGN_NOTES.md` for what's a faithful port of the source doc's variables/logic versus a documented approximation (the three easing curves, reconstructed from screenshots of hand-authored Unreal splines with no exact keyframe data) versus a wholly new web-port adaptation (turning a cm-offset into ARKit `eyeLook*` blend-shape weights — the original drove real eye bones, not blend shapes).
```ts
function stepEyeGaze(
@ -89,16 +89,16 @@ function gazeOffsetToBlendShapes(
`stepEyeGaze` is pure and directly unit-tested with a scripted `rng` (`gaze.spec.ts`) — every random draw anywhere in the module takes `rng` as a parameter, never calling `Math.random()` itself, the same "push side effects to the edge" shape as `nextAvatarState`. `gazeOffsetToBlendShapes` (`blend-shapes.ts`) is the separate, independently-tested conversion from `EyeGazeState.combinedOffsetCm` to the 8 ARKit `eyeLook*` channels — see its docstring for the left/right eye sign convention, the one place in this whole port that's easy to get backwards.
`$lib/client/humanoid/eye/gaze-controller.ts`'s `createEyeGazeController(overrides?)` is the client wrapper — owns the per-frame clock (`requestAnimationFrame`) and `Math.random`, mirroring `deriveAvatarState`'s shape (a Svelte-store-compatible `subscribe`, plus imperative setters: `setAvatarState`, `setLookAtDistanceCm`, `setLayerEnabled`, `setTimeScale`, `forceLookAway`, `destroy`). See `/demo/humanoid`'s "Eye system demo" section (`src/routes/demo/humanoid/EyeSystemDemo.svelte`) for the reference wiring against a live Three.js-rendered avatar, including per-layer toggles, a forced-look-away button, and raw macro/micro/nano/combined offset and blend-shape readouts for debugging.
`@webavatar/webavatar-humanoid/eye/client`'s `createEyeGazeController(overrides?)` — the module lives at `packages/humanoid/src/eye/client.ts` (renamed from `gaze-controller.ts` during the monorepo migration to match its export path) — is the client wrapper: owns the per-frame clock (`requestAnimationFrame`) and `Math.random`, mirroring `deriveAvatarState`'s shape (a Svelte-store-compatible `subscribe`, plus imperative setters: `setAvatarState`, `setLookAtDistanceCm`, `setLayerEnabled`, `setTimeScale`, `forceLookAway`, `destroy`). See `/demo/humanoid`'s "Eye system demo" section (`apps/template/src/routes/demo/humanoid/EyeSystemDemo.svelte`) for the reference wiring against a live Three.js-rendered avatar, including per-layer toggles, a forced-look-away button, and raw macro/micro/nano/combined offset and blend-shape readouts for debugging.
## `/demo/humanoid` — playground
`src/routes/demo/humanoid/+page.svelte` is a testing/debugging surface for this package's animation libraries generally, not just a demo of one — it composes `AvatarStateDemo.svelte` (the state-derivation demo above) and `EyeSystemDemo.svelte` side by side, structured so a future library's demo (blink, lip-sync, body/gesture) is another sibling component, not a rewrite. `EyeSystemDemo.svelte`'s `eye-demo/` subfolder holds its Three.js viewer and debug UI — `ThreeAvatarViewer.svelte` is the only file in this whole feature allowed to import `three` or touch mesh/bone data directly; everything under `$lib/humanoid/**` and `$lib/client/humanoid/**` stays renderer-agnostic. The bundled test avatar (`static/models/avatar.glb`, a Ready Player Me-style rig) carries the same 52 ARKit morph targets this package's `ARKitBlendShape` type enumerates, on 4 of its meshes.
`apps/template/src/routes/demo/humanoid/+page.svelte` is a testing/debugging surface for this package's animation libraries generally, not just a demo of one — it composes `AvatarStateDemo.svelte` (the state-derivation demo above) and `EyeSystemDemo.svelte` side by side, structured so a future library's demo (blink, lip-sync, body/gesture) is another sibling component, not a rewrite. `EyeSystemDemo.svelte`'s `eye-demo/` subfolder holds its Three.js viewer and debug UI — `ThreeAvatarViewer.svelte` is the only file in this whole feature allowed to import `three` or touch mesh/bone data directly; everything in `@webavatar/webavatar-humanoid` (`packages/humanoid/src/**`, both its root contract and its `client`/`eye` entry points) stays renderer-agnostic. The bundled test avatar (`apps/template/static/models/avatar.glb`, a Ready Player Me-style rig) carries the same 52 ARKit morph targets this package's `ARKitBlendShape` type enumerates, on 4 of its meshes.
## What's not built here (on purpose)
- The blink, lip-sync, and body/gesture libraries — future work, built as further consumers of this contract the same way `eye` was, not part of it.
- Mic-capture/audio-playback in the browser (still open — see `docs/REALTIME_SESSION.md`'s "What's deliberately not built yet").
- Server-side viseme/phoneme generation — an open design fork (server-side, since the relay already touches raw audio, vs. client-side from the playback buffer) to decide when the lip-sync library actually gets built.
- Any protocol change (`$lib/core/protocol.ts` is untouched by this package) — `AvatarState` derivation works entirely from events the protocol already carries.
- Any protocol change (core's `protocol.ts` — `packages/core/src/protocol.ts` — is untouched by this package) — `AvatarState` derivation works entirely from events the protocol already carries.
- The LookAt system (whole-body/head aim toward a target) the source eye-gaze doc describes as `eye`'s complement — `eye`'s `lookAtDistanceCm` input assumes something else eventually owns that; `EyeSystemDemo.svelte` exposes it as a manual slider standing in for that not-yet-built system, the same way the avatar-state demo's "listening" toggle stands in for real mic-capture.

18
docs/LOGGING.md

@ -6,11 +6,11 @@ An ephemeral, in-memory debug-logging system: a shared `Logger` contract other c
## The contract
`src/lib/core/logger.ts` defines `LogLevel` (`debug | info | warn | error`), `LogEntry { level, category, message, data?, timestamp }`, and the `Logger` interface (`debug/info/warn/error(category, message, data?)`). `category` is an intentionally open string, never a closed enum — a package like `humanoid`'s `eye` sub-package owns its own namespace (e.g. `"humanoid.eye"`) without core needing to know about it ahead of time, the same "no registration step" rule as the package system itself (see [ARCHITECTURE.md](ARCHITECTURE.md)'s "Optional packages").
`packages/core/src/logger.ts` (exported from the package root `'.'`) defines `LogLevel` (`debug | info | warn | error`), `LogEntry { level, category, message, data?, timestamp }`, and the `Logger` interface (`debug/info/warn/error(category, message, data?)`). `category` is an intentionally open string, never a closed enum — a package like `humanoid`'s `eye` sub-package owns its own namespace (e.g. `"humanoid.eye"`) without core needing to know about it ahead of time, the same "no registration step" rule as the package system itself (see [ARCHITECTURE.md](ARCHITECTURE.md)'s "Optional packages").
## Server side
`src/lib/server/core/logger.ts`'s `createLogger(onEntry?, options?)` builds a `Logger` that mirrors every call to the matching `console.*` method (visible in plain stdout/container logs with zero setup) and, if given, also invokes `onEntry` synchronously. `options.logMessages` (default `true`) controls only whether the `messages` category participates in that console mirror — see below.
`packages/core/src/server/logger.ts`'s `createLogger(onEntry?, options?)` (exported from `'./server'`) builds a `Logger` that mirrors every call to the matching `console.*` method (visible in plain stdout/container logs with zero setup) and, if given, also invokes `onEntry` synchronously. `options.logMessages` (default `true`) controls only whether the `messages` category participates in that console mirror — see below.
`RealtimeSession` (`session.ts`) — the one object every session event in both directions already funnels through (see REALTIME_SESSION.md's connection flow) — owns one such logger per connection, wired so `onEntry` forwards to its own `browserSocket` as a `debug.log` `ServerEvent`, gated by a `debugSubscribed` flag. Because each `RealtimeSession` instance owns exactly one browser connection, this scoping is structural, not filtered: there's no session-lookup or broadcast step, so one session's logs can't reach another session's browser by construction (see `session.spec.ts`'s two-concurrent-sessions test).
@ -20,7 +20,7 @@ Logger calls are added at existing hook points — the same "attach where the pl
Because `createLogger`'s console mirror otherwise runs for every category unconditionally (see above), full conversation text — including the system prompt — would always reach server stdout/container logs, every session, regardless of whether any browser is subscribed to `debug.log` at all. That's a different sensitivity tier than the rest of this system's operational logging, closer to what `ANALYTICS_LOG_MESSAGES` gates independently in `analytics.ts`. `DEBUG_LOG_MESSAGES` (`config.debug.messages`, default `true`) is the equivalent toggle here: set it to `false` and `createLogger` skips the console mirror specifically for the `messages` category, while every other category is unaffected. This only controls the console/stdout copy — `onEntry` (browser forwarding to a subscribed debug panel) still always fires either way, since that path is already independently gated by `debugSubscribed` (dev/`DEBUG_ACCESS_KEY`, see below). Worth setting to `false` in any deployment where server logs are shipped somewhere less trusted than the process's own stdout.
**What's deliberately deferred**: provider adapters (`provider.ts`, `providers/openai.ts`) are session-agnostic — they have no `sessionId` at all — so they don't get logger calls in this version. The one concrete case this leaves uninstrumented, `providers/openai.ts`'s silently-dropped unmapped OpenAI events, is exactly the case REALTIME_SESSION.md's "OpenAI Realtime API gotcha" section already documents debugging by hand (a temporary `console.debug` + dev-server restart). Threading a `Logger` through the provider interface (plus the mock provider and their tests) is a reasonable fast-follow, not done here to keep this change reviewable.
**What's deliberately deferred**: provider adapters (`packages/core/src/server/provider.ts`, `packages/core/src/server/providers/openai.ts`) are session-agnostic — they have no `sessionId` at all — so they don't get logger calls in this version. The one concrete case this leaves uninstrumented, `providers/openai.ts`'s silently-dropped unmapped OpenAI events, is exactly the case REALTIME_SESSION.md's "OpenAI Realtime API gotcha" section already documents debugging by hand (a temporary `console.debug` + dev-server restart). Threading a `Logger` through the provider interface (plus the mock provider and their tests) is a reasonable fast-follow, not done here to keep this change reviewable.
## Auth: `debug.subscribe` and `DEBUG_ACCESS_KEY`
@ -31,8 +31,8 @@ Server-log forwarding rides the _same_ realtime WebSocket connection as everythi
This deliberately does **not** reuse `REALTIME_TOKEN_SECRET`/the session token: that token proves "you got this from our server a moment ago," which every ordinary visitor to a public deployment gets automatically just by loading the page — it's not a developer credential. Server logs need a second, independent gate:
- **Dev** (`isDev: true`, passed as a literal from `dev-plugin.ts` — that file only ever runs in dev): every connection starts already subscribed. No `debug.subscribe` needed at all.
- **Prod** (`isDev: false`, passed as a literal from `server.js` — that file only ever runs in prod): a connection starts unsubscribed. The browser must send `debug.subscribe { key }` with a key matching `DEBUG_ACCESS_KEY`, verified by `verifyDebugAccessKey()` (timing-safe comparison, reusing `tokens.ts`'s `timingSafeCompare` helper). `DEBUG_ACCESS_KEY` is read directly from `process.env` at comparison time, same lazy pattern as `REALTIME_TOKEN_SECRET`, but a deliberately separate secret with its own blast radius — see [CONFIGURATION.md](CONFIGURATION.md). **Unset in prod means the feature is fully off for everyone** — not presence-checked in `loadConfig()`'s fail-fast validation, since that's an intentional, valid state, not a misconfiguration.
- **Dev** (`isDev: true`, passed as a literal from the `./vite` dev plugin — one of the two composition roots that call `createRealtimeRuntime`, see [ARCHITECTURE.md](ARCHITECTURE.md)): every connection starts already subscribed. No `debug.subscribe` needed at all.
- **Prod** (`isDev: false`, passed as a literal from `apps/template/server.js` — the other composition root, into the same shared `createRealtimeRuntime` factory, `packages/core/src/server/index.ts`): a connection starts unsubscribed. The browser must send `debug.subscribe { key }` with a key matching `DEBUG_ACCESS_KEY`, verified by `verifyDebugAccessKey()` (timing-safe comparison, reusing `tokens.ts`'s `timingSafeCompare` helper). `DEBUG_ACCESS_KEY` is read directly from `process.env` at comparison time, same lazy pattern as `REALTIME_TOKEN_SECRET`, but a deliberately separate secret with its own blast radius — see [CONFIGURATION.md](CONFIGURATION.md). **Unset in prod means the feature is fully off for everyone** — not presence-checked in `loadConfig()`'s fail-fast validation, since that's an intentional, valid state, not a misconfiguration.
`debug.subscribe` is handled unconditionally in `handleBrowserMessage` — alongside `session.start`/`session.end`, before the "must be active" gate — so a browser can subscribe before, during, or independent of an active session, and the message is never mistakenly forwarded to the upstream provider.
@ -40,16 +40,16 @@ This deliberately does **not** reuse `REALTIME_TOKEN_SECRET`/the session token:
There's no ambient/shared realtime client anywhere in this codebase — each page (e.g. `/demo/realtime`) constructs its own `ResilientRealtimeClient` ad hoc. So the client side splits into two independent pieces:
- **`src/lib/client/core/logger.ts`** — an _ambient_ client-side logger. `clientLogger` (any browser code, with or without a realtime connection, can call `clientLogger.warn('ws', ...)`) and `debugLogStore` (a hand-rolled, bounded (~500-entry) ring buffer with a Svelte-store-compatible `subscribe`, the same idiom as `$lib/client/humanoid/avatar-state.ts`'s `deriveAvatarState` — no `svelte/store` import anywhere in this repo, and this doesn't start one) always exist, regardless of any client.
- **`src/lib/client/core/server-log-forwarder.ts`** — _opt-in_ per page. `attachServerLogForwarding(client, { isDev })` takes a structurally-typed client (same idiom as `tool-dispatcher.ts`'s `ToolDispatchClient`) and returns a `ServerLogForwarder`: it listens for `debug.log` (pushing into the same shared buffer, tagged `source: 'server'`) and `debug.subscribe.result`, and exposes `subscribeWithKey(key)` for the prod flow.
- **`packages/core/src/client/logger.ts`** (exported from `'./client'`) — an _ambient_ client-side logger. `clientLogger` (any browser code, with or without a realtime connection, can call `clientLogger.warn('ws', ...)`) and `debugLogStore` (a hand-rolled, bounded (~500-entry) ring buffer with a Svelte-store-compatible `subscribe`, the same idiom as `@webavatar/webavatar-humanoid/client`'s `avatar-state.ts` (`deriveAvatarState`) — no `svelte/store` import anywhere in this repo, and this doesn't start one) always exist, regardless of any client.
- **`packages/core/src/client/server-log-forwarder.ts`** (exported from `'./client'`) — _opt-in_ per page. `attachServerLogForwarding(client, { isDev })` takes a structurally-typed client (same idiom as `tool-dispatcher.ts`'s `ToolDispatchClient`) and returns a `ServerLogForwarder`: it listens for `debug.log` (pushing into the same shared buffer, tagged `source: 'server'`) and `debug.subscribe.result`, and exposes `subscribeWithKey(key)` for the prod flow.
**Reconnects**: `ResilientRealtimeClient` reconnecting creates a **brand-new** server-side `RealtimeSession` (fresh `sessionId`, fresh unsubscribed state — see REALTIME_SESSION.md's "Resilience"). `attachServerLogForwarding` remembers the last key that worked and resends it on every transition to `'open'`, not just the first — otherwise forwarding would silently and permanently stop after any dropped connection.
`DebugPanel.svelte` (`src/lib/client/core`, **not** `/demo` — see below) renders `debugLogStore` unconditionally (so the ambient client-side buffer is always visible) and, given an optional `forwarder` prop, a subscription-status badge plus a key-entry field when in prod and not yet subscribed.
`DebugPanel.svelte` (`apps/template/src/lib/client/`, app-owned — importing `@webavatar/webavatar-core/client` for the logger/store pieces it needs, **not** `/demo` — see below) renders `debugLogStore` unconditionally (so the ambient client-side buffer is always visible) and, given an optional `forwarder` prop, a subscription-status badge plus a key-entry field when in prod and not yet subscribed.
## Where the panel lives, and why it's shippable in prod
Unlike `/demo/**`, which [ARCHITECTURE.md](ARCHITECTURE.md) frames as dev-only-gated verification scaffolding (404s via `+layout.server.ts` when `!dev`), `DebugPanel.svelte` lives in `src/lib/client/core/` — the same production-shippable tier as `client.ts`/`resilient-client.ts`. The key-based auth above, not route removal, is what protects server-log visibility in prod; the panel's client-side-only view has nothing sensitive to gate at all. `/demo/realtime/+page.svelte` is simply one caller of it (wired to its own client, the same way it's already a caller of `ResilientRealtimeClient`); a branched project mounts `<DebugPanel forwarder={...} />` the same way, wherever _it_ has a client.
Unlike `/demo/**`, which [ARCHITECTURE.md](ARCHITECTURE.md) frames as dev-only-gated verification scaffolding (404s via `+layout.server.ts` when `!dev`), `DebugPanel.svelte` lives in `apps/template/src/lib/client/` — app-owned code that consumes `@webavatar/webavatar-core/client` for `clientLogger`/`debugLogStore`, shipped in the app's production bundle the same as the rest of `apps/template/src/lib/client/`. The key-based auth above, not route removal, is what protects server-log visibility in prod; the panel's client-side-only view has nothing sensitive to gate at all. `apps/template/src/routes/demo/realtime/+page.svelte` is simply one caller of it (wired to its own client, the same way it's already a caller of `ResilientRealtimeClient`); a branched project mounts `<DebugPanel forwarder={...} />` the same way, wherever _it_ has a client.
## What's deliberately not built yet

92
docs/MONOREPO_MIGRATION_BRIEF.md

@ -1,8 +1,14 @@
# Monorepo migration implementation brief
> **Status:** Proposed target architecture, not yet implemented. This document is a handoff for a
> fresh implementation session. Until that migration is complete, `docs/ARCHITECTURE.md` describes
> the repository's actual structure.
> **Status:** Implemented, except the parts that genuinely require a Gitea instance and a private
> organization to exist — neither is provisioned yet. `@webavatar` was resolved to `@webavatar`;
> `<gitea-host>` and `<owner>` remain literal placeholders below. Concretely still blocked:
> actual `npm publish` and the tag-driven `publish` workflow (verification items 15–16), the
> unauthenticated-metadata private-visibility check, and the fresh-clone-over-a-real-remote half of
> the Git LFS check (item 14 — LFS tracking itself is verified; only the second-clone part needs a
> real remote). Everything else in "Verification requirements" below has passed. `docs/ARCHITECTURE.md`
> describes the implemented structure directly; this document is kept as the historical rationale and
> as the runbook for finishing the registry-dependent steps once Gitea is provisioned.
## Decisions required before handoff
@ -11,10 +17,10 @@ them. The clone this brief was written against has **no git remote configured**,
be read from the repository or organization configuration either.
| Placeholder | Meaning | Value |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------- | -------- |
| `@<scope>` | npm scope of both packages | _decide_ |
| `<gitea-host>` | Hostname of the Gitea instance hosting the source repository and the package registry | _decide_ |
| `<owner>` | Gitea user or organization the packages publish under. Must be a **private organization** (see "Private publication" below). | _decide_ |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
| `@webavatar` | npm scope of both packages | **`@webavatar`** — resolved, used literally everywhere below |
| `<gitea-host>` | Hostname of the Gitea instance hosting the source repository and the package registry | _decide_ — no instance provisioned yet |
| `<owner>` | Gitea user or organization the packages publish under. Must be a **private organization** (see "Private publication" below). | _decide_ — no private org exists yet |
Two related values are fixed by this brief and need no decision:
@ -35,8 +41,8 @@ statement of what remains blocked. It must not guess.
Convert `webavatar-core` from a SvelteKit template whose reusable modules live inside the app into an
npm-workspaces monorepo with independently versioned, privately distributed packages:
- `@<scope>/webavatar-core` — reusable shared, browser, server, and Vite integration.
- `@<scope>/webavatar-humanoid` — optional humanoid state and animation functionality.
- `@webavatar/webavatar-core` — reusable shared, browser, server, and Vite integration.
- `@webavatar/webavatar-humanoid` — optional humanoid state and animation functionality.
- `apps/template` — the SvelteKit application template, demo surfaces, project-owned configuration,
and composition roots.
@ -92,8 +98,8 @@ can be changed and tested together.
node), lint, and build commands. If the baseline is not green, distinguish pre-existing failures
from migration regressions. (When this revision was written: prettier and eslint clean,
`svelte-check` zero errors, the node Vitest project passing.)
4. Confirm the "Decisions required before handoff" table is filled in, and replace every `@<scope>`,
`<gitea-host>`, and `<owner>` placeholder below with those values. The npm `@<scope>` string and
4. Confirm the "Decisions required before handoff" table is filled in, and replace every `@webavatar`,
`<gitea-host>`, and `<owner>` placeholder below with those values. The npm `@webavatar` string and
the Gitea `<owner>` path segment are independent and do not have to match, but keeping them
identical avoids confusion.
5. Add the git remote for the Gitea repository. The LFS stage below rewrites history and must finish
@ -181,11 +187,11 @@ boundaries become real `package.json` and `exports` boundaries, not merely direc
The allowed graph is:
```text
apps/template ──────────────▶ @<scope>/webavatar-core
apps/template ──────────────▶ @webavatar/webavatar-core
│ ▲
└────────▶ @<scope>/webavatar-humanoid
└────────▶ @webavatar/webavatar-humanoid
│
@<scope>/webavatar-humanoid ─────────────┘
@webavatar/webavatar-humanoid ─────────────┘
contracts only, `import type` only
```
@ -205,7 +211,7 @@ Add lint rules that enforce these directions (`@typescript-eslint/no-restricted-
`allowTypeImports: true` on the core package for files under `packages/humanoid`). Documentation
alone is insufficient.
## Package 1: `@<scope>/webavatar-core`
## Package 1: `@webavatar/webavatar-core`
### Responsibilities
@ -229,7 +235,7 @@ Create curated index files and export only supported APIs. The manifest:
```json
{
"name": "@<scope>/webavatar-core",
"name": "@webavatar/webavatar-core",
"version": "0.1.0",
"license": "UNLICENSED",
"type": "module",
@ -314,7 +320,7 @@ protection has to be rebuilt in layers. All four are required:
```ts
// packages/core/src/server/browser-stub.ts
throw new Error(
'@<scope>/webavatar-core/server is server-only and was imported into browser code.'
'@webavatar/webavatar-core/server is server-only and was imported into browser code.'
);
```
@ -326,13 +332,13 @@ protection has to be rebuilt in layers. All four are required:
```ts
// apps/template/src/lib/server/webavatar-core.ts
export * from '@<scope>/webavatar-core/server';
export * from '@webavatar/webavatar-core/server';
```
SvelteKit routes and server modules import through this wrapper. Root-only entrypoints such as
`server.js` and `vite.config.ts` may import the package server and Vite entries directly.
3. **A lint rule** forbidding `@<scope>/webavatar-core/server` and `@<scope>/webavatar-core/vite`
3. **A lint rule** forbidding `@webavatar/webavatar-core/server` and `@webavatar/webavatar-core/vite`
everywhere in the app except `src/lib/server/**`, `server.js`, and `vite.config.ts`.
4. **A build-time guard in the template's Vite config**, so a direct import fails the build rather
@ -341,7 +347,7 @@ protection has to be rebuilt in layers. All four are required:
```ts
import type { Plugin } from 'vite';
const SERVER_ONLY = /^@<scope>\/webavatar-core\/(server|vite)(\/|$)/;
const SERVER_ONLY = /^@webavatar\/webavatar-core\/(server|vite)(\/|$)/;
export function serverOnlyGuard(): Plugin {
return {
@ -401,7 +407,7 @@ root is running. `server.js` shrinks to:
```js
import { createServer } from 'node:http';
import { createRealtimeRuntime } from '@<scope>/webavatar-core/server';
import { createRealtimeRuntime } from '@webavatar/webavatar-core/server';
import { handler } from './build/handler.js';
import { SESSION_PERSONA } from './build-server/project/persona.js';
import { toolRegistry } from './build-server/server/project/tools/index.js';
@ -427,9 +433,9 @@ logger. That test is the repository's only browser-mode Vitest test, so the brow
project (`client` in the current `vite.config.ts`) moves to the app with it; the packages' own
Vitest configs are node-only. Do not make the foundational core build responsible for packaging
Svelte components during the same migration. If multiple client projects later need the panel,
extract it in a separate `@<scope>/webavatar-devtools-svelte` package as a follow-up.
extract it in a separate `@webavatar/webavatar-devtools-svelte` package as a follow-up.
## Package 2: `@<scope>/webavatar-humanoid`
## Package 2: `@webavatar/webavatar-humanoid`
### Responsibilities
@ -463,17 +469,17 @@ conflict at install time.
```json
{
"name": "@<scope>/webavatar-humanoid",
"name": "@webavatar/webavatar-humanoid",
"version": "0.1.0",
"license": "UNLICENSED",
"type": "module",
"engines": { "node": ">=22.12" },
"files": ["dist"],
"peerDependencies": {
"@<scope>/webavatar-core": "^0.1.0"
"@webavatar/webavatar-core": "^0.1.0"
},
"devDependencies": {
"@<scope>/webavatar-core": "^0.1.0"
"@webavatar/webavatar-core": "^0.1.0"
},
"exports": {
".": {
@ -517,7 +523,7 @@ later, follow this shape: the `Logger` type comes from core as a type-only impor
no-op lives inside humanoid rather than being imported from core.
```ts
import type { Logger } from '@<scope>/webavatar-core';
import type { Logger } from '@webavatar/webavatar-core';
/** Local by design: importing core's NOOP_LOGGER would make this package's runtime depend on core for a four-line object. */
const NOOP_LOGGER: Logger = { debug() {}, info() {}, warn() {}, error() {} };
@ -540,8 +546,8 @@ export function createEyeGazeController(
The app supplies the implementation:
```ts
import { clientLogger } from '@<scope>/webavatar-core/client';
import { createEyeGazeController } from '@<scope>/webavatar-humanoid/eye/client';
import { clientLogger } from '@webavatar/webavatar-core/client';
import { createEyeGazeController } from '@webavatar/webavatar-humanoid/eye/client';
const gaze = createEyeGazeController(projectEyeConfig, { logger: clientLogger });
```
@ -569,12 +575,12 @@ The template owns:
Replace existing internal imports as follows:
| Current import family | Target import |
| ------------------------- | ----------------------------------------------------- |
| `$lib/core/**` | `@<scope>/webavatar-core` |
| `$lib/client/core/**` | `@<scope>/webavatar-core/client` |
| ------------------------- | ------------------------------------------------------- |
| `$lib/core/**` | `@webavatar/webavatar-core` |
| `$lib/client/core/**` | `@webavatar/webavatar-core/client` |
| `$lib/server/core/**` | `$lib/server/webavatar-core` in app source |
| `$lib/humanoid/**` | `@<scope>/webavatar-humanoid` or `/eye` |
| `$lib/client/humanoid/**` | `@<scope>/webavatar-humanoid/client` or `/eye/client` |
| `$lib/humanoid/**` | `@webavatar/webavatar-humanoid` or `/eye` |
| `$lib/client/humanoid/**` | `@webavatar/webavatar-humanoid/client` or `/eye/client` |
| `$lib/**/project/**` | Remains app-local project code |
Prefer importing from curated entry points instead of recreating one export per old source file. If
@ -617,7 +623,7 @@ ones, and the generated config must stay authoritative for `paths`, `rootDirs`,
### Production server compile
`server.js` is a plain-JavaScript Node entrypoint and it needs two kinds of TypeScript: the core
server code, which after the migration it imports from `@<scope>/webavatar-core/server`, and the
server code, which after the migration it imports from `@webavatar/webavatar-core/server`, and the
project persona and tool registry, which stay TypeScript inside the app. The second kind still has
to be compiled, so **the standalone compile shrinks; it does not disappear.** Keep
`apps/template/tsconfig.server.json`, scoped to exactly the project-owned directories:
@ -665,7 +671,7 @@ export default defineConfig(({ command }) => ({
plugins: [/* sveltekit, realtimeDevWsPlugin, serverOnlyGuard */],
ssr:
command === 'build'
? { external: ['@<scope>/webavatar-core', '@<scope>/webavatar-humanoid'] }
? { external: ['@webavatar/webavatar-core', '@webavatar/webavatar-humanoid'] }
: undefined
// ...optimizeDeps and test config as today
}));
@ -771,7 +777,7 @@ Use Gitea's built-in package registry at `https://<gitea-host>/api/packages/<own
committed scope mapping without a token:
```ini
@<scope>:registry=https://<gitea-host>/api/packages/<owner>/npm/
@webavatar:registry=https://<gitea-host>/api/packages/<owner>/npm/
```
Authentication is a separate, uncommitted line — a Gitea personal access token (or, in CI, a repo- or
@ -847,7 +853,7 @@ migration. Gitea Actions uses GitHub Actions syntax, and the runner must be able
`actions/checkout` and `actions/setup-node` (precondition 7).
`verify` runs on every push and pull request, deliberately **without** a registry token: with the
committed `.npmrc` scope mapping in place, any `@<scope>` range that does not resolve to a workspace
committed `.npmrc` scope mapping in place, any `@webavatar` range that does not resolve to a workspace
makes `npm ci` contact the registry, fail to authenticate, and fail the job — which is exactly the
workspace-range rule above, enforced mechanically.
@ -881,7 +887,7 @@ jobs:
- name: Humanoid ships no runtime reference to core
run: if grep -rl "webavatar-core" packages/humanoid/dist --include="*.js"; then exit 1; fi
- name: The SvelteKit server build imports core by name
run: grep -rl "@<scope>/webavatar-core/server" apps/template/build/server
run: grep -rl "@webavatar/webavatar-core/server" apps/template/build/server
```
`publish` runs only for the release tags from "Private publication and versioning", publishes only
@ -906,7 +912,7 @@ jobs:
with:
node-version: 22
registry-url: https://<gitea-host>/api/packages/<owner>/npm/
scope: '@<scope>'
scope: '@webavatar'
- run: npm ci
- run: npm run build:packages
- name: Resolve the workspace from the tag and check its version
@ -936,7 +942,7 @@ procedure in `apps/template/README.md` and keep it working:
1. Copy `apps/template` into a new repository. Do not copy the monorepo root, `packages/`, or the
root lockfile.
2. The copied `package.json` becomes the project's root manifest. It stays `"private": true`. Its
`@<scope>/*` ranges no longer resolve to workspace links, so pin them to published versions.
`@webavatar/*` ranges no longer resolve to workspace links, so pin them to published versions.
3. Add the committed `.npmrc` scope mapping, configure registry authentication as above, and run
`npm install`.
4. Copy `.env.example` to `.env`, fill in the secrets, and run `npm run dev`. The demo routes stay
@ -1076,9 +1082,9 @@ The migration is complete only when all of the following pass from a clean insta
dev/production wiring and package boundary changed even if the wire protocol did not.
11. Production `server.js` boots from built package artifacts rather than workspace source files, and
the SvelteKit server build imports the package by name instead of inlining it (a chunk under
`apps/template/build/server` references `@<scope>/webavatar-core/server`; none contains the core
`apps/template/build/server` references `@webavatar/webavatar-core/server`; none contains the core
server source).
12. Server-only guard: a temporary client-side import of `@<scope>/webavatar-core/server` fails the
12. Server-only guard: a temporary client-side import of `@webavatar/webavatar-core/server` fails the
template build; with the guard plugin disabled, the client bundle contains the browser stub's
error message and none of the server code. Revert the temporary import afterward.
13. The "Expected dev-mode reload behavior" table has been verified on all three paths and the docs

44
docs/REALTIME_SESSION.md

@ -40,11 +40,11 @@ sequenceDiagram
S-->>B: output_audio.chunk / transcript.delta / ...
```
Either side disconnecting or erroring tears down the other — see `RealtimeSession.close()` in [`session.ts`](../src/lib/server/core/session.ts).
Either side disconnecting or erroring tears down the other — see `RealtimeSession.close()` in [`session.ts`](../packages/core/src/server/session.ts).
## The extensibility seam
`src/lib/server/core/provider.ts` defines two interfaces:
[`packages/core/src/server/provider.ts`](../packages/core/src/server/provider.ts) defines two interfaces:
```ts
interface RealtimeProvider {
@ -58,13 +58,13 @@ interface ProviderSession {
}
```
The critical constraint: a `ProviderSession` speaks **only** the normalized `ClientEvent`/`ServerEvent` vocabulary from [`src/lib/core/protocol.ts`](../src/lib/core/protocol.ts) — never a provider's own wire format. `RealtimeSession` (the orchestrator in `session.ts`) only ever talks to that interface, never to OpenAI directly.
The critical constraint: a `ProviderSession` speaks **only** the normalized `ClientEvent`/`ServerEvent` vocabulary from [`protocol.ts`](../packages/core/src/protocol.ts) — never a provider's own wire format. `RealtimeSession` (the orchestrator in `session.ts`) only ever talks to that interface, never to OpenAI directly.
**To add a new provider or pipeline:** write a new file under `src/lib/server/core/providers/` implementing `RealtimeProvider`, translating between the normalized protocol and whatever that backend speaks (see `providers/openai.ts` for the reference implementation). Then wire it in wherever `OpenAiRealtimeProvider` is currently instantiated ([`server.js`](../server.js) for production, [`dev-plugin.ts`](../src/lib/server/core/dev-plugin.ts) for dev). Nothing else changes — not `session.ts`, not `ws-handler.ts`, not any client code.
**To add a new provider or pipeline:** write a new file under `packages/core/src/server/providers/` implementing `RealtimeProvider`, translating between the normalized protocol and whatever that backend speaks (see `providers/openai.ts` for the reference implementation). Then wire it into `selectProvider` (`providers/index.ts`) — the one place both composition roots (`apps/template/server.js` for production, the `./vite` plugin for dev) resolve a provider through, via the shared `createRealtimeRuntime` factory (see [ARCHITECTURE.md](ARCHITECTURE.md)'s "Composition roots"). Nothing else changes — not `session.ts`, not `ws-handler.ts`, not any client code.
## Resilience
`src/lib/client/core/resilient-client.ts`'s `ResilientRealtimeClient` wraps `RealtimeClient` (composition, not inheritance — `RealtimeClient` itself stays untouched, still pure transport) with automatic reconnection on an _unexpected_ transport drop. It exposes the same `send()`/`on()`/`close()` shape, plus a separate `onConnectionState(handler)` for transport-level state (`connecting | open | reconnecting | closed`) — kept out of the `ServerEvent`/protocol vocabulary deliberately, since that's the provider-relay contract, not the place for this transport's local retry bookkeeping.
`packages/core/src/client/resilient-client.ts`'s `ResilientRealtimeClient` wraps `RealtimeClient` (composition, not inheritance — `RealtimeClient` itself stays untouched, still pure transport) with automatic reconnection on an _unexpected_ transport drop. It exposes the same `send()`/`on()`/`close()` shape, plus a separate `onConnectionState(handler)` for transport-level state (`connecting | open | reconnecting | closed`) — kept out of the `ServerEvent`/protocol vocabulary deliberately, since that's the provider-relay contract, not the place for this transport's local retry bookkeeping.
**Reconnecting the browser↔relay socket does not resurrect a dead relay↔OpenAI session.** If the server proactively sends `session.closed` (`upstream_disconnected`, `client_requested`, etc.), that's a clean end — no auto-reconnect follows. Auto-reconnect only fires when the underlying socket closes with **no** preceding `session.closed` (a network blip, laptop sleep/wake, an idle timeout) — detected via `RealtimeClient.onClose()`, a small addition to `client.ts` alongside its existing `on()` (message events) for exactly this purpose, since without it there was no way to observe a drop that isn't itself a parsed `ServerEvent`.
@ -74,32 +74,40 @@ Backoff is exponential (`baseDelayMs * 2^(attempt-1)`), capped at `maxAttempts`
## Development without a live API
`src/lib/server/core/providers/mock.ts`'s `MockRealtimeProvider` implements the same `RealtimeProvider` interface as `OpenAiRealtimeProvider` (see "The extensibility seam" below), but replays a canned `MockScript` (`Array<{ delayMs, event: ServerEvent }>`, typed directly against `ServerEvent` so a fixture can't silently drift from the protocol) on a timer instead of talking to a real upstream. `send()` is a no-op — v1 is linear, one-shot replay, not interactive/branching scripting.
`packages/core/src/server/providers/mock.ts`'s `MockRealtimeProvider` implements the same `RealtimeProvider` interface as `OpenAiRealtimeProvider` (see "The extensibility seam" below), but replays a canned `MockScript` (`Array<{ delayMs, event: ServerEvent }>`, typed directly against `ServerEvent` so a fixture can't silently drift from the protocol) on a timer instead of talking to a real upstream. `send()` is a no-op — v1 is linear, one-shot replay, not interactive/branching scripting.
Select it with `REALTIME_PROVIDER=mock` (see [CONFIGURATION.md](CONFIGURATION.md)); `OPENAI_API_KEY` isn't required in that mode. `src/lib/server/core/providers/index.ts`'s `selectProvider(config)` is the one place both composition roots (`server.js`, `dev-plugin.ts`) call to pick between providers, instead of each hardcoding `new OpenAiRealtimeProvider(...)`.
Select it with `REALTIME_PROVIDER=mock` (see [CONFIGURATION.md](CONFIGURATION.md)); `OPENAI_API_KEY` isn't required in that mode. `providers/index.ts`'s `selectProvider(config)` is the one place `createRealtimeRuntime` calls to pick between providers, instead of either composition root hardcoding `new OpenAiRealtimeProvider(...)`.
This exists mainly so client/animation work (transcript rendering, `$lib/humanoid`'s avatar-state derivation — see [HUMANOID.md](HUMANOID.md)) can happen without an API key or live usage. `providers/fixtures/default.ts` ships one short canned exchange for exercising exactly that; see `/demo/humanoid`.
This exists mainly so client/animation work (transcript rendering, `@webavatar/webavatar-humanoid`'s avatar-state derivation — see [HUMANOID.md](HUMANOID.md)) can happen without an API key or live usage. `providers/fixtures/default.ts` ships one short canned exchange for exercising exactly that; see `/demo/humanoid`.
## The trust boundary
The browser's `session.start` message can only set `voice` (see `SessionStartConfig` in `protocol.ts`). `instructions`, `tools`, and `model` are always server-owned — sourced from the project's persona ([`src/lib/project/persona.ts`](../src/lib/project/persona.ts), `model` optionally overridden by `SESSION_MODEL`; see [docs/CONFIGURATION.md](CONFIGURATION.md)) via `resolveSessionDefaults()` (`defaults.ts`), and merged with the client-visible config in `resolveSessionConfig()` (`session.ts`). A server-side `defaultVoice` (`SESSION_DEFAULT_VOICE`) fills in when the browser's `session.start` doesn't specify a voice, but the browser's own choice still wins when it sends one — this doesn't loosen the trust boundary, since voice was already the one client-controllable field. This is deliberate: this relay is shared infrastructure that other people's browsers connect to, so a client project should never let an untrusted browser dictate what the model is told to do or which model/tools it can invoke. If a client project needs more client-controllable config, extend `SessionStartConfig` and `resolveSessionConfig()` explicitly and think about what's safe to expose — don't just pass the browser's config straight through.
The browser's `session.start` message can only set `voice` (see `SessionStartConfig` in `protocol.ts`). `instructions`, `tools`, and `model` are always server-owned — sourced from the project's persona ([`apps/template/src/lib/project/persona.ts`](../apps/template/src/lib/project/persona.ts), `model` optionally overridden by `SESSION_MODEL`; see [docs/CONFIGURATION.md](CONFIGURATION.md)) via `resolveSessionDefaults()` (`defaults.ts`), and merged with the client-visible config in `resolveSessionConfig()` (`session.ts`). A server-side `defaultVoice` (`SESSION_DEFAULT_VOICE`) fills in when the browser's `session.start` doesn't specify a voice, but the browser's own choice still wins when it sends one — this doesn't loosen the trust boundary, since voice was already the one client-controllable field. This is deliberate: this relay is shared infrastructure that other people's browsers connect to, so a client project should never let an untrusted browser dictate what the model is told to do or which model/tools it can invoke. If a client project needs more client-controllable config, extend `SessionStartConfig` and `resolveSessionConfig()` explicitly and think about what's safe to expose — don't just pass the browser's config straight through.
## SvelteKit WebSocket plumbing
SvelteKit has no native WebSocket support as of this writing (a `crossws`-based PR for it was closed by the maintainers in Feb 2026 in favor of an unrelated SSE feature). Raw HTTP upgrade requests have to be intercepted below SvelteKit's own routing, at the underlying `http.Server` level:
- **Production**: [`server.js`](../server.js) wraps adapter-node's generated `build/handler.js` in a plain `http.createServer` and attaches upgrade handling manually.
- **Dev**: [`dev-plugin.ts`](../src/lib/server/core/dev-plugin.ts) is a Vite plugin that attaches the same handling to Vite's dev server via `configureServer`. It's careful to no-op for any path that isn't ours, since Vite's own HMR websocket shares the same underlying HTTP server — touching the socket for a non-matching path would silently break HMR.
- **Production**: [`apps/template/server.js`](../apps/template/server.js) wraps adapter-node's generated `build/handler.js` in a plain `http.createServer`, calls `createRealtimeRuntime({ ..., isDev: false })`, and attaches `runtime.handleUpgrade` to the `'upgrade'` event.
- **Dev**: `@webavatar/webavatar-core/vite`'s `realtimeDevWsPlugin` ([`packages/core/src/vite/index.ts`](../packages/core/src/vite/index.ts)), used in `apps/template/vite.config.ts`, is a Vite plugin that calls the same `createRealtimeRuntime({ ..., isDev: true })` and attaches `runtime.handleUpgrade` to Vite's dev server via `configureServer`. It's careful to no-op for any path that isn't ours, since Vite's own HMR websocket shares the same underlying HTTP server — touching the socket for a non-matching path would silently break HMR.
Both call the same [`handleRealtimeUpgrade`](../src/lib/server/core/ws-handler.ts) function, so prod and dev behave identically.
Both go through the same [`handleRealtimeUpgrade`](../packages/core/src/server/ws-handler.ts) function inside `createRealtimeRuntime`'s `handleUpgrade` closure, so prod and dev behave identically — see [ARCHITECTURE.md](ARCHITECTURE.md)'s "Composition roots" for why this collapsed into one factory.
**Why `src/lib/core/` and `src/lib/server/core/` avoid `$lib`/`$env` aliases:** `server.js` is a plain Node entrypoint running the _built_ app — it can't resolve SvelteKit's `$lib`/`$env` virtual modules, which only exist inside Vite's build graph. So this module tree uses plain relative imports and reads secrets via `process.env` instead of `$env/static/private`, which lets it be compiled standalone (`tsc -p tsconfig.server.json` → `build-server/`, run as part of `npm run build`) for `server.js` to import directly. SvelteKit _routes_ (`+server.ts`, `+page.svelte`) are unaffected by this and use the normal `$lib` alias as usual.
**Why core avoids `$lib`/`$env` aliases:** `@webavatar/webavatar-core`'s server code uses plain relative imports and reads secrets via `process.env` instead of SvelteKit's `$env/static/private`, so it stays a portable package compiled by its own `tsc` build rather than depending on Vite's build graph — `server.js` (a plain Node entrypoint running the _built_ app) imports it directly, with no SvelteKit virtual modules involved. SvelteKit _routes_ (`+server.ts`, `+page.svelte`) are unaffected by this and use the normal `$lib` alias as usual, going through `apps/template/src/lib/server/webavatar-core.ts`.
**Known gotcha:** because this module tree is only loaded via `vite.config.ts`'s plugin list (not through the app's normal module graph), it is **not** part of Vite's HMR. Editing anything under `src/lib/server/core/**` while `npm run dev` is running requires a full dev-server restart to take effect — a hot-reload will silently keep running the old code. If you change something here and testing doesn't reflect it, restart before assuming the change is broken.
**Dev-mode reload behavior (verified live during the monorepo migration, superseding the old single-gotcha warning):**
| Path | After editing package source, with the package's `tsc --watch` running |
| -------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Browser code importing `@webavatar/webavatar-core/client` or `@webavatar/webavatar-humanoid` (e.g. `resilient-client.ts`, `avatar-state.ts`) | **Hot reload.** Once `tsc --watch` recompiles `dist/`, Vite detects the change and hot-updates the browser module graph — confirmed via a live edit to `client/logger.ts` propagating to `/demo/realtime` without a page reload. |
| SSR route code going through `apps/template/src/lib/server/webavatar-core.ts` (e.g. the session-token route) | **Hot reload**, for the same reason — confirmed via a live edit to `tokens.ts`'s `mintSessionToken` showing up on the very next `POST /api/realtime/session`, with no server restart. |
| The realtime WebSocket relay wired by `@webavatar/webavatar-core/vite`'s `realtimeDevWsPlugin` (used in `apps/template/vite.config.ts`) | **Restart required.** `vite.config.ts`'s plugin list is evaluated once when the dev server starts; a new WS connection after editing the plugin's own composition still ran the stale, already-registered upgrade handler — confirmed live, the edit only took effect after restarting `npm run dev`. |
The `tsc --watch` precondition above matters: `apps/template`'s own `npm run dev` in isolation does **not** recompile the packages, so package-source edits appear to do nothing at all unless you're running the root `npm run dev` (which orchestrates `watch -w packages/core`, `watch -w packages/humanoid`, and the app's dev server together) or a package's `npm run watch` separately.
## Protocol vocabulary
See [`protocol.ts`](../src/lib/core/protocol.ts) for the authoritative, current list — this is a summary, not a copy to keep in sync by hand.
See [`protocol.ts`](../packages/core/src/protocol.ts) for the authoritative, current list — this is a summary, not a copy to keep in sync by hand.
- **Client → server**: `session.start`, `input_audio.chunk`, `input_audio.commit`, `input_text.send`, `response.cancel`, `session.end`, `debug.subscribe`
- **Server → client**: `session.ready`, `output_audio.chunk`, `output_audio.done`, `transcript.delta`, `transcript.done`, `response.done`, `error`, `session.closed`, `debug.subscribe.result`, `debug.log`
@ -115,7 +123,7 @@ OpenAI's official docs were incomplete when this was built (WebFetch on `develop
- `response.output_audio.delta` / `response.output_audio.done` — not `response.audio.delta`
- `response.output_audio_transcript.delta` / `response.output_audio_transcript.done` — not `response.audio_transcript.delta`
Response-lifecycle events (`response.created`, `response.output_item.added/done`, `response.done`) are unaffected. This is handled in [`fromOpenAiEvent`](../src/lib/server/core/providers/openai.ts). If OpenAI changes their event schema again, unrecognized events are silently dropped (by design — see `default: return null` in that function); if a response's content stops showing up in the browser, temporarily add a `console.debug` in `handleUpstreamMessage`'s unmapped-event branch, restart the dev server, and read the real event names from `preview_logs` rather than re-guessing from docs.
Response-lifecycle events (`response.created`, `response.output_item.added/done`, `response.done`) are unaffected. This is handled in [`fromOpenAiEvent`](../packages/core/src/server/providers/openai.ts). If OpenAI changes their event schema again, unrecognized events are silently dropped (by design — see `default: return null` in that function); if a response's content stops showing up in the browser, temporarily add a `console.debug` in `handleUpstreamMessage`'s unmapped-event branch, restart the dev server, and read the real event names from `preview_logs` rather than re-guessing from docs.
Still unverified: the exact event name for a completed **input** audio transcription (`conversation.item.input_audio_transcription.completed` is the current guess, unconfirmed since testing so far only exercised text input).
@ -130,6 +138,6 @@ Still unverified: the exact event name for a completed **input** audio transcrip
## Verifying changes
- `npm run check` / `npm run lint` / `npm run build` for type, lint, and build correctness (`build` also runs the standalone `tsc -p tsconfig.server.json` compile — watch for errors there specifically, since `svelte-check` alone won't catch problems in code that isn't reached through the SvelteKit app).
- `npm run test:unit` — unit tests for `tokens.ts`, `session.ts` (state machine, against a fake in-memory provider), `providers/openai.ts`'s translation functions, `providers/mock.ts`'s script replay, `resilient-client.ts`'s backoff/reconnect logic, and `$lib/humanoid`'s reducer/compositor.
- **Live smoke test**: set `OPENAI_API_KEY` and `REALTIME_TOKEN_SECRET` in `.env` (see `.env.example`), run `npm run dev`, open `/demo/realtime`, and actually send a message. Type-checking and unit tests can't catch a wire-protocol mismatch like the one above — only a real round trip against the real API can. Remember the HMR gotcha above if you're iterating on `src/lib/server/core/**`.
- `npm run test:unit` — unit tests for `tokens.ts`, `session.ts` (state machine, against a fake in-memory provider), `providers/openai.ts`'s translation functions, `providers/mock.ts`'s script replay, `resilient-client.ts`'s backoff/reconnect logic, and `@webavatar/webavatar-humanoid`'s reducer/compositor.
- **Live smoke test**: set `OPENAI_API_KEY` and `REALTIME_TOKEN_SECRET` in `.env` (see `.env.example`), run `npm run dev` from the repo root, open `/demo/realtime`, and actually send a message. Type-checking and unit tests can't catch a wire-protocol mismatch like the one above — only a real round trip against the real API can. Remember the "Dev-mode reload behavior" table above if you're iterating on `packages/core/src/server/**` — you need the root `npm run dev`, not `apps/template`'s own, for changes there to take effect at all.
- **Mock-provider smoke test**: `REALTIME_PROVIDER=mock npm run dev`, open `/demo/humanoid`, connect, and watch `avatarState` move through the default fixture — zero OpenAI usage, useful for iterating on animation timing without live traffic.

26
docs/TOOLS.md

@ -9,7 +9,7 @@ A tool call can be satisfied in two different places:
- **Server tools** run on our server (e.g. a RAG lookup hitting a vector DB, or anything touching real data/secrets).
- **Client tools** run in the browser (e.g. "highlight this element", "scroll to section") — the server can't do these itself.
From the protocol's point of view both look the same: the provider emits a normalized `{ type: 'tool.call', callId, name, arguments }` `ServerEvent` ([`protocol.ts`](../src/lib/core/protocol.ts)). What differs is what `RealtimeSession` (`session.ts`) does with it:
From the protocol's point of view both look the same: the provider emits a normalized `{ type: 'tool.call', callId, name, arguments }` `ServerEvent` ([`protocol.ts`](../packages/core/src/protocol.ts)). What differs is what `RealtimeSession` (`session.ts`) does with it:
- A **server** tool is executed in-process — the browser never sees the call at all.
- A **client** tool is forwarded to the browser as-is; the browser runs its handler and replies with `{ type: 'tool.result', callId, output }`, which `RealtimeSession` relays up to the provider. If the browser never replies (disconnected tab, slow handler), a 10s timeout (`CLIENT_TOOL_TIMEOUT_MS` in `session.ts`) resolves the call with an error so the model's turn never hangs indefinitely.
@ -18,23 +18,23 @@ Either way, the result reaches the provider as the same `tool.result` `ClientEve
## The core/project ownership boundary
This repo is a template meant to be branched per client project. Everything under `core`/`server/core`/`client/core` is template infrastructure; everything under `project`/`server/project`/`client/project` is where a branched project's own code goes:
This repo is an npm-workspaces monorepo split between the `@webavatar/webavatar-core` package and the branched client project. Everything in `packages/core` is template infrastructure, published as a package and pulled forward into every client project; everything under `apps/template/src/lib/project`, `apps/template/src/lib/server/project`, `apps/template/src/lib/client/project` is where a branched project's own code goes:
| Layer | Core (template, gets pulled forward) | Project (branched-client-specific) |
| ----------- | ------------------------------------------------------------- | ---------------------------------------------------------- |
| Isomorphic | `src/lib/core/tools.ts` — `ToolDefinition` shape | `src/lib/project/tools.ts` — this project's tool schemas |
| Server-only | `src/lib/server/core/tools.ts` — registry + validation | `src/lib/server/project/tools/` — this project's executors |
| Client-only | `src/lib/client/core/tool-dispatcher.ts` — dispatch mechanism | `src/lib/client/project/tools/` — this project's handlers |
| Layer | Core (`@webavatar/webavatar-core`, gets pulled forward) | Project (branched-client-specific) |
| ----------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| Isomorphic | `packages/core/src/tools.ts` — `ToolDefinition` shape, exported from the package root `.` | `apps/template/src/lib/project/tools.ts` — this project's tool schemas |
| Server-only | `packages/core/src/server/tools.ts` — registry + validation, exported from `./server` | `apps/template/src/lib/server/project/tools/` — this project's executors |
| Client-only | `packages/core/src/client/tool-dispatcher.ts` — dispatch mechanism, exported from `./client` | `apps/template/src/lib/client/project/tools/` — this project's handlers |
**Rule: core never imports from project, only the reverse.** This is what lets a branched project pull template updates without merge conflicts in core files. In practice this means `server/core/defaults.ts` cannot import the project's tool registry directly — the merge happens at the two composition roots that are allowed to see both (`server.js` for production, `vite.config.ts` for dev, which passes the merged defaults into `dev-plugin.ts` as a parameter rather than having that core file import project code itself).
**Rule: core never imports from project, only the reverse.** This is what lets a branched project pull package updates without merge conflicts in core files. In practice this means `packages/core/src/server/defaults.ts` cannot import the project's tool registry directly — the merge happens at the composition roots that are allowed to see both, which now share a single `createRealtimeRuntime(options)` factory in `packages/core/src/server/index.ts` (exported from `./server`) rather than each doing its own wiring: `apps/template/server.js` calls it for production, and `apps/template/vite.config.ts` calls it (via the `./vite` plugin) for dev, each passing in the project's merged defaults as an option rather than having core import project code itself.
## Adding a tool
1. Add a `ToolDefinition` to `src/lib/project/tools.ts` — `name`, `description`, a JSON-schema `parameters` object, and `location: 'server' | 'client'`.
2. If `location: 'server'`, implement a `ToolExecutor` (`(args) => Promise<unknown>`) under `src/lib/server/project/tools/` and register it in that directory's `index.ts`.
3. If `location: 'client'`, implement a `ClientToolHandler` (same signature) under `src/lib/client/project/tools/` and register it in that directory's `index.ts`. Wire it wherever the page constructs its `RealtimeClient`, via `createToolDispatcher(client, CLIENT_TOOL_HANDLERS)` (see `/demo/realtime/+page.svelte` for the reference wiring).
1. Add a `ToolDefinition` to `apps/template/src/lib/project/tools.ts` — `name`, `description`, a JSON-schema `parameters` object, and `location: 'server' | 'client'`.
2. If `location: 'server'`, implement a `ToolExecutor` (`(args) => Promise<unknown>`) under `apps/template/src/lib/server/project/tools/` and register it in that directory's `index.ts`.
3. If `location: 'client'`, implement a `ClientToolHandler` (same signature) under `apps/template/src/lib/client/project/tools/` and register it in that directory's `index.ts`. Wire it wherever the page constructs its `RealtimeClient`, via `createToolDispatcher(client, CLIENT_TOOL_HANDLERS)` (see `/demo/realtime/+page.svelte` for the reference wiring).
`createToolRegistry` (`server/core/tools.ts`) validates the result at startup and throws if a server tool has no executor, a client tool _does_ have one registered (see next section), or an executor has no matching definition — a misconfigured tool fails loudly at boot, not silently the first time the model happens to call it.
`createToolRegistry` (`packages/core/src/server/tools.ts`, exported from `@webavatar/webavatar-core/server`) validates the result at startup and throws if a server tool has no executor, a client tool _does_ have one registered (see next section), or an executor has no matching definition — a misconfigured tool fails loudly at boot, not silently the first time the model happens to call it.
## Why the client/server split is enforced, not just documented
@ -48,4 +48,4 @@ Instead, `tool.result` never triggers continuation on its own. `RealtimeSession`
## What's unverified
The mapping from OpenAI's wire event for a completed function call (`response.output_item.done` with `item.type === 'function_call'`, in `providers/openai.ts`'s `fromOpenAiEvent`) is based on a docs fetch, not confirmed live traffic — this codebase has been burned by doc/reality drift on this exact API before (see the `output_` prefix gotcha in [REALTIME_SESSION.md](REALTIME_SESSION.md)), and there's no prior confirmed observation of `output_item.*` events in this codebase at all. If a tool call doesn't seem to reach `session.ts`, add a temporary `console.debug` in `fromOpenAiEvent`'s unmapped-event fallback, restart the dev server (this module isn't part of Vite's HMR graph), and read the real event name/shape from `preview_logs` rather than re-guessing from docs — same playbook as the previous gotcha.
The mapping from OpenAI's wire event for a completed function call (`response.output_item.done` with `item.type === 'function_call'`, in `providers/openai.ts`'s `fromOpenAiEvent`) is based on a docs fetch, not confirmed live traffic — this codebase has been burned by doc/reality drift on this exact API before (see the `output_` prefix gotcha in [REALTIME_SESSION.md](REALTIME_SESSION.md)), and there's no prior confirmed observation of `output_item.*` events in this codebase at all. If a tool call doesn't seem to reach `session.ts`, add a temporary `console.debug` in `fromOpenAiEvent`'s unmapped-event fallback, restart the dev server as needed (see the "Dev-mode reload behavior" table in [REALTIME_SESSION.md](REALTIME_SESSION.md) for exactly which changes require a restart vs. pick up on HMR), and read the real event name/shape from `preview_logs` rather than re-guessing from docs — same playbook as the previous gotcha.

80
eslint.config.js

@ -37,5 +37,85 @@ export default defineConfig(
// Override or add rule settings here, such as:
// 'svelte/button-has-type': 'error'
rules: {}
},
// --- Package-boundary enforcement (see docs/ARCHITECTURE.md) ---
{
// App/project code may only reach core's server-only entries through
// $lib/server/webavatar-core.ts; server.js and vite.config.ts are the
// two root-only composition roots allowed to import the package directly.
files: ['apps/template/**/*.{ts,js,svelte}'],
ignores: [
'apps/template/src/lib/server/**',
'apps/template/server.js',
'apps/template/vite.config.ts'
],
rules: {
'@typescript-eslint/no-restricted-imports': [
'error',
{
paths: [
{
name: '@webavatar/webavatar-core/server',
message: 'Server-only — import through $lib/server/webavatar-core.js instead.'
},
{
name: '@webavatar/webavatar-core/vite',
message: 'Server-only — only server.js and vite.config.ts may import this directly.'
}
]
}
]
}
},
{
// humanoid may depend on core's public contracts only as types — never
// on core's runtime values, and never on core's internal file layout.
files: ['packages/humanoid/src/**/*.ts'],
rules: {
'@typescript-eslint/no-restricted-imports': [
'error',
{
paths: [
{
name: '@webavatar/webavatar-core',
allowTypeImports: true,
message:
'humanoid may only import core contracts as types (`import type`) — no runtime values.'
}
],
patterns: [
{
group: ['**/core/**', '@webavatar/webavatar-core/*', '**/project/**', '**/apps/**'],
message: 'humanoid must not import core internals, app code, or project code.'
}
]
}
]
}
},
{
// Core never imports humanoid, app, or project code, in either direction.
files: ['packages/core/src/**/*.ts'],
rules: {
'@typescript-eslint/no-restricted-imports': [
'error',
{
paths: [
{ name: '@webavatar/webavatar-humanoid', message: 'core must not import humanoid.' }
],
patterns: [
{
group: [
'**/humanoid/**',
'@webavatar/webavatar-humanoid/*',
'**/project/**',
'**/apps/**'
],
message: 'core must not import humanoid, app, or project code.'
}
]
}
]
}
}
);

458
package-lock.json

@ -1,15 +1,37 @@
{
"name": "webavatar-base",
"name": "webavatar-monorepo",
"version": "0.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "webavatar-base",
"name": "webavatar-monorepo",
"license": "UNLICENSED",
"workspaces": [
"packages/*",
"apps/*"
],
"devDependencies": {
"@eslint/js": "^10.0.1",
"concurrently": "^9.1.0",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.19.0",
"globals": "^17.6.0",
"prettier": "^3.8.3",
"prettier-plugin-svelte": "^4.1.0",
"typescript-eslint": "^8.60.1"
},
"engines": {
"node": ">=22.12"
}
},
"apps/template": {
"name": "webavatar-template",
"version": "0.0.1",
"dependencies": {
"better-sqlite3": "^13.0.3",
"ws": "^8.21.3"
"@webavatar/webavatar-core": "^0.1.0",
"@webavatar/webavatar-humanoid": "^0.1.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@ -17,10 +39,8 @@
"@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@types/better-sqlite3": "^9.6.0",
"@types/node": "^24",
"@types/three": "^0.185.4",
"@types/ws": "^8.18.1",
"@vitest/browser-playwright": "^4.1.8",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
@ -321,7 +341,7 @@
"version": "0.146.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz",
"integrity": "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
@ -357,7 +377,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -374,7 +393,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -391,7 +409,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -408,7 +425,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -425,7 +441,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -442,7 +457,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -459,7 +473,6 @@
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
@ -479,7 +492,6 @@
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
@ -499,7 +511,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
@ -519,7 +530,6 @@
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
@ -539,7 +549,6 @@
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
@ -559,7 +568,6 @@
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
@ -579,7 +587,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -596,7 +603,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -613,7 +619,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -627,7 +632,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@rollup/plugin-commonjs": {
@ -1301,7 +1306,6 @@
"version": "9.6.0",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-9.6.0.tgz",
"integrity": "sha512-ZEEwBSgMu7GYJOynoagg5X9JbxfL6dTJDsgViJIqh67jV44kyOr9RXfmFjLK5rzC4MWssP06t9hu/JwGDnUbCg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@ -1357,7 +1361,6 @@
"version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.18.0"
@ -1410,7 +1413,6 @@
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@ -1806,6 +1808,14 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@webavatar/webavatar-core": {
"resolved": "packages/core",
"link": true
},
"node_modules/@webavatar/webavatar-humanoid": {
"resolved": "packages/humanoid",
"link": true
},
"node_modules/acorn": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
@ -1846,6 +1856,32 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/aria-query": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
@ -1921,6 +1957,36 @@
"node": ">=18"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chalk/node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@ -1937,6 +2003,21 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@ -1947,6 +2028,26 @@
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/commondir": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
@ -1954,6 +2055,31 @@
"dev": true,
"license": "MIT"
},
"node_modules/concurrently": {
"version": "9.2.4",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz",
"integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "4.1.2",
"rxjs": "7.8.2",
"shell-quote": "1.9.0",
"supports-color": "8.1.1",
"tree-kill": "1.2.2",
"yargs": "17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@ -2038,7 +2164,7 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@ -2051,6 +2177,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
@ -2068,6 +2201,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@ -2369,7 +2512,7 @@
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
@ -2466,6 +2609,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@ -2492,6 +2645,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
@ -2551,6 +2714,16 @@
"node": ">=0.10.0"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@ -2654,7 +2827,7 @@
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
"dev": true,
"devOptional": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
@ -2687,7 +2860,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@ -2708,7 +2880,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@ -2729,7 +2900,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@ -2750,7 +2920,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@ -2771,7 +2940,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@ -2792,7 +2960,6 @@
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
@ -2816,7 +2983,6 @@
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
@ -2840,7 +3006,6 @@
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
@ -2864,7 +3029,6 @@
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
@ -2888,7 +3052,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@ -2909,7 +3072,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@ -3020,7 +3182,7 @@
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"dev": true,
"devOptional": true,
"funding": [
{
"type": "github",
@ -3153,14 +3315,14 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"devOptional": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=12"
@ -3215,7 +3377,7 @@
"version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"dev": true,
"devOptional": true,
"funding": [
{
"type": "opencollective",
@ -3412,6 +3574,16 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@ -3438,7 +3610,7 @@
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz",
"integrity": "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.146.0",
@ -3514,6 +3686,16 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/sade": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
@ -3570,6 +3752,19 @@
"node": ">=8"
}
},
"node_modules/shell-quote": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz",
"integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
@ -3596,7 +3791,7 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"devOptional": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@ -3616,6 +3811,50 @@
"dev": true,
"license": "MIT"
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
@ -3789,7 +4028,7 @@
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
@ -3822,6 +4061,16 @@
"node": ">=6"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@ -3835,6 +4084,13 @@
"typescript": ">=4.8.4"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@ -3890,7 +4146,6 @@
"version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true,
"license": "MIT"
},
"node_modules/uri-js": {
@ -3914,7 +4169,7 @@
"version": "8.2.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz",
"integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.33.0",
@ -3992,7 +4247,6 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@ -4130,6 +4384,10 @@
"vitest": "^4.0.0"
}
},
"node_modules/webavatar-template": {
"resolved": "apps/template",
"link": true
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -4173,6 +4431,24 @@
"node": ">=0.10.0"
}
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
@ -4194,6 +4470,45 @@
}
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
@ -4213,6 +4528,49 @@
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"dev": true,
"license": "MIT"
},
"packages/core": {
"name": "@webavatar/webavatar-core",
"version": "0.1.0",
"license": "UNLICENSED",
"dependencies": {
"@types/better-sqlite3": "^9.6.0",
"@types/ws": "^8.18.1",
"better-sqlite3": "^13.0.3",
"ws": "^8.21.3"
},
"devDependencies": {
"@types/node": "^24",
"typescript": "^6.0.3",
"vitest": "^4.1.8"
},
"engines": {
"node": ">=22.12"
},
"peerDependencies": {
"vite": "^8.0.0"
},
"peerDependenciesMeta": {
"vite": {
"optional": true
}
}
},
"packages/humanoid": {
"name": "@webavatar/webavatar-humanoid",
"version": "0.1.0",
"license": "UNLICENSED",
"devDependencies": {
"@webavatar/webavatar-core": "^0.1.0",
"typescript": "^6.0.3",
"vitest": "^4.1.8"
},
"engines": {
"node": ">=22.12"
},
"peerDependencies": {
"@webavatar/webavatar-core": "^0.1.0"
}
}
}
}

52
package.json

@ -1,51 +1,33 @@
{
"name": "webavatar-base",
"name": "webavatar-monorepo",
"private": true,
"version": "0.0.1",
"type": "module",
"license": "UNLICENSED",
"engines": {
"node": ">=22.12"
},
"workspaces": [
"packages/*",
"apps/*"
],
"scripts": {
"dev": "vite dev",
"build": "vite build && tsc -p tsconfig.server.json",
"preview": "vite preview",
"start": "node server.js",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"build:packages": "npm run build -w packages/core && npm run build -w packages/humanoid",
"build": "npm run build:packages && npm run build -w apps/template",
"dev": "npm run build:packages && concurrently \"npm run watch -w packages/core\" \"npm run watch -w packages/humanoid\" \"npm run dev -w apps/template\"",
"check": "npm run build:packages && npm run check --workspaces --if-present",
"test": "npm run build:packages && npm run test -w packages/core -w packages/humanoid && npm run test:unit -w apps/template -- --run",
"lint": "prettier --check . && eslint .",
"format": "prettier --write .",
"test:unit": "vitest",
"test": "npm run test:unit -- --run && npm run test:e2e",
"test:e2e": "playwright install && playwright test"
"format": "prettier --write ."
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.60.0",
"@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@types/better-sqlite3": "^9.6.0",
"@types/node": "^24",
"@types/three": "^0.185.4",
"@types/ws": "^8.18.1",
"@vitest/browser-playwright": "^4.1.8",
"concurrently": "^9.1.0",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.19.0",
"globals": "^17.6.0",
"playwright": "^1.60.0",
"prettier": "^3.8.3",
"prettier-plugin-svelte": "^4.1.0",
"svelte": "^5.56.1",
"svelte-check": "^4.6.0",
"three": "^0.185.1",
"typescript": "^6.0.3",
"typescript-eslint": "^8.60.1",
"vite": "^8.0.16",
"vitest": "^4.1.8",
"vitest-browser-svelte": "^2.1.1"
},
"dependencies": {
"better-sqlite3": "^13.0.3",
"ws": "^8.21.3"
"typescript-eslint": "^8.60.1"
}
}

40
packages/core/README.md

@ -0,0 +1,40 @@
# @webavatar/webavatar-core
Reusable realtime-session infrastructure for webavatar client projects — session management, auth tokens, provider adapters, and the WebSocket relay — so a client project doesn't reimplement the basics from scratch. Privately distributed (not published to the public npm registry); see the monorepo root's `docs/ARCHITECTURE.md` for the full picture and `docs/REALTIME_SESSION.md` for how the session layer works.
**ESM-only.** No CommonJS build; `require()` is unsupported by design.
## Entry points
| Entry | Contains |
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `@webavatar/webavatar-core` | Isomorphic contracts: the `ClientEvent`/`ServerEvent` protocol, `Logger`/`LogEntry`/`LogLevel`/`NOOP_LOGGER`, `ToolDefinition`/`formatToolError`, `SessionPersona`. Safe for both browser and server code. |
| `@webavatar/webavatar-core/client` | Browser transports and wrappers: `RealtimeClient`, `ResilientRealtimeClient`, client-side logging (`clientLogger`, `debugLogStore`), `createToolDispatcher`. Transport only, no business logic. |
| `@webavatar/webavatar-core/server` | Server-only implementations: `loadConfig`, `selectProvider`, the analytics recorders, `resolveSessionDefaults`, `handleRealtimeUpgrade`, token mint/verify, `createToolRegistry`, and the `createRealtimeRuntime` composition-root factory. Resolves to a throwing stub in any consumer's browser bundle. |
| `@webavatar/webavatar-core/vite` | `realtimeDevWsPlugin` — the Vite dev-server integration that wires the same WS upgrade handling into `npm run dev`. Kept separate from `./server` so importing the package root never pulls in Vite. |
## The composition-root factory
Both a production entrypoint and a Vite dev-server plugin need the same wiring — load config, pick a provider, choose an analytics recorder, merge session defaults with your persona and tool registry:
```ts
import { createRealtimeRuntime } from '@webavatar/webavatar-core/server';
const runtime = createRealtimeRuntime({ persona, toolRegistry, isDev: false });
server.on('upgrade', runtime.handleUpgrade);
server.listen(runtime.config.port);
```
`isDev` is the only thing that differs per entrypoint. See `docs/ARCHITECTURE.md`'s "Composition roots" for why this exists — it's what keeps a consuming app's `server.js`/`vite.config.ts` thin.
## Server-only safety
`./server` and `./vite` map to a stub that throws when evaluated in a browser bundle (this package's `browser` export condition), for every consumer, automatically. A consuming SvelteKit app should still route through its own `$lib/server` wrapper and a build-time guard for defense in depth — see `docs/ARCHITECTURE.md`'s "Server-only safety" section for the full layered approach the template app uses.
## Compatibility
Caret-ranged (`^0.1.0`) during the `0.x` window — a core minor is a coordinated release with `@webavatar/webavatar-humanoid`'s peer range. See the monorepo root's `docs/MONOREPO_MIGRATION_BRIEF.md` ("Versions, ranges, and the `0.x` window") for the full policy.
## Peer dependencies
`vite` is an **optional** peer — only needed if you import `./vite`. Consumers that never touch that entry point don't need Vite installed at all.

60
packages/core/package.json

@ -0,0 +1,60 @@
{
"name": "@webavatar/webavatar-core",
"version": "0.1.0",
"license": "UNLICENSED",
"type": "module",
"engines": {
"node": ">=22.12"
},
"files": [
"dist"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./client": {
"types": "./dist/client/index.d.ts",
"import": "./dist/client/index.js"
},
"./server": {
"types": "./dist/server/index.d.ts",
"browser": "./dist/server/browser-stub.js",
"import": "./dist/server/index.js"
},
"./vite": {
"types": "./dist/vite/index.d.ts",
"browser": "./dist/server/browser-stub.js",
"import": "./dist/vite/index.js"
}
},
"dependencies": {
"better-sqlite3": "^13.0.3",
"ws": "^8.21.3",
"@types/better-sqlite3": "^9.6.0",
"@types/ws": "^8.18.1"
},
"peerDependencies": {
"vite": "^8.0.0"
},
"peerDependenciesMeta": {
"vite": {
"optional": true
}
},
"publishConfig": {
"registry": "https://<gitea-host>/api/packages/<owner>/npm/"
},
"devDependencies": {
"@types/node": "^24",
"typescript": "^6.0.3",
"vitest": "^4.1.8"
},
"scripts": {
"build": "tsc -p tsconfig.build.json",
"watch": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
"check": "tsc -p tsconfig.json --noEmit",
"test": "vitest run"
}
}

2
src/lib/client/core/client.ts → packages/core/src/client/client.ts

@ -1,4 +1,4 @@
import { REALTIME_WS_PATH, type ClientEvent, type ServerEvent } from '../../core/protocol.js';
import { REALTIME_WS_PATH, type ClientEvent, type ServerEvent } from '../protocol.js';
export interface RealtimeClientOptions {
/** Origin to connect to. Defaults to the current page's origin. */

22
packages/core/src/client/index.ts

@ -0,0 +1,22 @@
export { RealtimeClient } from './client.js';
export type { RealtimeClientOptions } from './client.js';
export { ResilientRealtimeClient } from './resilient-client.js';
export type {
ConnectionState,
RealtimeTransport,
ResilientRealtimeClientOptions
} from './resilient-client.js';
export { clientLogger, debugLogStore, pushLogEntry, clearLogs } from './logger.js';
export type { DisplayLogEntry } from './logger.js';
export { attachServerLogForwarding } from './server-log-forwarder.js';
export type {
ServerLogForwarder,
ServerLogForwarderState,
ServerLogForwardingClient
} from './server-log-forwarder.js';
export { createToolDispatcher } from './tool-dispatcher.js';
export type { ToolDispatchClient, ClientToolHandler } from './tool-dispatcher.js';

0
src/lib/client/core/logger.spec.ts → packages/core/src/client/logger.spec.ts

2
src/lib/client/core/logger.ts → packages/core/src/client/logger.ts

@ -1,4 +1,4 @@
import type { LogEntry, LogLevel, Logger } from '../../core/logger.js';
import type { LogEntry, LogLevel, Logger } from '../logger.js';
/** A `LogEntry` tagged with where it came from, for display in `DebugPanel.svelte`. */
export interface DisplayLogEntry extends LogEntry {

2
src/lib/client/core/resilient-client.spec.ts → packages/core/src/client/resilient-client.spec.ts

@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import type { ClientEvent, ServerEvent } from '../../core/protocol.js';
import type { ClientEvent, ServerEvent } from '../protocol.js';
import { ResilientRealtimeClient, type RealtimeTransport } from './resilient-client.js';
/** Minimal stand-in for RealtimeClient — only what ResilientRealtimeClient touches. */

2
src/lib/client/core/resilient-client.ts → packages/core/src/client/resilient-client.ts

@ -1,4 +1,4 @@
import type { ClientEvent, ServerEvent } from '../../core/protocol.js';
import type { ClientEvent, ServerEvent } from '../protocol.js';
import { RealtimeClient, type RealtimeClientOptions } from './client.js';
export type ConnectionState = 'connecting' | 'open' | 'reconnecting' | 'closed';

2
src/lib/client/core/server-log-forwarder.spec.ts → packages/core/src/client/server-log-forwarder.spec.ts

@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it } from 'vitest';
import type { ClientEvent, ServerEvent } from '../../core/protocol.js';
import type { ClientEvent, ServerEvent } from '../protocol.js';
import { clearLogs, debugLogStore } from './logger.js';
import {
attachServerLogForwarding,

2
src/lib/client/core/server-log-forwarder.ts → packages/core/src/client/server-log-forwarder.ts

@ -1,4 +1,4 @@
import type { ClientEvent, ServerEvent } from '../../core/protocol.js';
import type { ClientEvent, ServerEvent } from '../protocol.js';
import { pushLogEntry } from './logger.js';
/**

2
src/lib/client/core/tool-dispatcher.spec.ts → packages/core/src/client/tool-dispatcher.spec.ts

@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import type { ServerEvent } from '../../core/protocol.js';
import type { ServerEvent } from '../protocol.js';
import type { RealtimeClient } from './client.js';
import { createToolDispatcher } from './tool-dispatcher.js';

4
src/lib/client/core/tool-dispatcher.ts → packages/core/src/client/tool-dispatcher.ts

@ -1,5 +1,5 @@
import type { ClientEvent, ServerEvent } from '../../core/protocol.js';
import { formatToolError } from '../../core/tools.js';
import type { ClientEvent, ServerEvent } from '../protocol.js';
import { formatToolError } from '../tools.js';
export type ClientToolHandler = (args: Record<string, unknown>) => Promise<unknown>;

16
packages/core/src/index.ts

@ -0,0 +1,16 @@
export type {
SessionStartConfig,
ClientEvent,
BrowserClientEvent,
RealtimeErrorCode,
ServerEvent
} from './protocol.js';
export { REALTIME_WS_PATH, isBrowserClientEvent } from './protocol.js';
export type { LogLevel, LogEntry, Logger } from './logger.js';
export { NOOP_LOGGER } from './logger.js';
export type { ToolDefinition } from './tools.js';
export { formatToolError } from './tools.js';
export type { SessionPersona } from './persona.js';

0
src/lib/core/logger.ts → packages/core/src/logger.ts

11
packages/core/src/persona.ts

@ -0,0 +1,11 @@
/**
* Portable session-persona contract. A branched project supplies a concrete
* value satisfying this interface (see `apps/template/src/lib/project/persona.ts`)
* for `resolveSessionDefaults` to take as a parameter — core never imports a
* project's own persona value, only this contract (see docs/TOOLS.md for the
* same core/project ownership split applied to tools).
*/
export interface SessionPersona {
model: string;
instructions: string;
}

0
src/lib/core/protocol.ts → packages/core/src/protocol.ts

0
src/lib/server/core/analytics.spec.ts → packages/core/src/server/analytics.spec.ts

0
src/lib/server/core/analytics.ts → packages/core/src/server/analytics.ts

3
packages/core/src/server/browser-stub.ts

@ -0,0 +1,3 @@
throw new Error(
'@webavatar/webavatar-core/server is server-only and was imported into browser code.'
);

0
src/lib/server/core/config.spec.ts → packages/core/src/server/config.spec.ts

0
src/lib/server/core/config.ts → packages/core/src/server/config.ts

0
src/lib/server/core/defaults.spec.ts → packages/core/src/server/defaults.spec.ts

2
src/lib/server/core/defaults.ts → packages/core/src/server/defaults.ts

@ -1,4 +1,4 @@
import type { SessionPersona } from '../../project/persona.js';
import type { SessionPersona } from '../persona.js';
import type { SessionDefaults } from './session.js';
/**

96
packages/core/src/server/index.ts

@ -0,0 +1,96 @@
import type { IncomingMessage } from 'node:http';
import type { Duplex } from 'node:stream';
import type { SessionPersona } from '../persona.js';
import {
NoopAnalyticsRecorder,
SqliteAnalyticsRecorder,
type AnalyticsRecorder
} from './analytics.js';
import { loadConfig } from './config.js';
import { resolveSessionDefaults } from './defaults.js';
import { handleRealtimeUpgrade } from './ws-handler.js';
import { selectProvider } from './providers/index.js';
import type { RealtimeProvider } from './provider.js';
import type { SessionDefaults } from './session.js';
import type { ToolRegistry } from './tools.js';
import type { AppConfig } from './config.js';
export type { AppConfig, AnalyticsConfig, SessionRuntimeConfig, DebugConfig } from './config.js';
export { loadConfig } from './config.js';
export type { AnalyticsCategoryConfig, AnalyticsRecorder } from './analytics.js';
export { SqliteAnalyticsRecorder, NoopAnalyticsRecorder } from './analytics.js';
export type { RealtimeProvider, ProviderSession, ResolvedSessionConfig } from './provider.js';
export { selectProvider } from './providers/index.js';
export type { SessionDefaults, RealtimeSessionState } from './session.js';
export { createRealtimeSession, resolveSessionConfig } from './session.js';
export { resolveSessionDefaults } from './defaults.js';
export type { RealtimeUpgradeDeps } from './ws-handler.js';
export { handleRealtimeUpgrade } from './ws-handler.js';
export type { ToolExecutor, ToolRegistry } from './tools.js';
export { createToolRegistry } from './tools.js';
export type { MintedToken, TokenVerification } from './tokens.js';
export { mintSessionToken, verifySessionToken, timingSafeCompare } from './tokens.js';
export type { CreateLoggerOptions } from './logger.js';
export { createLogger, serverLogger, safeSerialize, verifyDebugAccessKey } from './logger.js';
export interface RealtimeRuntimeOptions {
persona: SessionPersona;
toolRegistry: ToolRegistry;
/** Literal per entrypoint: dev auto-forwards debug logs, prod requires DEBUG_ACCESS_KEY. */
isDev: boolean;
}
export interface RealtimeRuntime {
config: AppConfig;
provider: RealtimeProvider;
recorder: AnalyticsRecorder;
defaults: SessionDefaults;
/** Attach to an http.Server 'upgrade' event. A closure, so it can be passed unbound. */
handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void;
}
/**
* Collapses the composition wiring both entrypoints (production `server.js`,
* the dev-mode `./vite` plugin) need: load/validate config, pick a provider,
* choose an analytics recorder, and merge session defaults with the caller's
* persona and tool registry. A verbatim move of what each entrypoint used to
* duplicate — not new behavior. Neither a service locator nor global state:
* returns a fresh instance per call, called once per process by whichever
* composition root is running.
*/
export function createRealtimeRuntime(options: RealtimeRuntimeOptions): RealtimeRuntime {
const config = loadConfig();
const provider = selectProvider(config);
const recorder = config.analytics.enabled
? new SqliteAnalyticsRecorder(config.analytics.dbPath, config.analytics)
: new NoopAnalyticsRecorder();
const defaults: SessionDefaults = {
...resolveSessionDefaults(options.persona, config.session),
toolRegistry: options.toolRegistry
};
return {
config,
provider,
recorder,
defaults,
handleUpgrade(req, socket, head) {
handleRealtimeUpgrade(req, socket, head, {
provider,
defaults,
recorder,
clientToolTimeoutMs: config.session.clientToolTimeoutMs,
isDev: options.isDev,
logMessages: config.debug.messages
});
}
};
}

0
src/lib/server/core/logger.spec.ts → packages/core/src/server/logger.spec.ts

2
src/lib/server/core/logger.ts → packages/core/src/server/logger.ts

@ -1,4 +1,4 @@
import type { LogEntry, LogLevel, Logger } from '../../core/logger.js';
import type { LogEntry, LogLevel, Logger } from '../logger.js';
import { timingSafeCompare } from './tokens.js';
const CONSOLE_METHOD: Record<LogLevel, 'debug' | 'info' | 'warn' | 'error'> = {

4
src/lib/server/core/provider.ts → packages/core/src/server/provider.ts

@ -1,5 +1,5 @@
import type { ClientEvent, ServerEvent } from '../../core/protocol.js';
import type { ToolDefinition } from '../../core/tools.js';
import type { ClientEvent, ServerEvent } from '../protocol.js';
import type { ToolDefinition } from '../tools.js';
/**
* Fully-resolved, server-trusted session config. Never expose this shape to

0
src/lib/server/core/providers/fixtures/default.ts → packages/core/src/server/providers/fixtures/default.ts

0
src/lib/server/core/providers/index.ts → packages/core/src/server/providers/index.ts

2
src/lib/server/core/providers/mock.spec.ts → packages/core/src/server/providers/mock.spec.ts

@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ServerEvent } from '../../../core/protocol.js';
import type { ServerEvent } from '../../protocol.js';
import { MockRealtimeProvider, type MockScript } from './mock.js';
const SCRIPT: MockScript = [

2
src/lib/server/core/providers/mock.ts → packages/core/src/server/providers/mock.ts

@ -1,4 +1,4 @@
import type { ServerEvent } from '../../../core/protocol.js';
import type { ServerEvent } from '../../protocol.js';
import type { ProviderSession, RealtimeProvider } from '../provider.js';
/** One scripted event, replayed `delayMs` after the previous step (or after `createSession` for the first step). */

2
src/lib/server/core/providers/openai.spec.ts → packages/core/src/server/providers/openai.spec.ts

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import type { ToolDefinition } from '../../../core/tools.js';
import type { ToolDefinition } from '../../tools.js';
import { fromOpenAiEvent, toOpenAiEvents, toOpenAiTool } from './openai.js';
const TOOL: ToolDefinition = {

4
src/lib/server/core/providers/openai.ts → packages/core/src/server/providers/openai.ts

@ -1,6 +1,6 @@
import WebSocket from 'ws';
import type { ClientEvent, ServerEvent } from '../../../core/protocol.js';
import type { ToolDefinition } from '../../../core/tools.js';
import type { ClientEvent, ServerEvent } from '../../protocol.js';
import type { ToolDefinition } from '../../tools.js';
import type { ProviderSession, RealtimeProvider, ResolvedSessionConfig } from '../provider.js';
const REALTIME_URL = 'wss://api.openai.com/v1/realtime';

4
src/lib/server/core/session.spec.ts → packages/core/src/server/session.spec.ts

@ -1,8 +1,8 @@
import { EventEmitter } from 'node:events';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type WebSocket from 'ws';
import type { ClientEvent, ServerEvent } from '../../core/protocol.js';
import type { ToolDefinition } from '../../core/tools.js';
import type { ClientEvent, ServerEvent } from '../protocol.js';
import type { ToolDefinition } from '../tools.js';
import { SqliteAnalyticsRecorder, type AnalyticsRecorder } from './analytics.js';
import type { ProviderSession, RealtimeProvider } from './provider.js';
import { createRealtimeSession, resolveSessionConfig } from './session.js';

6
src/lib/server/core/session.ts → packages/core/src/server/session.ts

@ -1,14 +1,14 @@
import { randomUUID } from 'node:crypto';
import WebSocket from 'ws';
import type { LogEntry, Logger } from '../../core/logger.js';
import type { LogEntry, Logger } from '../logger.js';
import {
isBrowserClientEvent,
type BrowserClientEvent,
type ClientEvent,
type ServerEvent,
type SessionStartConfig
} from '../../core/protocol.js';
import { formatToolError } from '../../core/tools.js';
} from '../protocol.js';
import { formatToolError } from '../tools.js';
import type { AnalyticsRecorder } from './analytics.js';
import { createLogger, safeSerialize, verifyDebugAccessKey } from './logger.js';
import type { ProviderSession, RealtimeProvider, ResolvedSessionConfig } from './provider.js';

0
src/lib/server/core/tokens.spec.ts → packages/core/src/server/tokens.spec.ts

0
src/lib/server/core/tokens.ts → packages/core/src/server/tokens.ts

2
src/lib/server/core/tools.spec.ts → packages/core/src/server/tools.spec.ts

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import type { ToolDefinition } from '../../core/tools.js';
import type { ToolDefinition } from '../tools.js';
import { createToolRegistry } from './tools.js';
const SERVER_TOOL: ToolDefinition = {

2
src/lib/server/core/tools.ts → packages/core/src/server/tools.ts

@ -1,4 +1,4 @@
import type { ToolDefinition } from '../../core/tools.js';
import type { ToolDefinition } from '../tools.js';
export type ToolExecutor = (args: Record<string, unknown>) => Promise<unknown>;

2
src/lib/server/core/ws-handler.ts → packages/core/src/server/ws-handler.ts

@ -1,7 +1,7 @@
import type { IncomingMessage } from 'node:http';
import type { Duplex } from 'node:stream';
import { WebSocketServer } from 'ws';
import { REALTIME_WS_PATH } from '../../core/protocol.js';
import { REALTIME_WS_PATH } from '../protocol.js';
import type { AnalyticsRecorder } from './analytics.js';
import type { RealtimeProvider } from './provider.js';
import { createRealtimeSession, type SessionDefaults } from './session.js';

0
src/lib/core/tools.ts → packages/core/src/tools.ts

57
packages/core/src/vite/index.ts

@ -0,0 +1,57 @@
import { loadEnv, type Plugin } from 'vite';
import type { SessionPersona } from '../persona.js';
import { createRealtimeRuntime } from '../server/index.js';
import type { ToolRegistry } from '../server/tools.js';
export interface RealtimeDevWsPluginOptions {
/**
* Raw, env-independent pieces (project persona + tool registry) — taken as
* parameters rather than imported here so this module never has to import
* project code directly. See the composition root that supplies these
* (apps/template/vite.config.ts) and docs/TOOLS.md.
*
* Deliberately NOT pre-resolved against env vars by the caller: resolving
* them happens inside configureServer below, gated by the VITEST check —
* see the comment on that check for why.
*/
persona: SessionPersona;
toolRegistry: ToolRegistry;
}
/**
* Wires the same upgrade handling used in production (via
* `createRealtimeRuntime` from `./server`) into Vite's dev server, so
* `npm run dev` supports the realtime WS endpoint without a production build.
*
* Loads .env itself: this hook runs at dev-server startup, outside
* SvelteKit's own request/module graph, so `$env/...` isn't available here —
* see the note in `./server/tokens.ts` for why this whole module tree reads
* `process.env` directly instead.
*/
export function realtimeDevWsPlugin(options: RealtimeDevWsPluginOptions): Plugin {
return {
name: 'webavatar-realtime-ws-dev',
configureServer(server) {
// vitest's browser-mode project boots its own Vite dev server for
// component tests; this plugin has nothing to do there. Critically,
// this guard is also what keeps loadConfig()'s fail-fast validation
// (inside createRealtimeRuntime) from running during `npm run
// test:unit` — that suite's `test.projects` entries both `extends`
// the app's vite.config.ts, so this plugin's config gets evaluated
// there too, and unit tests need no env vars set at all. Don't move
// runtime creation above this line.
if (process.env.VITEST) return;
const loaded = loadEnv('development', process.cwd(), '');
for (const [key, value] of Object.entries(loaded)) {
if (process.env[key] === undefined) process.env[key] = value;
}
const runtime = createRealtimeRuntime({ ...options, isDev: true });
server.httpServer?.on('upgrade', (req, socket, head) => {
runtime.handleUpgrade(req, socket, head);
});
}
};
}

12
packages/core/tsconfig.build.json

@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"exclude": ["src/**/*.spec.ts"]
}

11
packages/core/tsconfig.json

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"target": "es2023",
"types": ["node"],
"noEmit": true
},
"include": ["src/**/*.ts"]
}

9
packages/core/vitest.config.ts

@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/*.spec.ts'],
expect: { requireAssertions: true }
}
});

28
packages/humanoid/README.md

@ -0,0 +1,28 @@
# @webavatar/webavatar-humanoid
Renderer-agnostic avatar-state derivation and animation-channel composition for 3D humanoid avatars rigged with ARKit blend shapes. An **optional** package — infrastructure only some webavatar client projects need (one building a 2D avatar has no reason to depend on this at all). See the monorepo root's `docs/HUMANOID.md` for the full design writeup.
**ESM-only.** No CommonJS build; `require()` is unsupported by design.
## Entry points
| Entry | Contains |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `@webavatar/webavatar-humanoid` | `AvatarState`/`AvatarSignal`/`nextAvatarState` (the pure state-transition reducer), `AnimationConfig`/`DEFAULT_ANIMATION_CONFIG`/`resolveAnimationConfig`, `ARKitBlendShape`/`AnimationFrame`/`AnimationLayer`/`compositeAnimationFrames`. |
| `@webavatar/webavatar-humanoid/client` | `deriveAvatarState` — the browser wrapper that folds a live `RealtimeClient`'s `ServerEvent`s through the pure reducer and owns the `idleActive`→`idlePassive` timer. |
| `@webavatar/webavatar-humanoid/eye` | The first animation library built as a consumer of the root contract: the Macro/Micro/Nano gaze system (`stepEyeGaze`, `EyeConfig`/`DEFAULT_EYE_CONFIG`/`resolveEyeConfig`, `gazeOffsetToBlendShapes`). |
| `@webavatar/webavatar-humanoid/eye/client` | `createEyeGazeController` — the browser wrapper that owns the gaze system's per-frame clock and `Math.random`. |
Future animation libraries (blink, lip-sync, body/gesture) are expected to nest the same way, as `./<name>` / `./<name>/client` sub-packages, rather than becoming further top-level packages.
## What this package is not
No renderer knowledge (Three.js, Babylon, a game engine — none of it), no rig/mesh binding, no Svelte dependency (its hand-rolled stores satisfy the Svelte store contract structurally). Consuming code owns which `ARKitBlendShape` maps to which morph target on its own mesh.
## Core dependency: type-only
This package's only relationship to `@webavatar/webavatar-core` is a **type-only** import (`import type { ServerEvent }`) — its built JavaScript contains no runtime reference to core at all, verified as part of this package's own build (`grep -rl "webavatar-core" dist --include="*.js"` finds nothing). Core is declared as a `peerDependency` purely so npm reports a version-compatibility error at install time if a project's core and humanoid versions drift apart — not because this package calls into core at runtime.
## Compatibility
Caret-ranged (`^0.1.0`) during the `0.x` window — every humanoid release during this window bumps its peer/dev range on core in the same commit as any core minor it needs to track. See the monorepo root's `docs/MONOREPO_MIGRATION_BRIEF.md` ("Versions, ranges, and the `0.x` window") for the full policy.

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save