Skip to content
Closed
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/audits/block-api-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
| tooltipcard | ✅ | ✅ | ➖ | ✅ | ➖ | ➖ |
| dbview | ✅ | ✅ | ➖ | ✅ | ➖ | ➖ |
| dbform | ✅ | ✅ | ➖ | ✅ | ➖ | ➖ |
| meeting | ✅ | ✅ | ➖ | ✅ | ➖ | ➖ |
| form | ✅ | ✅ | ➖ | ✅ | ➖ | ➖ |
| openbook.ledger/journal-entry | ✅ | ✅ | ➖ | ✅ | ➖ | ✅ |
| openbook.ledger/trial-balance | ✅ | ✅ | ➖ | ✅ | ➖ | ✅ |
Expand Down
44 changes: 44 additions & 0 deletions docs/meeting-block.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Meeting block — THE REPRESENTATION CONTRACT (MEET-4)

`meeting` is a `kit` block with `nature: 'container'`, no required parent, and
`kitValue: false`. It publishes no reactive value. Its `children` are ordinary
manual-note blocks (paragraphs, todos, headings, groups, etc.), not transcript
segments. No dedicated notes slot or child-only type is required.

All top-level props are optional. Missing status means `idle`; missing arrays
mean empty lists; missing summary/title mean empty text; missing startedAt means
not started. These are consumer defaults, not schema-inserted persisted values.
As with other block props, patches shallow-merge; `null` removes a top-level key.
Unknown top-level props remain allowed for forward compatibility.

| Prop | Stored shape and meaning |
| --- | --- |
| `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. |
| `summary` | Plain string; no rich-text runs or Markdown interpretation is required. |
| `startedAt` | Unix epoch milliseconds, a finite nonnegative number. |
| `title` | Optional plain string. |

All durations and offsets are finite nonnegative numbers in milliseconds;
fractional milliseconds are accepted. Every listed nested field is required
except `startedAtMs`; nested objects reject extra keys and null fields. Empty
arrays and empty transcript text are valid. Cross-field `endMs >= startMs` is
checked at runtime and described in the JSON schema (standard JSON Schema cannot
express a comparison to a sibling field). Asset existence, timeline sorting,
status transitions, and transcription size limits are producer responsibilities.

Audio chunks and transcript segments use structured props, following existing
kit option/rich-run arrays and the form's structured schema prop. They are
machine-produced snapshots, while manual notes need independently editable CRDT
children. Each array is one prop value: updates replace the whole array, with
no per-segment merge guarantee. Recording/transcription producers should batch
updates and serialize writes to avoid concurrent array replacement losing data.
This keeps the first representation small and consistent; large-transcript
storage migration, if needed later, must explicitly version this contract.

MEET-4 registers a minimal meeting shell through `registerArtifactKit` in both
editor and viewer hosts. It displays title, status, and saved note-block count;
notes remain stored but are not yet rendered/editable inside the shell. It has
no slash-menu entry or recording controls. MEET-5 replaces this renderer and
must render the existing child blocks rather than migrate them into props.
1 change: 1 addition & 0 deletions packages/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +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) |
| `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
16 changes: 16 additions & 0 deletions packages/mcp/scripts/blockTypes.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,22 @@ async function main(): Promise<void> {
check('an invalid structured option is refused with a typed error naming opts',
isError(badOpts) && /"opts"/.test(resultText(badOpts)) && /object/i.test(resultText(badOpts)));

const meetingProps = {status: 'done', audioChunks: [{assetId: 'audio-1', durationMs: 500, startedAtMs: 0}], transcript: [{startMs: 0, endMs: 500, text: 'Hello'}], summary: 'Agreed', startedAt: 1234, title: 'Review'};
const meetingPage = await seed.savePage({name: 'Meeting target', data: {editor: 'blocks', blockdoc: {blocks: [{id: 'meeting1', type: 'meeting', children: [{id: 'note1', type: 'paragraph', text: [{t: 'Notes'}]}]}]}, editorjs: {blocks: []}, values: [], names: []}});
const meetingUpdate = await mcp.client.callTool({name: 'update_block_props', arguments: {pageId: meetingPage.id, blockId: 'meeting1', props: meetingProps}});
check('meeting structured props update is accepted', !isError(meetingUpdate));
const meetingCreate = await mcp.client.callTool({name: 'append_blocks', arguments: {pageId: meetingPage.id, blocks: [{type: 'meeting', props: meetingProps, children: [{type: 'paragraph', text: 'Manual notes'}]}]}});
check('meeting creation with manual note children is accepted', !isError(meetingCreate));
for (const props of [{status: 'paused'}, {audioChunks: [{assetId: 'a', durationMs: -1}]}, {transcript: [{startMs: 2, endMs: 1, text: 'Reversed'}]}]) {
const badCreate = await mcp.client.callTool({name: 'append_blocks', arguments: {pageId: meetingPage.id, blocks: [{type: 'meeting', props}]}});
const badUpdate = await mcp.client.callTool({name: 'update_block_props', arguments: {pageId: meetingPage.id, blockId: 'meeting1', props}});
check(`meeting rejects invalid creation and update: ${JSON.stringify(props)}`, isError(badCreate) && isError(badUpdate));
}
const meetingListing = JSON.parse(resultText(await mcp.client.callTool({name: 'list_block_types', arguments: {types: ['meeting']}})));
assert.equal(meetingListing.blocks.length, 1);
assert.deepEqual(meetingListing.blocks[0].propsSchema.properties.transcript.items.required, ['startMs', 'endMs', 'text']);
check('meeting listing publishes container nature and structured propsSchema', meetingListing.blocks[0].nature === 'container');

console.log('\nAPI-2: plugin block types — rejected while uninstalled, accepted once installed');
const uninstalled = await mcp.client.callTool({
name: 'create_artifact_page',
Expand Down
11 changes: 6 additions & 5 deletions packages/mcp/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
findUnknownBlockType,
FORM_FIELD_KINDS,
invalidBlockProps,
invalidBlockTreeProps,
insertBlocks,
isHttpUrl,
KIT_VALUE_BLOCK_TYPES,
Expand Down Expand Up @@ -609,7 +610,7 @@ function nestedBlockSchema(typeDesc: string, propsDesc: string): z.ZodType<Neste
}

/**
* Validate a nested payload's STRUCTURE — the container parent/child contract
* Validate a nested payload's declared props and STRUCTURE — the container parent/child contract
* (a `children` array only on a container-nature type; a child-only type only
* under its matching container; every table square), plus the
* {@link MAX_BLOCK_DEPTH} / {@link MAX_BLOCK_NODES} caps. Returns an error
Expand All @@ -618,14 +619,14 @@ function nestedBlockSchema(typeDesc: string, propsDesc: string): z.ZodType<Neste
* payload is refused with a clear message instead of silently dropping to a
* wall of "Unsupported block" placeholders.
*
* The rules are the SDK block-type catalogue's (`blockTreeError`) — one source
* The rules are the SDK block-type catalogue's (`blockTreeError` and
* `invalidBlockTreeProps`) — one source
* shared with the in-app agent. Block TYPES are otherwise unvalidated here for
* `append_blocks` (the apply layer is type-agnostic so custom/plugin leaves
* keep working) — only the STRUCTURE the model would silently discard is
* rejected. `create_artifact_page` additionally gates types (see below).
* keep working). Declared prop values are validated at every depth. `create_artifact_page` additionally gates types (see below).
*/
const blockPayloadError = (blocks: NestedBlockInput[]): string | null =>
blockTreeError(blocks, {maxDepth: MAX_BLOCK_DEPTH, maxNodes: MAX_BLOCK_NODES});
blockTreeError(blocks, {maxDepth: MAX_BLOCK_DEPTH, maxNodes: MAX_BLOCK_NODES}) ?? invalidBlockTreeProps(blocks);

/**
* The write-tool kind an MCP mutation maps to (the same identifiers the in-app
Expand Down
46 changes: 46 additions & 0 deletions packages/sdk/src/blockCatalogue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
CONTAINER_BLOCK_TYPES,
findUnknownBlockType,
invalidBlockProps,
invalidBlockTreeProps,
isPluginBlockType,
KNOWN_BLOCK_TYPE_IDS,
MAX_BLOCK_DEPTH,
Expand Down Expand Up @@ -242,3 +243,48 @@ describe('generated tool text', () => {
for (const type of KNOWN_BLOCK_TYPE_IDS) expect(guidance).toContain(type);
});
});


describe('meeting representation contract (MEET-4)', () => {
it('is a kit container with typed structured props and no reactive value', () => {
expect(blockTypeInfo('meeting')).toMatchObject({category: 'kit', nature: 'container', kitValue: false});
expect(blockTreeError([{type: 'meeting', children: [{type: 'paragraph', text: 'Manual notes'}]}])).toBeNull();
for (const status of ['idle', 'recording', 'processing', 'done']) {
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'}],
})).toBeNull();
}
expect(invalidBlockProps('meeting', {})).toBeNull();
expect(invalidBlockProps('meeting', {status: null, audioChunks: null, transcript: null, summary: null, startedAt: null, title: null})).toBeNull();
});

it.each([
{status: 'paused'}, {startedAt: -1}, {startedAt: '2026-01-01'}, {summary: []}, {title: 1},
{audioChunks: ['asset']}, {audioChunks: [{assetId: 'a'}]}, {audioChunks: [{assetId: '', durationMs: 1}]},
{audioChunks: [{assetId: 'a', durationMs: -1}]}, {audioChunks: [{assetId: 'a', durationMs: 1, startedAtMs: -1}]},
{audioChunks: [{assetId: 'a', durationMs: Infinity}]}, {audioChunks: [{assetId: 'a', durationMs: 1, extra: true}]},
{transcript: [{startMs: 0, text: 'Missing end'}]}, {transcript: [{startMs: 2, endMs: 1, text: 'Reversed'}]},
{transcript: [{startMs: -1, endMs: 1, text: 'Negative'}]}, {transcript: [{startMs: 0, endMs: 1, text: 1}]},
])('rejects malformed props: %j', (props) => {
expect(invalidBlockProps('meeting', props)).toContain('Invalid prop');
});

it('publishes nested item schemas and required fields to agents', () => {
const {blocks} = JSON.parse(blockCatalogueText([], ['meeting']));
expect(blocks).toHaveLength(1);
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']);
});
});


describe('creation prop validation', () => {
it('validates nested blocks with the same schemas as prop updates', () => {
expect(invalidBlockTreeProps([{type: 'group', children: [{type: 'meeting', props: {status: 'paused'}}]}])).toContain('Invalid prop "status"');
expect(invalidBlockTreeProps([{type: 'heading', props: {level: 'two'}}])).toContain('Invalid prop "level"');
expect(invalidBlockTreeProps([{type: 'meeting', props: []}])).toContain('expected an object');
expect(invalidBlockTreeProps([{type: 'meeting'}, {type: 'plugin/widget', props: {anything: true}}, {type: 'meeting', props: {title: null, future: {x: 1}}}])).toBeNull();
});
});
39 changes: 32 additions & 7 deletions packages/sdk/src/blockCatalogue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export {BLOCK_PROP_JSON_SCHEMAS, BLOCK_PROP_SCHEMAS} from './blockPropSchemas';

/** How a block stores content: `container` blocks carry child blocks in
* `children`, `text` blocks carry rich text in `text`, `void` blocks carry
* only `props` (all kit widgets are void). */
* only `props`. Kit blocks may also be containers. */
export type BlockNature = 'container' | 'text' | 'void';

/** The value shapes per-type prop validation understands. Deliberately coarse
Expand Down Expand Up @@ -109,6 +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: '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 All @@ -134,9 +135,9 @@ export const KIT_VALUE_BLOCK_TYPES: ReadonlySet<string> = new Set(
BLOCK_TYPE_CATALOGUE.filter((entry) => entry.kitValue === true).map((entry) => entry.type),
);

/** Core types whose `children` hold ordinary blocks. */
export const CONTAINER_BLOCK_TYPES: ReadonlySet<CoreBlockType> = new Set(
BLOCK_TYPE_CATALOGUE.filter((e) => e.nature === 'container').map((e) => e.type as CoreBlockType),
/** Catalogued types whose `children` hold ordinary blocks (including kit containers). */
export const CONTAINER_BLOCK_TYPES: ReadonlySet<string> = new Set(
BLOCK_TYPE_CATALOGUE.filter((e) => e.nature === 'container').map((e) => e.type),
);

/** Core types that carry editable rich text. */
Expand Down Expand Up @@ -284,7 +285,7 @@ export function blockTreeError(
structural = parentType
? `A "${type}" block must be a direct child of a "${needs}" block, not a "${parentType}" block (at "${here}").`
: `A "${type}" block can't be top-level — it belongs directly inside a "${needs}" block (at "${here}").`;
} else if (hasChildren && !CONTAINER_BLOCK_TYPES.has(type as CoreBlockType)) {
} else if (hasChildren && !CONTAINER_BLOCK_TYPES.has(type)) {
structural = `A "${type}" block can't hold children — only container blocks (${[...CONTAINER_BLOCK_TYPES].join(', ')}) do (at "${here}"). The nested blocks would be dropped.`;
} else if (type === 'table' && hasChildren) {
structural = raggedTableError(children, here);
Expand Down Expand Up @@ -339,6 +340,30 @@ export function invalidBlockProps(type: string, props: Record<string, unknown>):
return `Invalid prop "${prop}" of a "${type}" block: ${issue.message} (got ${clipJson(props[prop])}).`;
}

/** Validate declared props throughout a creation payload. Call blockTreeError
* first to enforce structure/size limits. Plugin and unknown props retain the
* same permissive behavior as update_block_props. */
export function invalidBlockTreeProps(blocks: readonly unknown[], depth = 1): string | null {
if (depth > TYPE_WALK_MAX_DEPTH) return null; // structure validation owns depth errors
for (const raw of blocks) {
if (!raw || typeof raw !== 'object') continue;
const block = raw as {type?: unknown; props?: unknown; children?: unknown};
const type = String(block.type ?? '');
if (block.props !== undefined) {
if (!block.props || typeof block.props !== 'object' || Array.isArray(block.props)) {
return `Invalid props of a "${type}" block: expected an object.`;
}
const error = invalidBlockProps(type, block.props as Record<string, unknown>);
if (error) return error;
}
if (Array.isArray(block.children)) {
const error = invalidBlockTreeProps(block.children, depth + 1);
if (error) return error;
}
}
return null;
}

const clipJson = (v: unknown): string => {
const s = JSON.stringify(v) ?? String(v);
return s.length > 40 ? `${s.slice(0, 40)}…` : s;
Expand Down Expand Up @@ -388,10 +413,10 @@ export function addBlocksGuidance(): string {
'Append rich blocks to a page — text, layouts, tables, media, interactive inputs, and charts. User approves before they are added.',
'Each block is {type, text?, props?, children?}. `text` is a plain string (or rich runs [{"t","a":{b,i,u,s,c,a}}]); `children` nests blocks inside containers. Call list_block_types for the full catalogue including installed plugin blocks.',
`TEXT: ${core.filter((e) => e.nature === 'text' && !e.parent).map(hinted).join('; ')}.`,
`CONTAINERS (use children): ${core.filter((e) => e.nature === 'container' || e.parent).map(hinted).join('; ')}. Give every table row the same number of cells.`,
`CONTAINERS (use children): ${BLOCK_TYPE_CATALOGUE.filter((e) => e.nature === 'container' || e.parent).map(hinted).join('; ')}. Give every table row the same number of cells.`,
`MEDIA/OTHER: ${core.filter((e) => e.nature === 'void').map(hinted).join('; ')}.`,
`INPUTS (each publishes props.name into the reactive scope): ${kit.filter((e) => e.kitValue).map(hinted).join('; ')}.`,
`REACTIVE DISPLAY/ACTIONS (props.source is a JS expression over input names): ${kit.filter((e) => !e.kitValue).map(hinted).join('; ')}.`,
`REACTIVE DISPLAY/ACTIONS (props.source is a JS expression over input names): ${kit.filter((e) => !e.kitValue && e.nature !== 'container').map(hinted).join('; ')}.`,
'Example: a budget widget → [{"type":"heading","text":"Budget","props":{"level":2}},{"type":"columns","children":[{"type":"column","props":{"span":5},"children":[{"type":"slider","props":{"name":"spent","label":"Spent","value":80,"min":0,"max":200}},{"type":"number","props":{"name":"budget","label":"Budget","value":120}}]},{"type":"column","props":{"span":7},"children":[{"type":"kitchart","props":{"kind":"bar","title":"Spent vs budget","labels":"Spent, Budget","source":"[spent, budget]"}},{"type":"statuslight","props":{"label":"On track","source":"budget - spent","okAt":0,"warnAt":-20}}]}]}].',
].join('\n');
}
Loading
Loading