Skip to content

About

Read, lint, edit and write SillyTavern character cards (V1/V2/V3) — PNG chara chunks and JSON. Library + CLI, zero runtime dependencies.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

tavern-card-tools

Read, lint, edit, and write SillyTavern character cards — the PNG ones with the card hidden in a chara text chunk, and the plain JSON ones. Library plus a small CLI. No runtime dependencies.

The problem it solves: a character card is a JSON blob base64'd into a PNG metadata chunk, and most tooling that touches one either drops the fields it doesn't recognize, re-encodes the image, or leaves a stale chunk behind that V3-aware frontends read in preference to the one you just wrote. This library does the boring parts correctly:

  • Reads V1, V2, and V3 cards, from JSON or from a PNG.
  • Keeps unknown and extension fields byte-for-byte through an edit. Your lorebook, your extensions block, and whatever a future spec adds all survive a load-edit-save cycle.
  • Writes the card back into a PNG by rebuilding the chunk list — the image data is copied, never re-encoded, so the art is bit-identical.
  • Strips a stale ccv3 chunk on write, so edits actually apply everywhere.
  • Lints a card and tells you why something is a problem, not just that it is.
  • Estimates token cost per section, split into permanent context and per-greeting. Read the honesty section below before trusting the numbers.

Everything runs locally. There is no network code in this package.

Install

Not on npm yet — install from the repository:

npm install github:zeshutmax/tavern-card-tools

That builds on install (there is a prepare script), so you get dist/ and the tavern-card binary without any extra step.

Node 18 or newer. The library is also browser-safe: it uses only atob, TextEncoder/TextDecoder, DataView, and typed arrays, so it runs in a page without a bundler polyfill. (The CLI is Node-only, for obvious reasons.)

Library

Parse a card from JSON

import { parseTavernCardFull, readCardFields } from "tavern-card-tools";

const result = parseTavernCardFull(jsonText);
if (!result.ok) {
  // "not-json" — didn't parse; "not-a-card" — parsed, but no name anywhere
  throw new Error(result.error);
}

const card = result.card;
card.shape; // "v2" for an envelope card (V2 or V3), "v1" for a bare object
card.root;  // the entire parsed object, untouched

const fields = readCardFields(card); // name, description, first_mes, tags, …

readCardFields reads through the shape difference for you: envelope cards keep their fields under data, V1 cards keep them at the top level. Missing fields come back as "" or [] rather than undefined, and a field holding the wrong type (a number where a string belongs) degrades to empty instead of throwing.

If you only want to know who the character is, parseTavernJson returns a narrow five-field projection instead.

Extract a card from a PNG

import { extractTavernPngCardJson, parseTavernCardFull } from "tavern-card-tools";

const bytes = new Uint8Array(await file.arrayBuffer());
const found = extractTavernPngCardJson(bytes.buffer);
if (!found.ok) {
  // "not-png" — bad signature; "png-no-card" — a PNG, but no chara chunk
  throw new Error(found.error);
}

const card = parseTavernCardFull(found.json);

In Node, be careful handing a Buffer to the ArrayBuffer-taking functions: Buffer is a view into a shared pool, so buf.buffer is usually not the bytes you want. Copy first:

const copy = new Uint8Array(buf.byteLength);
copy.set(buf);
const found = extractTavernPngCardJson(copy.buffer);

parseTavernPng is the one-step version when the narrow projection is enough. Neither function validates chunk CRCs on read — a bit-rotted checksum should not cost someone their character.

Lint

import { lintTavernCard } from "tavern-card-tools";

for (const finding of lintTavernCard(card, { artBytes: pngBytes.length })) {
  console.log(finding.level, finding.label);  // "warn", "first_mes never references {{user}}"
  console.log(finding.detail);                // the specifics and why it matters
}

Each finding has a stable id, a level of "pass" or "warn", a one-line label, and a detail paragraph. There is no "error" level — nothing in a character card is fatal, and a linter that shouts is a linter people turn off.

