Skip to content

Eldriss-Studio/poltergink

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

22 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

poltergink

CI license: MIT

A poltergeist in the Ink shell — an LLM-native TypeScript wrapper around inkjs.

poltergink makes Inkle's Ink narrative engine two-sided: instead of a human picking branches, an LLM holds a persona, reads the unfolding story, and is forced to pick from the author-defined currentChoices. No freeform text, no off-script generation — just constrained choice selection over a real narrative graph.

Status: Early development. The Story facade, the Player interface with ScriptedPlayer, the Session orchestrator with typed event emission, and the Transcript (with per-turn snapshots) are shipped and tested. LLMPlayer and Persona are next.

What's available

Story — the narrative facade

Load an Ink story from raw .ink source or pre-compiled JSON, advance it turn by turn, pick choices, and snapshot/restore state. This is the foundation everything else builds on.

import { Story } from "poltergink";

// Load from raw .ink source (inkjs Compiler bundled — no inklecate needed)
const story = Story.fromInk(`
  You stand at a crossroads.
  * [Go north] -> north
  * [Go south] -> south
  ...
`);

// Or from pre-compiled JSON
// const story = Story.fromJson(compiledJsonString);

// Advance until the next choice point (or the end)
const scene = story.advance();
console.log(scene.text);     // accumulated passage text
console.log(scene.choices);  // Choice[] the player can pick from
console.log(scene.tags);     // passage-level tags emitted this turn
console.log(scene.ended);    // true when story is over

// Pick a choice by its zero-based index
story.choose(0);
const next = story.advance();

// Save and restore full story state
const saved = story.snapshot();
story.restore(saved);

Exported types: Choice, Scene, Story, StoryChoiceRangeError.

StoryChoiceRangeError carries .attempted and .available so callers can surface a clean error without parsing a message string.

Player, ScriptedPlayer, Session — the turn loop

The Session orchestrator drives a Story through a Player until the story ends. ScriptedPlayer is the deterministic reference player used by tests and reproducible demos.

import { ScriptedPlayer, Session, Story } from "poltergink";

const story = Story.fromInk(`
  At the door.
  * [Knock]   -> answered
  * [Walk away] -> END
  === answered ===
  Someone opens. -> END
`);

const player = new ScriptedPlayer([0]); // pre-decided sequence of choice indices
const session = new Session({ story, player });

const result = await session.run();
console.log(result.turns);       // [{ scene, decision }, …]
console.log(result.finalScene);  // the ended scene (closing narrative, ended === true)

Player contract. Any implementation of Player.selectChoice(ctx) works — ScriptedPlayer, your own heuristic class, or the upcoming LLMPlayer. ctx carries the current scene plus the running history of completed Turns; pass that history to an LLM as context, look at it in a heuristic player, or ignore it entirely. The return is a Decision{ choiceIndex, reasoning?, raw? }.

ScriptExhaustedError carries .scriptLength and .turnIndex so callers can pinpoint exactly where the script ran out.

Transcript + typed events

Session.run() returns a frozen Transcript: every turn includes the scene shown, the decision made, and full state snapshots before and after the choice. Snapshots are inkjs state.ToJson blobs — round-trippable via Story.restore, so any turn can be replayed from a fresh story.

const transcript = await session.run();

// Every TurnRecord is immutable.
for (const turn of transcript.turns) {
  console.log(`turn ${turn.index}: chose ${turn.decision.choiceIndex}`);

  // Replay from this exact branch point on a fresh story.
  const replay = Story.fromInk(source);
  replay.restore(turn.snapshotBefore);
  // replay.advance() now sees the same scene `turn.scene` saw.
}

console.log(transcript.finalScene.text);   // closing narrative
console.log(transcript.finalSnapshot);     // post-end state JSON

Session also emits typed events during the run — useful for live UIs, instrumentation, or feeding an LLM context-window with what's just happened:

const unsubscribe = session.on("choice:made", (e) => {
  console.log(`turn ${e.turnIndex}: ${e.record.decision.choiceIndex}`);
});
// unsubscribe() to stop listening.

session.on("turn:start", (e) => { /* … */ });
session.on("story:ended", (e) => console.log(e.finalScene.text));

SessionEvent is a discriminated union (turn:start | choice:made | story:ended) — e.type === "choice:made" narrows to the record-carrying variant.

Session accepts an optional maxTurns cap. If exceeded it throws SessionMaxTurnsError carrying the partial Transcript — useful as a runaway-loop safety net or in tests against unfamiliar stories.

Tag-driven routing

Ink tags flow through at two levels: passage tags appear on Scene.tags after each advance(); choice tags (written before the choice's bracketed text) appear on Choice.tags at decision time. The PersonaDirector (coming in v0) will read these to select a persona at the branch point.

A stranger approaches. # mood:tense
* # persona:detective [Ask who they are]  <- Choice.tags = ["persona:detective"]
  -> interrogate
* # persona:charmer [Smile and wave]
  -> deflect

Roadmap (v0)

  • Story facade over inkjs.fromInk, .fromJson, .advance, .choose, .snapshot, .restore
  • Player interface — Player, TurnContext, Decision, Turn
  • ScriptedPlayer — deterministic, for tests and reproducible demos
  • Session orchestrator — drives a Story × Player to completion
  • Transcript — per-turn snapshots, replay from any turn
  • Typed Session events — turn:start, choice:made, story:ended
  • LLMPlayer — Vercel AI SDK + Zod-constrained output
  • Persona + PersonaDirector — tag-driven persona switching mid-session
  • Astro Starlight docs site with TypeDoc-generated API reference

Design references

Contributing

Contributions are welcome. See CONTRIBUTING.md for the ATDD-first workflow, commit conventions, and the ADR process for proposing or recording a load-bearing decision. Participation is governed by our Code of Conduct. Security issues: see SECURITY.md.

License

MIT © 2026 Yuri Flagrare.

About

LLM-native TypeScript wrapper around inkjs - a poltergeist in the Ink shell. An LLM holds a persona, reads the narrative, and is forced to pick from author-defined choices.

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors