@convai/embody 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @convai/embody
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 249a96f: Optional corrective map for one-morph-per-control bodies. A table fitted from the body's MetaHuman DNA re-weights each lipsync frame toward the full rig (PSD correctives, joint arcs). Pass `correctiveMap` (from `compileCorrectiveMap`) to `createCharacter` or the React components; it is off by default and swaps live through the prop or `character.lipsync.correctiveMap`.
8
+
3
9
  ## 0.4.1
4
10
 
5
11
  ### Patch Changes
package/README.md CHANGED
@@ -22,13 +22,18 @@ lipsync, gaze, breathing and blink. Layered on
22
22
  ## Install
23
23
 
24
24
  ```bash
25
- pnpm add @convai/embody three
25
+ pnpm add @convai/embody @convai/web-sdk three
26
26
  # React consumers also need:
27
27
  pnpm add @react-three/fiber react
28
28
  ```
29
29
 
30
- `three` and `@convai/web-sdk` are peer dependencies. `react` and
31
- `@react-three/fiber` are optional peers — the `/core` path needs neither.
30
+ `@convai/web-sdk` and `three` are peer dependencies. `react` and
31
+ `@react-three/fiber` are optional peers — the root `@convai/embody` entry
32
+ point needs neither. Node.js 18 or later.
33
+
34
+ A character renders only if its avatar has a published web body, or a default
35
+ body applies to it. Otherwise loading fails with *"Character has no 3D
36
+ embodiment configured"* — a data problem, not a code one.
32
37
 
33
38
  ## Quick start
34
39
 
@@ -173,10 +178,33 @@ The defaults cover the normal case; each of these exists for a specific one.
173
178
  | `session` | Extra SDK options (voice, memory, transport, `endUserId`). Credentials and `characterId` are not repeated here. |
174
179
  | `baseUrl` | A different backend environment. Defaults per release channel. |
175
180
  | `queue` on `update()` | One client driving several bodies, when you are deciding which owns the current turn. |
181
+ | `correctiveMap` | A table fitted from the body's MetaHuman DNA (`compileCorrectiveMap(json)`) that re-weights each lipsync frame toward what the full rig shows: PSD correctives and joint arcs that one-morph-per-control bodies lack. Off by default. Changing the prop, or `character.lipsync.correctiveMap`, swaps it live. In `pnpm dev`, the demo lists `*.map.json` tables from `../open-rig-dna/maps` (or `$EMBODY_CORRECTIVE_MAPS`) for A/B: **C** toggles, **F** frames the face. |
176
182
 
177
- ## Docs
183
+ ## Documentation
178
184
 
179
- Design, plan and decision history live in [`docs/`](./docs):
185
+ **User documentation** is published on docs.convai.com under *Web Plugins →
186
+ Convai Embody*. Its source is [`docs/site/`](./docs/site) in this repository:
187
+
188
+ | | |
189
+ |---|---|
190
+ | [Add a 3D character to a React app](./docs/site/convai-embody/add-a-3d-character-to-a-react-app.md) | install, render, verify, troubleshoot |
191
+ | [Add a 3D character to a three.js scene](./docs/site/convai-embody/add-a-3d-character-to-a-three-js-scene.md) | the framework-free API |
192
+ | [Add a chat widget to a character](./docs/site/convai-embody/add-a-chat-widget-to-a-character.md) | one connection for body and chat |
193
+ | [React API reference](./docs/site/convai-embody/react-api-reference.md) | every prop and default |
194
+ | [createCharacter reference](./docs/site/convai-embody/createcharacter-reference.md) | every option, the handle, the errors |
195
+ | [How Convai Embody works](./docs/site/convai-embody/how-convai-embody-works.md) | resolution, the frame pipeline, environments |
196
+
197
+ Edit those pages here, not in the documentation repository. **Docs are part of every change:** CI runs the same sync into a throwaway checkout of Convai-Documentation and lints every page with that repository's `check_page.py`, so a page that would fail the docs gate fails the PR. Section path, version variable and sidebar placement live in [`docs/site/docs.config.json`](./docs/site/docs.config.json); page order, including nested groups, in [`docs/site/sidebar.json`](./docs/site/sidebar.json). Every publish to
198
+ npm `latest` runs [`docs-sync.yml`](./.github/workflows/docs-sync.yml), which
199
+ opens a pull request against `Conv-AI/Convai-Documentation` (`staging`) with
200
+ the pages, their sidebar entries, `last_reviewed`, and the `embody_version`
201
+ GitBook variable. Merging that pull request publishes to the GitBook **Staging**
202
+ Space for review; the pages reach docs.convai.com only when a human then merges
203
+ `staging` into `main` in that repository. Run the workflow by hand for a
204
+ docs-only fix. The pages follow that repository's authoring rules, recorded for
205
+ this package in [`docs/site/pack.md`](./docs/site/pack.md).
206
+
207
+ **Design, plan and decision history** live in [`docs/`](./docs):
180
208
 
