@miadi/ncp-story-studio
v0.8.0
Published
Narrative Context Protocol story studio as consumable modules: canonical NCP types and a zero-dependency validator, a headless story-lattice layout with a React renderer, story-project storage providers (JSON files and Neon Postgres), the agent link proto
Readme
@miadi/ncp-story-studio
The Narrative Context Protocol story studio, as consumable modules: canonical NCP types and a zero-dependency validator, a reader that accepts the NCP dialects that actually exist, a headless story-lattice layout with a React renderer, story-project storage over JSON files or Neon Postgres, the agent link protocol, and the canonical terminology as data.
Phase plan, sources of truth, and what is deliberately not built yet: PHASES.md.
Install
pnpm add @miadi/ncp-story-studio
# in this workspace
pnpm -w add @miadi/ncp-story-studio@workspace:* --filter <consumer>react and @neondatabase/serverless are optional peer dependencies: they are needed only by
/graph/react and /store/neon.
Modules
| Subpath | Needs | What it is |
|---|---|---|
| @miadi/ncp-story-studio | — | Package identity plus the schema surface |
| .../schema | — | Types, enums, validator, dialect reader, completeness |
| .../graph | — | Story lattice geometry, tone palette, SVG output |
| .../graph/react | react | <StoryGraph> — a client component |
| .../store | — | Story projects, pure edits, the provider contract |
| .../store/json | node | JSON files, one folder per project |
| .../store/neon | node, @neondatabase/serverless | Postgres, one row per project |
| .../agents | — | Personas, lattice context, the [[Beat:id\|label]] link protocol |
| .../terminology | — | The canonical NCP vocabulary as data |
| .../fixtures | node | Nine bundled stories: six from the studio's own prototype, three canonical NCP examples |
Schema
import { readNcp, validateNcpDocument, storyCompleteness } from '@miadi/ncp-story-studio/schema'
const { document, dialect, notes } = readNcp(JSON.parse(raw))
const result = validateNcpDocument(document)
result.valid // no issue of severity `error`
result.issues // { path, code, severity, message } — evidence, not a score
result.extensions_used// which declared extensions this document relies on
storyCompleteness(document.story).overall // 0..1, measured against named targetsThree dialects, one reader
NCP files in circulation are not all shaped the same way, and none of the differences is a defect —
they are what different tools wrote. readNcp recognizes each and reports every adaptation it makes.
| Dialect | Shape | Example |
|---|---|---|
| canonical | { schema_version, story { narratives[…] } } | example-story.json |
| subtxt-export | narratives beside story, perspectives keyed by point of view, context strings, Dramatica display labels, placeholder ids | the-shawshank-redemption.json, anora.json |
| miadi-design | NCP container names with a design vocabulary (label, description, event, structural_function) | weaver_of_words__the_catalyst_of_change_251101.json |
Display labels ("Story Goal", "Success", "Main Character Resolve") are mapped to the canonical
identifiers through the protocol's own terminology data; whatever cannot be mapped is declared in a
custom_* field rather than silently dropped.
Declared extensions
Fields this workspace adds beyond the canonical schema are declared, not drifted:
perspective.throughline, player.motivations, storypoint.custom_appreciation,
storypoint.custom_narrative_function, storybeat.custom_narrative_function,
dynamic.custom_dynamic, dynamic.custom_vector. validateNcpDocument reports each one in use, and
{ allowExtensions: false } turns their use into an error.
Two strictness levels
An issue that is about particular elements names them in elementIds — two beats sharing a
sequence are both named, though the issue's path points at the collection — so a surface can take
the reader to them.
By default the validator checks what a studio needs: identity, referential integrity, enum
membership, and per-series sequence uniqueness. { requireCanonicalFields: true } adds the canonical
schema's own required lists — which name every field, including visual, audio, illustration
and tones. None of the shipped canonical examples satisfies that, which is why it is opt-in.
Graph
import { layoutNarrative, layoutToSvg } from '@miadi/ncp-story-studio/graph'
const layout = layoutNarrative(narrative, { beatSpacing: 320 })
const svg = layoutToSvg(layout) // server-rendered, no DOMimport { StoryGraph } from '@miadi/ncp-story-studio/graph/react'
<StoryGraph narrative={narrative} focusedBeatId={id} onBeatClick={setId} />The line is navigable: drag the empty space to pan, arrow keys scroll it (shift for a longer
step, Home and End for the ends), and a press that starts on a beat still opens that beat.
cards={false} gives a compact strip — still a caption per beat, with its narrative function, its
summary and its three tone letters, so a long story stays readable at a glance.
On a phone
import { BeatScrubber, StoryGraph } from '@miadi/ncp-story-studio/graph/react'
<BeatScrubber narrative={narrative} currentBeatId={position} onScrub={goToBeat} />
<StoryGraph
narrative={narrative}
focusedBeatId={focused}
snap={isTouch} // one beat comes to rest at the centre, like a carousel
onCenteredBeatChange={setPosition} // the beat a swipe settled on
/>- The graph follows its focus. When
focusedBeatIdchanges the line scrolls that beat to the centre (followFocus={false}turns it off), so a link to beat 40 lands on beat 40. snapmakes a swipe settle with one beat centred. A mouse drag suspends it while held.onCenteredBeatChangereports the beat a reader's own scroll settled on. The scroll the graph makes itself to follow a focus is not reported, so a host can feed it straight back intofocusedBeatIdwithout the two chasing each other.- A vertical swipe on the line scrolls the page. Touch is left to the browser's native pan; only a mouse drag is captured.
centerBeatIdkeeps a beat at the centre apart from the focus ring — pass where the reader is (a scrubber position, a list scrolled by hand) and keepfocusedBeatIdfor the beat they chose. The line is put back on that beat when its layout changes (cards ↔ compact).- A tap on a card opens its beat, as the ring always did; a mouse drag that starts on a card still pans.
<BeatScrubber>is one tick per beat, coloured by its abstraction tone and sized by its appreciation (signposts tallest). A thumb runs along it;onScrubfires at most once a frame as beats are crossed, and once more with the beat the thumb lifted on, thenonScrubEnd. Holding the thumb still for 300ms switches to fine mode, 12px a beat. The counter under it takes a number. It is arole="slider"with a visible focus ring — arrow keys step, Page Up/Down step ten, Home and End go to the ends.- Only the beats around the view are drawn — a dozen, however long the story — so a phone holds a ninety-beat line without running out of memory. The canvas keeps its full width.
- The pure geometry behind both is exported from
/graphfor any other surface:nearestNodeIndex(layout, x),scrollLeftForNode(layout, index, viewportWidth),scrubIndex(count, fraction),visibleNodeRange(layout, scrollLeft, viewportWidth, pad).
Cards clamp their summary and illustration to three lines, so the canvas is 480px tall rather than 720 — the full telling is one tap away, and a phone shows the line and its tones on one screen.
Store
import { JsonStoryStore } from '@miadi/ncp-story-studio/store/json'
import { NeonStoryStore } from '@miadi/ncp-story-studio/store/neon'
const store = new JsonStoryStore({ root: '/srv/miadi/ncp-story-projects' })
await store.init()
const project = await store.get('catalyst-of-change')
await store.save({ ...project, document }, { expected_updated_at: project.updated_at })expected_updated_at makes a write conditional. On Neon the guard is part of the statement, so two
concurrent writers cannot both succeed. On the filesystem it is enforced within one process and is
best-effort across processes — a filesystem has no atomic compare-and-set.
The JSON provider keeps the document as a plain NCP file (story.ncp.json) beside its metadata
(project.json), so exporting a project is copying it.
Story edits (updateStorybeat, reorderStorybeats, upsertPlayer, …) are pure: they return a new
story and never mutate the one they are given, so undo is keeping the previous value.
Agents
import { runAgentTurn, parseAgentMessage } from '@miadi/ncp-story-studio/agents'
const response = await runAgentTurn(model, { story, message })
response.segments // text and links, ready to render
response.links // every link with whether it resolves in this story
response.provenance// which model answered, and how the story was carriedAgentModel is an interface with one method, so the package depends on no vendor SDK. Personas:
🧠 Mia reads structure, 🌸 Miette reads meaning, 🎨 Ava8 reads tone and musical shape.
The registry is open. A story can bring reading positions of its own — pass them as
extraPersonas, or keep them on the project and read them back with readPersonas(project.meta.personas).
Four ship with the stories that had them in the prototype: 🌊 Tushell and 🦉 Wise Owl with Tushell
and the Orchestra of Agents, 🌀 Miawapaskone and 🪶 Grandmother Yazhi with the Keeper Chronicles.
The bundled stories
/fixtures ships nine documents in two families.
miadi — the six teaching narratives the prototype was built around, each with every beat
toned: The Catalyst of Change (the seed: a story about learning to write with soul, inside a tool
for writing with soul), Echoes of the Past (the smallest complete lattice, the one to read first),
Tushell and the Orchestra of Agents, The Keeper Chronicles: The Initiation, The Bridge Walker,
The Archive Keeper.
Catalyst is assembled by scripts/build-miadi-fixtures.mjs: its source document is written in the
design vocabulary and carries no tones, while the prototype held each beat's appreciation, narrative
function and tone triad in a separate table. The script merges them and records what it merged. The
prototype's own adapter is deliberately not used — it dropped 22 of the 23 moments.
ncp — the protocol repository's own examples: the minimal canonical story, The Shawshank
Redemption (which does not validate clean, and says why), and Anora.
Where the canonical data comes from
assets/ncp-schema.json, assets/terminology.json and assets/examples/*.json are copied from the
Narrative Context Protocol
repository (MIT). They are re-synced, with a source path and sha256 stamped into the generated files:
npm run sync # sync-schema + sync-docs
npm test # build, then node --testThis package is a consumer of that protocol, never its author. When the schema changes there, the generated enums, required lists, and term maps change here — and the stamp says which version is in.