The rules: spec envelope present and recognized; name / description / first_mes non-empty; {{user}} referenced in the greeting; {{macros}} all from the known SillyTavern set; <START> markers present in mes_example; and three size checks — permanent context (~2,000 estimated tokens), card JSON (100 KB), and art (1 MB). artBytes is optional; omit it and the art check is skipped.

Edit and re-embed

import {
  writeCardFields,
  embedTavernCardInPng,
  extractTavernPngCardJson,
} from "tavern-card-tools";

const edited = writeCardFields(card, {
  description: "Rewritten.",
  tags: ["original", "slow-burn"],
});

const out = embedTavernCardInPng(artPngBytes, edited.root);

// Worth doing: confirm what you wrote reads back.
if (!extractTavernPngCardJson(out.buffer).ok) throw new Error("write failed");

writeCardFields is immutable — it returns a new card and leaves the original alone. It only touches the keys you pass. An empty value ("" or []) clears a key that already exists but will not create one, so lean cards stay lean. Everything you didn't mention, including fields this library has never heard of, is preserved exactly.

embedTavernCardInPng walks the chunk list, drops any existing chara and ccv3 text chunks, copies every other chunk verbatim, and inserts a fresh chara tEXt chunk with a correct CRC32 immediately before IEND. It throws not-png on a bad signature and png-missing-iend on a truncated file. Re-embedding into an already-carded PNG replaces the chunk rather than stacking another copy.

serializeTavernCard(card) gives you the pretty-printed (2-space) JSON for a .json download.

CLI

The package installs a tavern-card command:

tavern-card inspect  <file.png|file.json>   shape, fields, token estimates
tavern-card lint     <file.png|file.json>   pass/warn findings
tavern-card extract  <file.png> [out.json]  card JSON out of a PNG
tavern-card embed    <card.json> <art.png> [out.png]

One-off, without adding it to a project: npx -p github:zeshutmax/tavern-card-tools tavern-card inspect card.png.

Omit the input path to read stdin; omit the output path to write stdout. For embed, dropping the card path takes the card from stdin (cat card.json | tavern-card embed art.png out.png). lint exits 1 if anything warns, which makes it usable in a pre-commit hook or in CI over a repository of cards.

$ tavern-card inspect wren.png
Wren Halloway
  shape V2/V3 envelope (fields under data)
  spec chara_card_v2 / spec_version 2.0
  file 9,909 bytes (PNG — art plus the embedded card)
  other fields: extensions

  permanent context — sent with every message
    description      ~72
    personality      ~28
    scenario         ~36
    mes_example      ~54
    total            ~190

  per greeting — one of these opens the chat
    first_mes        ~49
    alt greeting 1   ~38

  Token counts are estimates (blended char/word heuristic, roughly
  +/-15% of a real tokenizer). Budget with them, don't bill with them.

They pipe:

tavern-card extract old.png | jq '.data.name = "New Name"' | tavern-card embed art.png new.png

Spec compatibility

V1 is a bare object with the fields at the top level: name, description, personality, scenario, first_mes, mes_example. Still widely readable, still common in the wild.

V2 (chara_card_v2) wraps everything in { spec, spec_version, data: {…} } and adds creator_notes, system_prompt, post_history_instructions, alternate_greetings, tags, character_book, and extensions.

V3 (chara_card_v3) keeps the same envelope shape, so it parses through the identical code path and reports shape: "v2" — the field names V2 and V3 share are read and written normally, and V3-only fields ride through untouched because unknown fields always do. This library does not implement V3-specific semantics (decorators, asset lists); it preserves them.

A note on ccv3. V3 cards may carry a second text chunk keyed ccv3 alongside chara, and a V3-aware frontend prefers it. Writing a card without removing the old ccv3 chunk produces a PNG that looks correct in one frontend and shows pre-edit content in another — a genuinely nasty bug to chase. embedTavernCardInPng therefore strips ccv3 and writes only chara, which every frontend reads. If your workflow depends on a V3-only field being visible to a V3-aware reader through the ccv3 chunk specifically, this package is not currently the right tool.