181
209
  | | |
182
210
  |---|---|
@@ -194,13 +222,19 @@ The backend host is a source constant, `DEFAULT_BASE_URL` in
194
222
 
195
223
  | Branch | Host | npm tag |
196
224
  |---|---|---|
197
- | `stable-release` | `https://api.convai.com` | `latest` |
198
- | `beta-release` | `https://api.convai.com` | `beta` |
199
- | `stg-release` | `https://api-stg.convai.com` | `stg` |
225
+ | `main` / `prod` | `https://api.convai.com` | `latest` |
226
+ | `beta` | `https://api.convai.com` | `beta` |
227
+ | `stg` | `https://api-stg.convai.com` | `stg` |
200
228
 
201
229
  Beta points at **production** — it is the first line of testing against real
202
- infrastructure, not a staging channel. Only `stg-release` targets staging,
203
- which has its own character database.
230
+ infrastructure, not a staging channel. Only `stg` targets staging, which has
231
+ its own character database. Character IDs belong to one environment: a staging
232
+ ID against production returns a not-found error that reads like a permissions
233
+ problem.
234
+
235
+ Promotion has a silent failure mode: merging `stg` into `main` carries the
236
+ staging `DEFAULT_BASE_URL` across with no conflict, because only `stg` ever
237
+ edits that line. `pnpm check:hosts` is what catches it.
204
238
 
205
239
  `scripts/check-hosts.mjs` enforces this. Each channel build asserts the policy
206
240
  for the release it claims to be, so a staging artifact cannot be cut from
@@ -217,7 +251,7 @@ pnpm check:hosts # auto-detects the current branch
217
251
 
