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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/meeting-block.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Unknown top-level props remain allowed for forward compatibility.
| `status` | `'idle' \| 'recording' \| 'processing' \| 'done'`. Validation checks the enum, not state transitions. |
| `audioChunks` | Ordered array of `{assetId: string, durationMs: number, startedAtMs?: number}`. `assetId` is a nonempty asset reference (max 512 characters), never inline audio. `durationMs` is the chunk duration; optional `startedAtMs` is an offset from meeting start, **not** Unix time. Array order is capture/playback order. |
| `transcript` | Ordered array of `{startMs: number, endMs: number, text: string}`. Offsets are relative to meeting start; `endMs >= startMs`. Text is plain text. Array order is display order; overlapping segments are allowed. |
| `transcriptionCompleted` | Optional `string[]` of successfully transcribed chunk keys, each `startedAtMs:assetId` (a nonnegative number, a colon, then a nonempty asset ID). The recorder’s `chunkKey` uses `0` when `startedAtMs` is absent. Used to skip already transcribed chunks on retry, including chunks with empty transcription results. Missing means an empty list; `null` removes the prop. Existing keys are preserved unchanged; duplicates and non-UUID asset IDs are allowed. Updates replace the whole array. |
| `summary` | Plain string; no rich-text runs or Markdown interpretation is required. |
| `startedAt` | Unix epoch milliseconds, a finite nonnegative number. |
| `title` | Optional plain string. |
Expand Down
2 changes: 1 addition & 1 deletion packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ Call `list_block_types` for the machine-readable source of truth: every entry ha
| `tooltipcard` | `term:string`, `tip:string` |
| `dbview` | `pageId:string` |
| `dbform` | `databaseId:string`, `viewId:string` |
| `meeting` | `status:"idle"/"recording"/"processing"/"done"`, `audioChunks:[{assetId:string,durationMs:number,startedAtMs?:number}]`, `transcript:[{startMs:number,endMs:number,text:string}]`, `summary:string`, `startedAt:number`, `title:string`; container children are manual notes; [representation contract](../../docs/meeting-block.md) |
| `meeting` | `status:"idle"/"recording"/"processing"/"done"`, `audioChunks:[{assetId:string,durationMs:number,startedAtMs?:number}]`, `transcript:[{startMs:number,endMs:number,text:string}]`, `transcriptionCompleted?:string[]` (completed chunk keys: a nonnegative number, a colon, then a nonempty asset ID; absent means empty, `null` removes), `summary:string`, `startedAt:number`, `title:string`; container children are manual notes; [representation contract](../../docs/meeting-block.md) |
| `form` | `formId:string`, `submissionKey:string`, `enabled:boolean`, `databaseId:string`, `schema:object`, `label:string`, `description:string` |

Shared input/frame props are `name`, `label`, and `description` strings plus `compact` and `interactive` booleans. A structured option's `value` defaults to a slug of its `label`; `selected` contains those string values.
Expand Down
21 changes: 20 additions & 1 deletion packages/sdk/src/blockCatalogue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,10 +253,11 @@ describe('meeting representation contract (MEET-4)', () => {
expect(invalidBlockProps('meeting', {status, startedAt: 1234, title: 'Review', summary: 'Agreed.',
audioChunks: [{assetId: 'audio-1', durationMs: 500, startedAtMs: 0}, {assetId: 'audio-2', durationMs: 250}],
transcript: [{startMs: 0, endMs: 500, text: 'Hello'}],
transcriptionCompleted: ['0:audio-1', '500:audio-2'],
})).toBeNull();
}
expect(invalidBlockProps('meeting', {})).toBeNull();
expect(invalidBlockProps('meeting', {status: null, audioChunks: null, transcript: null, summary: null, startedAt: null, title: null})).toBeNull();
expect(invalidBlockProps('meeting', {status: null, audioChunks: null, transcript: null, transcriptionCompleted: null, summary: null, startedAt: null, title: null})).toBeNull();
});