Detection rule for shape: if the object has a data sub-object with a non-empty name, it is an envelope card; otherwise, if the top level has a non-empty name, it is V1; otherwise it is not a card. The spec string is not used for detection, because plenty of real exporters omit it.

Permanent context vs per-greeting

The token panel splits a card's cost into two budgets, and it matters that they stay separate.

Permanent context — description, personality, scenario, mes_example, and system_prompt — is sent with every single message. A 2,000-token permanent block on an 8k-context model means a quarter of the window is gone before the conversation starts, every turn, forever.

Per-greeting — first_mes and each entry in alternate_greetings — costs you one of them. The reader picks a greeting (or swipes to another); the rest are never sent. A card with eight alternate greetings costs the same at runtime as a card with one.

So do not add the two totals together. A card with a 400-token permanent block and six 300-token greetings has a real footprint of about 700 tokens, not 2,200. Summing the sections is the single most common way people talk themselves into deleting greetings that were costing them nothing.

Honesty about the token counts

The token numbers are estimates, not tokenizer output.

estimateTokens blends a character count (chars ÷ 3.6, slightly denser than the classic ÷ 4 because roleplay prose runs punctuation-heavy) with a word count (words × 1.35), and averages the two. That is the entire algorithm. There is no BPE tokenizer in this package, no vocabulary file, no model-specific table.

In practice, real tokenizers land within roughly ±15% of these numbers on ordinary card prose, and every model tokenizes differently anyway — so the true number is a range even in principle.

What this is good for:

  • "Is this card 800 tokens or 2,400?" — reliably answered.
  • Comparing sections of the same card to find what is actually heavy.
  • Watching a number go down while you cut a bloated description.

What this is not good for:

  • Exact context-budget arithmetic against a hard limit.
  • Anything involving cost, billing, or an API quota.
  • Non-Latin scripts, heavy markup, or unusual formatting, where a character-and-word heuristic drifts further than ±15%.

If you need exact counts, run the actual tokenizer for your model. This package deliberately does not, because a dependency-free card editor that is roughly right is more useful to more people than an exact one that ships a megabyte of tables and only covers one model family.

The library always presents these as ≈. Please keep that convention if you surface them in a UI.

Also included: archetype matching

mapToDraft, matchArchetype, inferGender, and distillFreeform map a card onto your own catalog of character archetypes by keyword scoring, infer gender from third-person pronouns, and distill a card's prose down to a length-clamped summary. This exists for importing cards into an application that doesn't use the Tavern format natively. If you just want to read, lint, and write cards, ignore this part — nothing else depends on it.

Who made this

This code was extracted from Card Studio, a browser tool I built for Bae. The hosted version is at bae.ppl.studio/tools/card-studio — same engine, with an editor UI around it, no login, and nothing uploaded (it is the same client-side code you are reading here). The engine was already a single dependency-free file with no ties to the rest of that codebase, so publishing it under MIT cost nothing and seemed more useful than leaving it sitting in a src/lib folder. If you only want the library, you never need to visit the site. There is also a card builder there for writing a spec-complete V2 card from scratch, same engine underneath.

Contributing

Issues and pull requests are welcome, particularly: real-world cards that parse wrongly (attach the file if you can share it), frontends whose chunk-writing conventions differ from what is handled here, and lint rules that would have saved you time.

npm install
npm run typecheck
npm test

Tests use node:test and generate their PNG fixtures in code, so there are no binary blobs in the repository. If you change estimateTokens, a golden-value test will fail on purpose — update it deliberately and say so in the PR.

License

MIT. See LICENSE.

About

Read, lint, edit and write SillyTavern character cards (V1/V2/V3) — PNG chara chunks and JSON. Library + CLI, zero runtime dependencies.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages