Derive methods and the Derivation index
Transcription, text extraction (PDF text, OCR, image description), summary, AI fill, AI derivation into another Block type and bibliography fetch have one thing in common: they make something from a Block. Each is one derive method. A Feature contributes its methods as deriveMethods, and the preset merges all of them into one registry. The derive-method contract lists the fields.
A Block that was made this way is a Derived block. It records in meta.derivation what it was made from (its Derivation). The original keeps nothing, so the link cannot disagree with the document after a move, a delete or a merge.
Register a derive method
A derive method declares the capabilities the original must have (requires), the ability (ability) and, where the capabilities cannot say it, available(ctx): the backend flag ctx.ctx.backend.ai, the public AI flags, a readable source. run writes the result. A method with sub-choices (derive into one of several Block types) gives menu instead of run.
The example adds „Kernaussagen ableiten“. It is offered on every Container block with a title when the backend has AI, and it inserts a Typed block of the Block type note that records its Derivation:
import type { Derivation, EditorFeature } from '@skaile/forge-editor/core'
import { derivationState, derivedOf, newTypedBlockJSON } from '@skaile/forge-editor/core'
import { ABILITY, blockIdOf } from '@skaile/forge-editor/core/schema'
import type { Node as PMNode } from '@tiptap/pm/model'
// One registration: the block action, its gate (`requires`, `ability`, `available`) and the work.
export function keyPointsFeature(): EditorFeature {
return {
id: 'key-points',
dimension: 'ai',
requires: ['composites'],
deriveMethods: [{
id: 'key-points',
label: () => 'Kernaussagen ableiten',
icon: 'i-lucide-list-checks',
ability: ABILITY.compositeDerive,
requires: ['container', 'title'],
available: ({ ctx }) => !!ctx.backend.ai,
run: ({ editor, node, pos }) => {
const block = blockIdOf(node)
if (!block) return
const derivation: Derivation = { from: { block }, method: 'key-points', createdAt: new Date().toISOString(), inputHash: '', outputHash: '' }
const result = newTypedBlockJSON('note', [{ type: 'paragraph', content: [{ type: 'text', text: 'Kernaussagen ...' }] }])
// The Derived block records where it came from; the original keeps nothing.
editor.commands.insertContentAt(pos + node.nodeSize, { ...result, attrs: { ...result.attrs, meta: { derivation } } })
},
}],
}
}
/** The Derived blocks made from the original at `originalPos`, computed from block IDs and asset names. */
export const derivedBlocksOf = (doc: PMNode, originalPos: number, documentId: string) =>
derivedOf(doc, originalPos, documentId).map(entry => ({ pos: entry.pos, method: entry.derivation.method }))
/** True when the original of the Derived block at `derivedPos` was deleted. */
export const isSourceMissing = (doc: PMNode, derivedPos: number, documentId: string) =>
derivationState(doc, derivedPos, () => '', documentId)?.source === 'missing'
defineEditor merges the methods of all Features in feature order into preset.deriveMethods and throws for a duplicate id, a method with neither or both of run and menu, and a requires entry that no Feature declares (the built-in capabilities count).
The menu of the user does not change. blockActionsOf turns every method into a Block action with the same id (deriveActionsOf), so the overlay filter applies requires, the ability and available like for every other action. deriveMethodsAt(editor, ctx, pos) lists the methods that can run on one Block.
The built-in composite-ai integration registers its six methods this way:
| Method id | requires |
|---|---|
composite-fill | ai-brief |
composite-summarize, composite-derive | container, title |
bibliography-fetch | bibliography |
rendition-create | source |
rendition-regenerate | derived |
The Derivation
Derivation is the record in meta.derivation: from (an asset by asset name, or a Block by block ID, with document when the Block lives in another Document), method (a DerivationMethod such as 'transcription', 'ocr', 'description' or any string a host uses), model, createdAt, inputHash, outputHash and output. The derived capability, which the composites feature declares, applies to every Block that has the record (perNode), and derivationOf(node) reads it.
A Block made from several originals (a mixdown, a summary of several Blocks) names them in inputs, a record of { from, hash } by input name. Then from is the primary input and inputHash holds derivationFingerprint({ method, inputs }): the SHA-256 of the method and the hash of every input, by input name, so the order the inputs were written in does not change it. activity ({ id, agent?, at? }) records the run that produced the Block, for example one AI request and the model that answered; the AI rendition and „Ableiten (KI)“ write it.
Find the Derived blocks of an original
derivationIndexOf(doc) computes the reverse index from block IDs and asset names. It is memoised per doc node, so it is recomputed for every new document version, and it cannot disagree with the document after a move or delete (Yjs merges included). The functions on top of it:
derivedOf(doc, originalPos, documentId?)lists the Derived blocks of the original atoriginalPos, in document order. A Derived block that was moved out of its original still counts. Derivations from another Document are not listed.derivationOriginOf(doc, derivedPos, documentId?)finds the original and answersfound(with node and position),missingorforeign.derivationState(doc, derivedPos, bodyHash, documentId?, foreignHash?, inputHashOf?)addsstale(an original changed since) andedited(the body changed since). For a Derivation withinputs,inputHashOf(from)returns the live hash of one input (null= unknown):staleistruewhen any input differs,falsewhen all match andnullwithoutinputHashOfor while an input is unknown.derivedStateOf(state)folds aDerivationStateinto oneDerivedStateId:missing,stale,edited,foreignorfresh, the first that applies.
State badge
The composites chrome, the Document tree and the row badge use that one state. derivedChromeOf(editor, row) returns { state }. The composites feature registers derivedRowBadge under rowBadges: a Derived block whose original changed shows „veraltet“ (English: "outdated"), one whose original is gone shows „Quelle fehlt“. A tree row of a Derived block that is stale, missing or edited shows a dot with the reason as its label (OutlineNode.derived, TreeItem.derived). The labels are the keys composites.derivation.state.<fresh|stale|edited|missing|foreign>.
Source missing
When the original is deleted, the Derived block stays. derivationState(...).source is 'missing', and the composite chrome, the details card, the Block inspector and the outline of the Document tree show „Quelle fehlt“ (English: "Source missing"). A Derivation from another Document is foreign and never missing, because listing those needs a query on the documents port. The last two exports of the example read both states:
derivedBlocksOf(doc, originalPos, documentId) // [{ pos, method }, ...]
isSourceMissing(doc, derivedPos, documentId) // true after the original was deleted
derivationIndexOf, derivedOf, derivationOriginOf, derivationState, derivedStateOf, derivationFingerprint, derivationOf, derivedChildrenOf and DERIVATION_KEY are exported from @skaile/forge-editor/core, and only from there: /composites and /composites/schema do not re-export them.
Block types in code and JSON
Define a Block type as data in code or in a JSON file, check it against the capability registry, and give the server the same list.
Block properties and Property definitions
Define Block properties with Property definitions, give them to a Block type with the properties capability and read and write them.