it.each([
Expand All @@ -270,9 +271,27 @@ describe('meeting representation contract (MEET-4)', () => {
expect(invalidBlockProps('meeting', props)).toContain('Invalid prop');
});

it.each([[], ['0:legacy-asset'], ['1.5:asset'], ['1e-7:asset', '1e+21:asset', '1.5e21:asset'], ['001:asset:with:colons', '001:asset:with:colons']].map((keys) => ({keys})))(
'accepts optional completion keys without rewriting legacy values: %j', ({keys}) => {
expect(invalidBlockProps('meeting', {transcriptionCompleted: keys})).toBeNull();
},
);

it.each(['0:asset', {}, [1], [null], [''], ['asset'], [':asset'], ['-1:asset'], ['1:'], ['x:asset']].map((value) => ({value})))(
'rejects malformed completion keys: %j', ({value}) => {
expect(invalidBlockProps('meeting', {transcriptionCompleted: value})).toContain('Invalid prop "transcriptionCompleted"');
},
);

it('publishes nested item schemas and required fields to agents', () => {
const {blocks} = JSON.parse(blockCatalogueText([], ['meeting']));
expect(blocks).toHaveLength(1);
expect(blockTypeInfo('meeting')?.props?.transcriptionCompleted).toBe('array');
expect(blocks[0].description).toContain('transcriptionCompleted');
expect(blocks[0].propsSchema.properties.transcriptionCompleted).toMatchObject({
type: 'array', nullable: true, items: {type: 'string', pattern: '^\\d+(\\.\\d+)?(e[+-]?\\d+)?:.+$'},
});
expect(blocks[0].propsSchema.required).toBeUndefined();
expect(blocks[0].propsSchema.properties.status.enum).toEqual(['idle', 'recording', 'processing', 'done']);
expect(blocks[0].propsSchema.properties.audioChunks.items.required).toEqual(['assetId', 'durationMs']);
expect(blocks[0].propsSchema.properties.transcript.items.required).toEqual(['startMs', 'endMs', 'text']);
Expand Down
2 changes: 1 addition & 1 deletion packages/sdk/src/blockCatalogue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ const CATALOGUE_LITERAL = [
{type: 'tooltipcard', category: 'kit', nature: 'void', props: {term: 'string', tip: 'string'}, hint: '{term,tip}'},
{type: 'dbview', category: 'kit', nature: 'void', props: {pageId: 'string'}, hint: 'embedded live database view {pageId} — the page hosting the database'},
{type: 'dbform', category: 'kit', nature: 'void', props: {databaseId: 'string', viewId: 'string'}, hint: 'embedded database form {databaseId,viewId} — a live reference, never a copied schema or capability'},
{type: 'meeting', category: 'kit', nature: 'container', kitValue: false, props: {status: 'string', audioChunks: 'array', transcript: 'array', summary: 'string', startedAt: 'number', title: 'string'}, hint: 'meeting recording {status?,audioChunks?:[{assetId,durationMs,startedAtMs?}],transcript?:[{startMs,endMs,text}],summary?,startedAt?,title?}; times in ms, startedAt is Unix epoch; children hold manual notes; see docs/meeting-block.md'},
{type: 'meeting', category: 'kit', nature: 'container', kitValue: false, props: {status: 'string', audioChunks: 'array', transcript: 'array', transcriptionCompleted: 'array', summary: 'string', startedAt: 'number', title: 'string'}, hint: 'meeting recording {status?,audioChunks?:[{assetId,durationMs,startedAtMs?}],transcript?:[{startMs,endMs,text}],transcriptionCompleted?:["startedAtMs:assetId"],summary?,startedAt?,title?}; times in ms, startedAt is Unix epoch; children hold manual notes; see docs/meeting-block.md'},
{type: 'form', category: 'kit', nature: 'void', kitValue: false, props: {formId: 'string', submissionKey: 'string', enabled: 'boolean', databaseId: 'string', schema: 'object', label: 'string', description: 'string'}, hint: 'public form definition {formId,submissionKey,enabled,databaseId?,schema}'},
] as const satisfies readonly BlockTypeInfo[];

Expand Down
5 changes: 5 additions & 0 deletions packages/sdk/src/blockPropSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ const audioChunk = object({
durationMs: number('Chunk duration in milliseconds.', 0),
startedAtMs: number('Offset from the meeting start in milliseconds.', 0),
}, 'One audio asset in capture order.', ['assetId', 'durationMs']);
const transcriptionChunkKey: Field = {
schema: z.string().regex(/^\d+(\.\d+)?(e[+-]?\d+)?:.+$/, 'must be a startedAtMs:assetId chunk key'),
json: {type: 'string', pattern: '^\\d+(\\.\\d+)?(e[+-]?\\d+)?:.+$', description: 'Chunk offset in milliseconds followed by a colon and a nonempty asset ID.'},
};
const transcriptSegment = object({
startMs: number('Inclusive offset from the meeting start in milliseconds.', 0),
endMs: number('Exclusive offset from the meeting start in milliseconds; must be >= startMs.', 0),
Expand Down Expand Up @@ -91,6 +95,7 @@ const fields = {
status: enumeration(['idle', 'recording', 'processing', 'done'], 'Absent means idle.'),
audioChunks: array(audioChunk, 'Audio chunks in capture order; replace the entire array when updating.'),
transcript: array(transcriptSegment, 'Plain-text segments in display order; replace the entire array when updating.'),
transcriptionCompleted: array(transcriptionChunkKey, 'Successfully transcribed chunk keys (startedAtMs:assetId); absent means empty. Replace the entire array when updating.'),
summary: string('Plain-text summary.'),
startedAt: number('Meeting start as Unix epoch milliseconds.', 0),
title: text,
Expand Down
13 changes: 12 additions & 1 deletion packages/ui/src/blockeditor/__tests__/meetingRecorder.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as Y from 'yjs';
import {setLocale, t} from '@/i18n';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, fireEvent, render, screen} from '@testing-library/react';
import type {AiTranscriptionResult} from '@book.dev/sdk';
import {invalidBlockProps, type AiTranscriptionResult} from '@book.dev/sdk';
import {setAssetBridge} from '@/lib/assetBridge';
import {registerBlockEditorDoc} from '@/lib/aiBridge';
import {AssetBridgeHost} from '@/components/AssetBridgeHost';
Expand Down Expand Up @@ -84,10 +84,21 @@ describe('meeting recording and recovery', () => {
expect(blockProp(block, 'transcript')).toEqual([
{startMs: 250, endMs: 1500, text: 'hello'}, {startMs: 45250, endMs: 46500, text: 'hello'}, {startMs: 47250, endMs: 48500, text: 'hello'},
]);
expect(blockProp(block, 'transcriptionCompleted')).toEqual(['0:asset-2', '45000:asset-2', '47000:asset-3']);
expect(invalidBlockProps('meeting', {transcriptionCompleted: blockProp(block, 'transcriptionCompleted')})).toBeNull();
expect(docToJSON(decodeSnapshot(encodeSnapshot(doc)))).toEqual(docToJSON(doc));
expect(track.stop).toHaveBeenCalled();
});

it.each([1.5, 1e-7, 1e21])('persists schema-valid completion keys when retrying a chunk starting at %s ms', async (startedAtMs) => {
const audio = {assetId: 'saved-audio', durationMs: 1000, startedAtMs};
const {session, block} = harness({status: 'done', audioChunks: [audio]});
session.retry(audio); await flush();
const completed = blockProp(block, 'transcriptionCompleted');
expect(completed).toEqual([chunkKey(audio)]);
expect(invalidBlockProps('meeting', {transcriptionCompleted: completed})).toBeNull();
});

it('keeps audio on upload failure, retries with exponential backoff, then permits manual recovery', async () => {
putAsset.mockRejectedValue(new Error('offline'));
const {session, block} = harness();
Expand Down
Loading