218
252
  ```bash
219
253
  pnpm install
220
- pnpm test # 183 tests
254
+ pnpm test # vitest
221
255
  pnpm typecheck
222
256
  pnpm check:tests # every src/ module has a named test — see docs/testing-spec.md
223
257
  pnpm build # emits dist/ with .d.ts
@@ -9,6 +9,7 @@ import type { ConvaiClient } from '@convai/web-sdk/core';
9
9
  import { type UnitNormalizer } from '../rig/units.js';
10
10
  import { AnimationController } from '../clips/animations.js';
11
11
  import { LipsyncSystem, type MouthTuning } from '../speech/lipsync.js';
12
+ import type { CorrectiveMap } from '../speech/correctiveMap.js';
12
13
  import { type CharacterDecorator } from '../decorate/types.js';
13
14
  import type { BlendshapeQueue } from '@convai/web-sdk/core';
14
15
  import { Pipeline } from './pipeline.js';
@@ -87,6 +88,11 @@ export interface CreateCharacterOptions {
87
88
  targetHeight?: number;
88
89
  groundY?: number;
89
90
  mouth?: MouthTuning;
91
+ /**
92
+ * A corrective map fitted from this body's DNA (`compileCorrectiveMap`).
93
+ * Off by default. Swap it later through `handle.lipsync.correctiveMap`.
94
+ */
95
+ correctiveMap?: CorrectiveMap | null;
90
96
  /** Tier-2 customization: run once after load, before first frame. */
91
97
  decorators?: CharacterDecorator[];
92
98
  /** Extra local clips (name → url), merged OVER manifest clips by name. */
@@ -223,7 +223,7 @@ export async function createCharacter(opts) {
223
223
  const idle = pickIdleClip(animations.names, opts.idleClip);
224
224
  if (idle)
225
225
  animations.play(idle);
226
- const lipsync = new LipsyncSystem(opts.mouth);
226
+ const lipsync = new LipsyncSystem(opts.mouth, opts.correctiveMap ?? null);
227
227
  lipsync.bind(root);
228
228
  for (const d of opts.decorators ?? []) {
229
229
  d({ root, bundle, representation: rep, materials: collectMaterials(root) });
package/dist/index.d.ts CHANGED
@@ -28,6 +28,8 @@ export { AnimationController } from './clips/animations.js';
28
28
  export type { ClipTransition } from './clips/animations.js';
29
29
  export { LipsyncSystem } from './speech/lipsync.js';
30
30
  export type { MouthTuning } from './speech/lipsync.js';
31
+ export { compileCorrectiveMap, applyCorrectiveMap, CORRECTIVE_MAP_FORMAT } from './speech/correctiveMap.js';
32
+ export type { CorrectiveMap, CorrectiveMapTable } from './speech/correctiveMap.js';
31
33
  export { createUnitNormalizer } from './rig/units.js';
32
34
  export { collectMaterials } from './decorate/types.js';
33
35
  export type { CharacterDecorator, DecoratorContext } from './decorate/types.js';
package/dist/index.js CHANGED
@@ -25,6 +25,7 @@ export { httpResolver, pickRepresentation, DEFAULT_BASE_URL } from './resolve/re
25
25
  export { createCharacter } from './character/createCharacter.js';
26
26
  export { AnimationController } from './clips/animations.js';
27
27
  export { LipsyncSystem } from './speech/lipsync.js';
28
+ export { compileCorrectiveMap, applyCorrectiveMap, CORRECTIVE_MAP_FORMAT } from './speech/correctiveMap.js';
28
29
  export { createUnitNormalizer } from './rig/units.js';
29
30
  export { collectMaterials } from './decorate/types.js';
30
31
  export { Pipeline } from './character/pipeline.js';
@@ -44,6 +44,13 @@ export function useCharacter(opts) {
44
44
  if (character)
45
45
  character.camera = camera;
46
46
  }, [character, camera]);
47
+ // Live, unlike the props above: swapping the map must not reload the body,
48
+ // or an A/B on live speech would drop the turn it is comparing.
49
+ const correctiveMap = opts.correctiveMap ?? null;
50
+ useEffect(() => {
51
+ if (character)
52
+ character.lipsync.correctiveMap = correctiveMap;
53
+ }, [character, correctiveMap]);
47
54
  useFrame((_, delta) => character?.update(delta, queueRef.current));
48
55
  return {
49
56
  character,
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Corrective map — re-weights Convai's 251 face controls so that a body with
3
+ * one baked morph per control lands closer to what the full MetaHuman rig
4
+ * shows.
5
+ *
6
+ * RigLogic fires correctives (PSDs) when controls combine, and its face joints
7
+ * rotate, so the skin moves along arcs; a sum of straight-line morphs follows
8
+ * neither. A table fitted from the character's DNA (convai-embody-console's
9
+ * tools/corrective-mapping/fit_mapping.py) corrects each frame before the
10
+ * mouth layer sees it:
11
+ *
12
+ * w = x + clamp(A · φ(x), ±limit)
13
+ *
14
+ * φ(x) is one activation per corrective (Π clamp01(x_i), weighted and capped
15
+ * at 1, as RigLogic computes it) and, in version 2 tables, each x_i² and each
16
+ * x_i · jawOpen. The table holds no geometry: feature definitions and
17
+ * coefficients only. Format and maths match the console's
18
+ * corrective-mapping.mjs, so a table fitted there loads here unchanged.
19
+ */
20
+ export declare const CORRECTIVE_MAP_FORMAT = "convai-corrective-map";
21
+ /** One corrective's inputs (channel indices) and per-input weights. */
22
+ interface PsdDef {
23
+ inputs: number[];
24
+ weights: number[];
25
+ }
26
+ /** The JSON fit_mapping.py writes. */
27
+ export type CorrectiveMapTable = {
28
+ format: typeof CORRECTIVE_MAP_FORMAT;
29
+ version: 1;
30
+ channels: number;
31
+ /** One row per corrective: its definition and the channels it corrects. */
32
+ rows: (PsdDef & {
33
+ a: [number, number][];
34
+ })[];
35
+ } | {
36
+ format: typeof CORRECTIVE_MAP_FORMAT;
37
+ version: 2;
38
+ channels: number;
39
+ features: {
40
+ psd: PsdDef[];
41
+ squares?: boolean;
42
+ jaw?: number;
43
+ };
44
+ /** [feature index, [[channel, coefficient], …]] */
45
+ coefficients: [number, [number, number][]][];
46
+ limit?: number;
47
+ };
48
+ interface Column {
49
+ targets: Int16Array;
50
+ coeffs: Float32Array;
51
+ }
52
+ /** A validated table packed into typed arrays for the per-frame loop. */
53
+ export interface CorrectiveMap {
54
+ readonly channels: number;
55
+ readonly psd: {
56
+ inputs: Int16Array;
57
+ weights: Float32Array;
58
+ }[];
59
+ readonly squares: boolean;
60
+ /** jawOpen's channel index, or -1 when the table has no jaw features. */
61
+ readonly jaw: number;
62
+ readonly columns: (Column | null)[];
63
+ readonly limit: number;
64
+ /** Scratch for the per-frame delta; one map serves one frame at a time. */
65
+ readonly delta: Float32Array;
66
+ }
67
+ /** Validate a table and pack it. Throws on anything malformed, at load rather than mid-speech. */
68
+ export declare function compileCorrectiveMap(table: CorrectiveMapTable): CorrectiveMap;
69
+ /**
70
+ * Map one frame into `out` (allocated when omitted) and return it. `frame` is
71
+ * not modified. A frame of the wrong width is returned unmapped: a table
72
+ * fitted for another channel layout must not scribble over this one.
73
+ */
74
+ export declare function applyCorrectiveMap(frame: Float32Array, map: CorrectiveMap, out?: Float32Array): Float32Array;
75
+ export {};
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Corrective map — re-weights Convai's 251 face controls so that a body with
3
+ * one baked morph per control lands closer to what the full MetaHuman rig
4
+ * shows.
5
+ *
6
+ * RigLogic fires correctives (PSDs) when controls combine, and its face joints
7
+ * rotate, so the skin moves along arcs; a sum of straight-line morphs follows
8
+ * neither. A table fitted from the character's DNA (convai-embody-console's
9
+ * tools/corrective-mapping/fit_mapping.py) corrects each frame before the
10
+ * mouth layer sees it:
11
+ *
12
+ * w = x + clamp(A · φ(x), ±limit)
13
+ *
14
+ * φ(x) is one activation per corrective (Π clamp01(x_i), weighted and capped
15
+ * at 1, as RigLogic computes it) and, in version 2 tables, each x_i² and each
16
+ * x_i · jawOpen. The table holds no geometry: feature definitions and
17
+ * coefficients only. Format and maths match the console's
18
+ * corrective-mapping.mjs, so a table fitted there loads here unchanged.
19
+ */
20
+ export const CORRECTIVE_MAP_FORMAT = 'convai-corrective-map';
21
+ const clamp01 = (v) => (v > 0 ? (v < 1 ? v : 1) : 0);
22
+ function checkIndex(i, channels, what) {
23
+ if (!Number.isInteger(i) || i < 0 || i >= channels)
24
+ throw new Error(`${what} ${i} is outside 0..${channels - 1}.`);
25
+ }
26
+ function compilePsd(defs, channels) {
27
+ return defs.map((d) => {
28
+ d.inputs.forEach((i) => checkIndex(i, channels, 'A corrective input'));
29
+ return { inputs: Int16Array.from(d.inputs), weights: Float32Array.from(d.weights) };
30
+ });
31
+ }
32
+ function column(entries, channels) {
33
+ entries.forEach(([i]) => checkIndex(i, channels, 'A target channel'));
34
+ return { targets: Int16Array.from(entries.map(([i]) => i)), coeffs: Float32Array.from(entries.map(([, c]) => c)) };
35
+ }
36
+ /** Validate a table and pack it. Throws on anything malformed, at load rather than mid-speech. */
37
+ export function compileCorrectiveMap(table) {
38
+ if (!table || table.format !== CORRECTIVE_MAP_FORMAT)
39
+ throw new Error(`Not a ${CORRECTIVE_MAP_FORMAT} table.`);
40
+ const channels = table.channels;
41
+ const delta = new Float32Array(channels);
42
+ if (table.version === 1) {
43
+ const psd = compilePsd(table.rows, channels);
44
+ const columns = table.rows.map((row) => column(row.a, channels));
45
+ return { channels, psd, squares: false, jaw: -1, columns, limit: Infinity, delta };
46
+ }
47
+ if (table.version === 2) {
48
+ const f = table.features;
49
+ const psd = compilePsd(f.psd, channels);
50
+ const jaw = f.jaw ?? -1;
51
+ if (jaw >= 0)
52
+ checkIndex(jaw, channels, 'The jaw channel');
53
+ const size = psd.length + (f.squares ? channels : 0) + (jaw >= 0 ? channels : 0);
54
+ const columns = Array.from({ length: size }, () => null);
55
+ for (const [feature, entries] of table.coefficients) {
56
+ checkIndex(feature, size, 'Feature');
57
+ columns[feature] = column(entries, channels);
58
+ }
59
+ return { channels, psd, squares: Boolean(f.squares), jaw, columns, limit: table.limit ?? Infinity, delta };
60
+ }
61
+ throw new Error(`Unsupported ${CORRECTIVE_MAP_FORMAT} version ${table.version}.`);
62
+ }
63
+ function addColumn(delta, col, value) {
64
+ if (!col)
65
+ return;
66
+ for (let k = 0; k < col.targets.length; k++)
67
+ delta[col.targets[k]] += value * col.coeffs[k];
68
+ }
69
+ /**
70
+ * Map one frame into `out` (allocated when omitted) and return it. `frame` is
71
+ * not modified. A frame of the wrong width is returned unmapped: a table
72
+ * fitted for another channel layout must not scribble over this one.
73
+ */
74
+ export function applyCorrectiveMap(frame, map, out = new Float32Array(frame.length)) {
75
+ const n = map.channels;
76
+ if (frame.length !== n) {
77
+ out.set(frame);
78
+ return out;
79
+ }
80
+ const delta = map.delta;
81
+ delta.fill(0);
82
+ let f = 0;
83
+ for (const psd of map.psd) {
84
+ let prod = 1;
85
+ for (let k = 0; k < psd.inputs.length; k++) {
86
+ const v = frame[psd.inputs[k]];
87
+ if (!(v > 0)) {
88
+ prod = 0;
89
+ break;
90
+ }
91
+ prod *= v < 1 ? v : 1;
92
+ }
93
+ if (prod !== 0) {
94
+ let p = 0;
95
+ for (let k = 0; k < psd.weights.length; k++)
96
+ p += Math.min(1, psd.weights[k] * prod);
97
+ addColumn(delta, map.columns[f], p);
98
+ }
99
+ f++;
100
+ }
101
+ if (map.squares) {
102
+ for (let i = 0; i < n; i++) {
103
+ const v = clamp01(frame[i]);
104
+ if (v !== 0)
105
+ addColumn(delta, map.columns[f + i], v * v);
106
+ }
107
+ f += n;
108
+ }
109
+ if (map.jaw >= 0) {
110
+ const jaw = clamp01(frame[map.jaw]);
111
+ if (jaw !== 0) {
112
+ for (let i = 0; i < n; i++) {
113
+ const v = clamp01(frame[i]);
114
+ if (v !== 0)
115
+ addColumn(delta, map.columns[f + i], v * jaw);
116
+ }
117
+ }
118
+ }
119
+ const limit = map.limit;
120
+ for (let i = 0; i < n; i++) {
121
+ const d = delta[i] > limit ? limit : delta[i] < -limit ? -limit : delta[i];
122
+ out[i] = frame[i] + d;
123
+ }
124
+ return out;
125
+ }
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import type * as THREE from 'three';
17
17
  import type { BlendshapeQueue } from '@convai/web-sdk/core';
18
+ import { type CorrectiveMap } from './correctiveMap.js';
18
19
  /** Tier-1 customization: the mouth knobs, per character. Defaults = pagoda-act's tuned values. */
19
20
  export interface MouthTuning {
20
21
  /** Hard cap on jawOpen; peak excursions read exaggerated on camera. */
@@ -46,10 +47,18 @@ export declare class LipsyncSystem {
46
47
  energy: number;
47
48
  /** True while the bot owns the mouth; body layers read it. */
48
49
  speaking: boolean;
50
+ /**
51
+ * Corrective map for this body, or null to play the stream as sent. Applied
52
+ * to each frame as it comes off the queue, before the SDK's shaping and the
53
+ * mouth layer. Mutable so a viewer can A/B it on live speech.
54
+ */
55
+ correctiveMap: CorrectiveMap | null;
56
+ private mapped;
49
57
  private readonly ch;
50
- constructor(tuning?: MouthTuning);
58
+ constructor(tuning?: MouthTuning, correctiveMap?: CorrectiveMap | null);
51
59
  /** Bind every morph-bearing mesh under root. Returns matched channel count. */
52
60
  bind(root: THREE.Object3D): number;
61
+ private correct;
53
62
  /** Tick each frame. Pass the queue only if this character owns the turn. */
54
63
  update(delta: number, q: BlendshapeQueue | undefined): void;
55
64
  }
@@ -1,4 +1,5 @@
1
1
  import { createLipsyncProcessor, mhaChannelIndex, } from '@convai/web-sdk/lipsync-helpers';
2
+ import { applyCorrectiveMap } from './correctiveMap.js';
2
3
  const DEFAULTS = {
3
4
  jawCap: 0.55,
4
5
  sealCeiling: 0.5,
@@ -10,7 +11,7 @@ const DEFAULTS = {
10
11
  gainOverrides: { mouthLowerLipDepressL: 0.28, mouthLowerLipDepressR: 0.28 },
11
12
  };
12
13
  export class LipsyncSystem {
13
- constructor(tuning = {}) {
14
+ constructor(tuning = {}, correctiveMap = null) {
14
15
  this.bindings = [];
15
16
  this.accum = 0;
16
17
  this.lastFrame = null;
@@ -20,6 +21,8 @@ export class LipsyncSystem {
20
21
  this.energy = 0;
21
22
  /** True while the bot owns the mouth; body layers read it. */
22
23
  this.speaking = false;
24
+ this.mapped = null;
25
+ this.correctiveMap = correctiveMap;
23
26
  this.t = { ...DEFAULTS, ...tuning, gainOverrides: { ...DEFAULTS.gainOverrides, ...tuning.gainOverrides } };
24
27
  this.processor = createLipsyncProcessor('mha', {
25
28
  skipEyeChannels: true,
@@ -43,6 +46,14 @@ export class LipsyncSystem {
43
46
  this.bindings = this.processor.bindAll(root);
44
47
  return this.bindings.reduce((s, b) => s + b.matched, 0);
45
48
  }
49
+ correct(frame) {
50
+ const map = this.correctiveMap;
51
+ if (!map)
52
+ return frame;
53
+ if (this.mapped?.length !== frame.length)
54
+ this.mapped = new Float32Array(frame.length);
55
+ return applyCorrectiveMap(frame, map, this.mapped);
56
+ }
46
57
  /** Tick each frame. Pass the queue only if this character owns the turn. */
47
58
  update(delta, q) {
48
59
  if (!this.bindings.length)
@@ -74,7 +85,7 @@ export class LipsyncSystem {
74
85
  if (this.accum > 0.25)
75
86
  this.accum = 0.25; // a starved queue banks no debt
76
87
  if (fresh)
77
- this.lastFrame = fresh;
88
+ this.lastFrame = this.correct(fresh);
78
89
  }
79
90
  else {
80
91
  this.accum = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@convai/embody",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Give a Convai character a body: resolve 3D characters by characterId and drive them with lipsync and animation. Layered on @convai/web-sdk.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Convai <support@convai.com>",
@@ -99,7 +99,7 @@
99
99
  "typecheck": "tsc --noEmit",
100
100
  "test": "vitest run",
101
101
  "test:watch": "vitest",
102
- "verify": "pnpm check:hosts && pnpm check:tests && pnpm typecheck && pnpm test && pnpm build",
102
+ "verify": "pnpm check:hosts && pnpm check:tests && pnpm typecheck && pnpm test && pnpm test:scripts && pnpm build",
103
103
  "changeset": "changeset",
104
104
  "version": "changeset version",
105
105
  "release": "pnpm build:prod && changeset publish",
@@ -109,6 +109,7 @@
109
109
  "build:stg": "pnpm clean && BRANCH=stg node scripts/check-hosts.mjs && pnpm build",
110
110
  "release:stg": "pnpm build:stg && changeset publish --tag stg",
111
111
  "docs": "typedoc",
112
- "docs:watch": "typedoc --watch"
112
+ "docs:watch": "typedoc --watch",
113
+ "test:scripts": "node --test scripts/sync-docs.test.mjs"
113
114
  }
114
115
  }