feat(ccc): add forward-compatible CCv3 codec (#2113)

Signed-off-by: RainbowBird <git@luoling.moe>
This commit is contained in:
RainbowBird
2026-07-27 01:38:53 +08:00
committed by GitHub
parent 5884b0bdba
commit ce65bcd7f5
18 changed files with 493 additions and 135 deletions
+47 -1
View File
@@ -1,6 +1,52 @@
# @proj-airi/ccc
Create Character Card in a modular way.
Character Card protocol primitives for AIRI.
## What it owns
- CCv3 JSON envelope validation and compatibility classification.
- Forward-compatible preservation of unknown fields during validation.
- Shared CCv3 TypeScript contracts.
- Character Card JSON, Markdown, PNG, and APNG export helpers.
The package is intentionally runtime-agnostic. It does not own AIRI module
settings, local persistence, chat message assembly, or editor behavior.
## Parse a CCv3 document
```ts
import { parseCharacterCardV3 } from '@proj-airi/ccc'
const { card, compatibility } = parseCharacterCardV3(jsonText)
const characterName = card.data.name
const versionSupport = compatibility // 'older' | 'current' | 'newer'
```
The parser accepts either decoded JSON data or JSON text. Older and newer
`spec_version` values remain importable, while `compatibility` lets the caller
decide whether to warn the user. Unknown keys are preserved on the validated
output so future CCv3 fields are not silently deleted.
Malformed JSON and invalid CCv3 structures throw
`InvalidCharacterCardError`. Use `isInvalidCharacterCardError` when a boundary
needs to distinguish protocol failures from storage or filesystem errors.
## When to use it
- Importing or exporting community Character Cards.
- Validating a CCv3 envelope before converting it into an application model.
- Building format adapters such as PNG, APNG, or CHARX.
## When not to use it
- Persisting AIRI-specific active-card state.
- Applying speech, vision, body-model, or agent configuration.
- Constructing provider messages from prompts, greetings, examples, or a
Lorebook.
Those policies belong to AIRI's character runtime rather than the community
protocol codec.
## License
+4 -2
View File
@@ -7,9 +7,11 @@
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint .",
"lint:fix": "eslint --fix ."
"lint:fix": "eslint --fix .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"meta-png": "catalog:"
"meta-png": "catalog:",
"valibot": "catalog:"
}
}
+210
View File
@@ -0,0 +1,210 @@
import type { InferOutput } from 'valibot'
import {
array,
boolean,
literal,
number,
objectWithRest,
optional,
picklist,
pipe,
record,
regex,
safeParse,
string,
union,
unknown,
} from 'valibot'
const currentSpecVersion = 3
const invalidCharacterCardMessage = 'Invalid Character Card V3.'
const extensionsDepthPromptSchema = objectWithRest({
depth: number(),
prompt: string(),
role: string(),
}, unknown())
const extensionsSchema = objectWithRest({
depth_prompt: optional(extensionsDepthPromptSchema),
fav: optional(boolean()),
talkativeness: optional(number()),
world: optional(string()),
}, unknown())
const openExtensionsSchema = objectWithRest({}, unknown())
const assetSchema = objectWithRest({
type: string(),
uri: string(),
name: string(),
ext: string(),
}, unknown())
const characterBookEntrySchema = objectWithRest({
keys: array(string()),
content: string(),
extensions: openExtensionsSchema,
enabled: boolean(),
insertion_order: number(),
use_regex: boolean(),
case_sensitive: optional(boolean()),
constant: optional(boolean()),
id: optional(union([number(), string()])),
name: optional(string()),
comment: optional(string()),
priority: optional(number()),
selective: optional(boolean()),
secondary_keys: optional(array(string())),
position: optional(picklist(['before_char', 'after_char'])),
}, unknown())
const characterBookSchema = objectWithRest({
name: optional(string()),
description: optional(string()),
scan_depth: optional(number()),
token_budget: optional(number()),
recursive_scanning: optional(boolean()),
extensions: openExtensionsSchema,
entries: array(characterBookEntrySchema),
}, unknown())
const characterCardDataSchema = objectWithRest({
name: string(),
description: string(),
personality: string(),
scenario: string(),
first_mes: string(),
mes_example: string(),
alternate_greetings: array(string()),
character_book: optional(characterBookSchema),
character_version: string(),
creator: string(),
creator_notes: string(),
extensions: extensionsSchema,
post_history_instructions: string(),
system_prompt: string(),
tags: array(string()),
assets: optional(array(assetSchema)),
creation_date: optional(number()),
creator_notes_multilingual: optional(record(string(), string())),
group_only_greetings: array(string()),
modification_date: optional(number()),
nickname: optional(string()),
source: optional(array(string())),
}, unknown())
const characterCardV3Schema = objectWithRest({
spec: literal('chara_card_v3'),
spec_version: pipe(string(), regex(/^\d+(?:\.\d+)*$/)),
data: characterCardDataSchema,
}, unknown())
/** One asset reference declared by a CCv3 document. */
export type Asset = InferOutput<typeof assetSchema>
/** Asset references declared by a CCv3 document. */
export type Assets = Asset[]
/** Character-specific Lorebook data. */
export type CharacterBook = InferOutput<typeof characterBookSchema>
/** One independently matched and ordered Lorebook entry. */
export type CharacterBookEntry = InferOutput<typeof characterBookEntrySchema>
/** Application-defined Lorebook metadata. */
export type CharacterBookExtensions = InferOutput<typeof openExtensionsSchema>
/** Application-defined Lorebook entry metadata. */
export type CharacterBookEntryExtensions = InferOutput<typeof openExtensionsSchema>
/** Complete data object carried by a CCv3 envelope. */
export type Data = InferOutput<typeof characterCardDataSchema>
/** Fields inherited from Character Card V1. */
export type DataV1 = Pick<Data, 'description' | 'first_mes' | 'mes_example' | 'name' | 'personality' | 'scenario'>
/** Fields inherited from Character Card V2. */
export type DataV2 = Pick<Data, 'alternate_greetings' | 'character_book' | 'character_version' | 'creator' | 'creator_notes' | 'extensions' | 'post_history_instructions' | 'system_prompt' | 'tags'>
/** Fields introduced or changed by Character Card V3. */
export type DataV3 = Pick<Data, 'assets' | 'creation_date' | 'creator_notes_multilingual' | 'group_only_greetings' | 'modification_date' | 'nickname' | 'source'>
/** Community extension fields carried by a character card. */
export type Extensions = InferOutput<typeof extensionsSchema>
/** Standard `depth_prompt` extension value. */
export type ExtensionsDepthPrompt = InferOutput<typeof extensionsDepthPromptSchema>
/** A Character Card document using the CCv3 envelope and data contract. */
export type CharacterCardV3 = InferOutput<typeof characterCardV3Schema>
/** Compatibility of a parsed document relative to AIRI's current CCv3 implementation. */
export type CharacterCardCompatibility = 'older' | 'current' | 'newer'
/** A validated CCv3 document together with its compatibility classification. */
export interface ParsedCharacterCardV3 {
/** Validated card data. Unknown future fields remain attached to their owning objects. */
card: CharacterCardV3
/** Whether the source version is older than, equal to, or newer than CCv3 3.0. */
compatibility: CharacterCardCompatibility
}
/** Options retained on a CCv3 validation failure. */
export interface InvalidCharacterCardErrorOptions {
/** Parser or schema issues that made the source unusable. */
cause?: unknown
/** Original JSON text or object supplied by the caller. */
source: unknown
}
/** Error thrown when input cannot be interpreted as a Character Card V3 document. */
export class InvalidCharacterCardError extends Error {
readonly source: unknown
constructor(options: InvalidCharacterCardErrorOptions) {
super(invalidCharacterCardMessage, { cause: options.cause })
this.name = 'InvalidCharacterCardError'
this.source = options.source
}
}
/** Checks whether an error came from the CCv3 parsing boundary. */
export function isInvalidCharacterCardError(error: unknown): error is InvalidCharacterCardError {
return error instanceof InvalidCharacterCardError
}
/**
* Parses and validates a Character Card V3 object or JSON document.
*
* Unknown fields are preserved so a newer card can be inspected and exported
* without silently discarding data AIRI does not understand yet. Older and
* newer `spec_version` values are accepted and reported through
* `compatibility`; callers can decide how prominently to warn users.
*/
export function parseCharacterCardV3(source: unknown): ParsedCharacterCardV3 {
const candidate = parseJsonSource(source)
const result = safeParse(characterCardV3Schema, candidate)
if (!result.success) {
throw new InvalidCharacterCardError({
cause: result.issues,
source,
})
}
return {
card: result.output,
compatibility: resolveCompatibility(result.output.spec_version),
}
}
function parseJsonSource(source: unknown): unknown {
if (typeof source !== 'string')
return source
try {
return JSON.parse(source)
}
catch (cause) {
throw new InvalidCharacterCardError({ cause, source })
}
}
function resolveCompatibility(specVersion: string): CharacterCardCompatibility {
const sourceVersion = Number.parseFloat(specVersion)
if (sourceVersion < currentSpecVersion)
return 'older'
if (sourceVersion > currentSpecVersion)
return 'newer'
return 'current'
}
+11
View File
@@ -0,0 +1,11 @@
export {
InvalidCharacterCardError,
isInvalidCharacterCardError,
parseCharacterCardV3,
} from './characterCardV3'
export type {
CharacterCardCompatibility,
InvalidCharacterCardErrorOptions,
ParsedCharacterCardV3,
} from './characterCardV3'
+26 -1
View File
@@ -1,4 +1,4 @@
import type { Data } from '../export/types'
import type { Data } from '../codec/characterCardV3'
import type { Message } from './types/mes_example'
interface CardCore {
@@ -31,6 +31,21 @@ interface CardMeta {
}
interface CardAdditional {
/**
* Character assets such as icons, backgrounds, and emotions.
* - assets
*/
assets?: Data['assets']
/**
* Character-specific Lorebook.
* - character_book
*/
characterBook?: Data['character_book']
/**
* Creation time as a UTC Unix timestamp in seconds.
* - creation_date
*/
creationDate?: Data['creation_date']
/**
* Extensions.
* - extensions
@@ -59,6 +74,16 @@ interface CardAdditional {
* @see {@link https://github.com/kwaroran/character-card-spec-v3/blob/main/SPEC_V3.md#creator_notes_multilingual}
*/
notesMultilingual?: Data['creator_notes_multilingual']
/**
* Last modification time as a UTC Unix timestamp in seconds.
* - modification_date
*/
modificationDate?: Data['modification_date']
/**
* IDs or URLs describing the card's provenance.
* - source
*/
source?: Data['source']
}
interface CardDescription {
+2 -1
View File
@@ -1,5 +1,6 @@
export type * as ccv3 from '../codec/characterCardV3'
export { exportToAPNG } from './apng'
export { exportToJSON } from './json'
export { exportToMD as exportToMarkdown, exportToMD } from './md'
export { exportToPNG, exportToPNGBase64 } from './png'
export type * as ccv3 from './types'
+7 -2
View File
@@ -1,5 +1,5 @@
import type { CharacterCardV3 } from '../codec/characterCardV3'
import type { Card } from '../define'
import type { CharacterCardV3 } from './types/character_card_v3'
/**
* Exports a Card object to CharacterCardV3 format
@@ -23,6 +23,9 @@ function createCardData(data: Card): CharacterCardV3['data'] {
return {
name: data.name,
nickname: data.nickname,
assets: data.assets,
character_book: data.characterBook,
creation_date: data.creationDate,
description: data.description ?? '',
personality: data.personality ?? '',
scenario: data.scenario ?? '',
@@ -36,6 +39,8 @@ function createCardData(data: Card): CharacterCardV3['data'] {
system_prompt: data.systemPrompt ?? '',
post_history_instructions: data.postHistoryInstructions ?? '',
mes_example: formatMessageExample(data.messageExample),
modification_date: data.modificationDate,
source: data.source,
tags: data.tags ?? [],
extensions: createExtensions(data),
}
@@ -60,7 +65,7 @@ function formatMessageExample(messageExample: string[][] | undefined): string {
* @param data Source card data
* @returns Extensions object
*/
function createExtensions(data: Card): Record<string, any> {
function createExtensions(data: Card): CharacterCardV3['data']['extensions'] {
return {
depth_prompt: {
depth: 4,
-8
View File
@@ -1,8 +0,0 @@
export type Assets = Asset[]
export interface Asset {
ext: string
name: string
type: string
uri: string
}
@@ -1,43 +0,0 @@
export interface CharacterBook {
description?: string
entries: CharacterBookEntry[]
extensions: CharacterBookExtensions
name?: string
recursive_scanning?: boolean
scan_depth?: number
token_budget?: number
}
export interface CharacterBookEntry {
case_sensitive?: boolean
/** not used in prompt engineering */
comment?: string
/** if true, always inserted in the prompt (within budget limit) */
constant?: boolean
content: string
enabled: boolean
extensions: CharacterBookEntryExtensions
// FIELDS WITH NO CURRENT EQUIVALENT IN SILLY
/** not used in prompt engineering */
id?: number
/** if two entries inserted, lower "insertion order" = inserted higher */
insertion_order: number
// FIELDS WITH NO CURRENT EQUIVALENT IN AGNAI
keys: string[]
/** not used in prompt engineering */
name?: string
/** whether the entry is placed before or after the character defs */
position?: 'after_char' | 'before_char'
/** if token budget reached, lower priority value = discarded first */
priority?: number
/** see field `selective`. ignored if selective == false */
secondary_keys?: string[]
/** if `true`, require a key from both `keys` and `secondary_keys` to trigger the entry */
selective?: boolean
}
export interface CharacterBookExtensions extends Record<string, unknown> {}
export interface CharacterBookEntryExtensions extends Record<string, unknown> {}
@@ -1,7 +0,0 @@
import type { Data } from './data'
export interface CharacterCardV3 {
data: Data
spec: 'chara_card_v3'
spec_version: '3.0'
}
-40
View File
@@ -1,40 +0,0 @@
import type { Assets } from './assets'
import type { CharacterBook } from './character_book'
import type { Extensions } from './extensions'
/** @see {@link https://github.com/kwaroran/character-card-spec-v3/blob/main/SPEC_V3.md#charactercard-object} */
export type Data = DataV1 & DataV2 & DataV3
/** @see {@link https://github.com/malfoyslastname/character-card-spec-v2/blob/main/spec_v1.md} */
export interface DataV1 {
description: string
first_mes: string
mes_example: string
name: string
personality: string
scenario: string
}
/** @see {@link https://github.com/malfoyslastname/character-card-spec-v2/blob/main/spec_v2.md} */
export interface DataV2 {
alternate_greetings: string[]
character_book?: CharacterBook
character_version: string
creator: string
creator_notes: string
extensions: Extensions
post_history_instructions: string
system_prompt: string
tags: string[]
}
/** @see {@link https://github.com/kwaroran/character-card-spec-v3/blob/main/SPEC_V3.md#charactercard-object} */
export interface DataV3 {
assets?: Assets
creation_date?: number
creator_notes_multilingual?: Record<string, string>
group_only_greetings: string[]
modification_date?: number
nickname?: string
source?: string[]
}
@@ -1,25 +0,0 @@
export interface Extensions extends Record<string, unknown> {
/**
* @default
* ```ts
* {
* depth: 4,
* prompt: '',
* role: 'system',
* }
* ```
*/
depth_prompt?: ExtensionsDepthPrompt
/** @default `false` */
fav?: boolean
/** @default `0.5` */
talkativeness?: number
/** @default `undefined` */
world?: string
}
export interface ExtensionsDepthPrompt {
depth: number
prompt: string
role: 'system' | ({} & string)
}
-5
View File
@@ -1,5 +0,0 @@
export type { Asset, Assets } from './assets'
export type { CharacterBook, CharacterBookEntry, CharacterBookEntryExtensions, CharacterBookExtensions } from './character_book'
export type { CharacterCardV3 } from './character_card_v3'
export type { Data, DataV2, DataV3 } from './data'
export type { Extensions, ExtensionsDepthPrompt } from './extensions'
+1
View File
@@ -1,3 +1,4 @@
export * from './codec'
export * from './define'
export * from './export'
export * from './utils'
+174
View File
@@ -0,0 +1,174 @@
import { describe, expect, it } from 'vitest'
import {
exportToJSON,
InvalidCharacterCardError,
isInvalidCharacterCardError,
parseCharacterCardV3,
} from '../src'
const completeCard = {
spec: 'chara_card_v3',
spec_version: '3.0',
data: {
name: 'ReLU',
description: 'A curious AI.',
personality: 'Warm and precise.',
scenario: 'A quiet observatory.',
first_mes: 'Welcome, {{user}}.',
mes_example: '<START>\n{{user}}: Hello\n{{char}}: Hi!',
alternate_greetings: ['Good evening.'],
group_only_greetings: ['Hello, everyone.'],
character_version: '1.0.0',
creator: 'AIRI',
creator_notes: 'Created for codec coverage.',
creator_notes_multilingual: {
ja: 'コーデックテスト用です。',
},
system_prompt: 'Stay in character.',
post_history_instructions: 'Answer the latest message.',
tags: ['assistant'],
source: ['https://example.com/relu'],
creation_date: 1_700_000_000,
modification_date: 1_700_000_100,
assets: [
{
type: 'icon',
uri: 'ccdefault:',
name: 'main',
ext: 'png',
future_asset_field: 'preserved',
},
],
character_book: {
name: 'Observatory',
extensions: {
vendor: 'airi',
},
entries: [
{
keys: ['comet'],
content: 'The comet returns every 72 years.',
extensions: {},
enabled: true,
insertion_order: 10,
use_regex: false,
id: 'comet-entry',
future_entry_field: true,
},
],
future_book_field: 42,
},
extensions: {
airi: {
modules: {},
},
},
future_data_field: {
keep: true,
},
},
future_envelope_field: 'preserved',
} as const
describe('character card V3 codec', () => {
it('parses the complete CCv3 contract without discarding future fields', () => {
const result = parseCharacterCardV3(completeCard)
expect(result.compatibility).toBe('current')
expect(result.card).toEqual(completeCard)
expect(result.card.data.character_book?.entries[0]?.id).toBe('comet-entry')
expect(result.card.data.character_book?.entries[0]?.use_regex).toBe(false)
})
it('parses JSON text through the same validation boundary', () => {
const result = parseCharacterCardV3(JSON.stringify(completeCard))
expect(result.card.data.name).toBe('ReLU')
expect(result.compatibility).toBe('current')
})
it('accepts compatible newer documents and reports their compatibility', () => {
const result = parseCharacterCardV3({
...completeCard,
spec_version: '4.0',
})
expect(result.card.spec_version).toBe('4.0')
expect(result.compatibility).toBe('newer')
})
it('accepts older documents with a V3 envelope and reports their compatibility', () => {
const result = parseCharacterCardV3({
...completeCard,
spec_version: '2.0',
})
expect(result.compatibility).toBe('older')
})
it('exports every standard CCv3 field represented by the domain card', () => {
const exported = exportToJSON({
name: completeCard.data.name,
nickname: 'Re',
version: completeCard.data.character_version,
assets: [...completeCard.data.assets],
characterBook: completeCard.data.character_book,
creationDate: completeCard.data.creation_date,
description: completeCard.data.description,
greetings: [
completeCard.data.first_mes,
...completeCard.data.alternate_greetings,
],
greetingsGroupOnly: [...completeCard.data.group_only_greetings],
modificationDate: completeCard.data.modification_date,
notes: completeCard.data.creator_notes,
notesMultilingual: completeCard.data.creator_notes_multilingual,
personality: completeCard.data.personality,
postHistoryInstructions: completeCard.data.post_history_instructions,
scenario: completeCard.data.scenario,
source: [...completeCard.data.source],
systemPrompt: completeCard.data.system_prompt,
tags: [...completeCard.data.tags],
extensions: completeCard.data.extensions,
})
expect(exported.data.assets).toEqual(completeCard.data.assets)
expect(exported.data.character_book).toEqual(completeCard.data.character_book)
expect(exported.data.creation_date).toBe(completeCard.data.creation_date)
expect(exported.data.modification_date).toBe(completeCard.data.modification_date)
expect(exported.data.source).toEqual(completeCard.data.source)
})
it('reports one domain error for malformed JSON or invalid cards', () => {
for (const source of [
'{',
{
...completeCard,
spec: 'chara_card_v2',
},
{
...completeCard,
data: {
...completeCard.data,
character_book: {
...completeCard.data.character_book,
entries: [{
...completeCard.data.character_book.entries[0],
use_regex: undefined,
}],
},
},
},
]) {
expect(() => parseCharacterCardV3(source)).toThrow(InvalidCharacterCardError)
try {
parseCharacterCardV3(source)
}
catch (error) {
expect(isInvalidCharacterCardError(error)).toBe(true)
}
}
})
})
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['test/**/*.test.ts'],
},
})
+3
View File
@@ -3302,6 +3302,9 @@ importers:
meta-png:
specifier: 'catalog:'
version: 1.0.6
valibot:
specifier: 'catalog:'
version: 1.3.1(typescript@5.9.3)
packages/core-agent:
dependencies:
+1
View File
@@ -7,6 +7,7 @@ export default defineConfig({
'apps/ui-server-auth',
'apps/stage-tamagotchi',
'packages/cap-vite',
'packages/ccc',
'packages/core-agent',
'packages/better-ws',
'packages/plugin-sdk',