Gives a Claude Code session a character persona, with a time-aware liveliness layer and a pixel portrait panel; bundles three characters from claudecafe.dev.

persona-panel gives a Claude Code session a character. Claude takes on the character's persona and voice, gets a short sense of time at each turn, and shows the character as the work goes: in an interactive terminal a pixel portrait in a side panel, in the desktop app's Code tab an avatar beside each reply and a portrait pane.
Three characters from claudecafe.dev come bundled: Kotone, Kurumi and Kokona. You can add your own.
persona-panel is a Claude Code mod, so it needs Claude Code v2.1.287 or later, where mods are on by default. It draws in the terminal and in the desktop app's Code tab. The desktop pictures come from the character's pack, not the plugin; in a desktop session the plugin installs the published packs itself (see below).
/plugin marketplace add https://claudecafe.dev/plugins/marketplace.json
/plugin install persona-panel@claudecafe
Open a new session. Kotone is on shift unless you choose another character, and whoever starts a session stays for it.
set_expression tool or the reply's mood marker.thoughts on, the pane also shows her unsaid, sharp-tongued aside on the work in a box over her, and wears the face she thinks it with. Tool calls play a short cut-in above the prompt, and Claude can play one on purpose with the cut_in tool. /portrait reopens the pane./persona-panel:config views or changes the settings; /persona-panel:look has the character describe what they look like right now.Settings live in ~/.config/claudecafe/config.json (or under $XDG_CONFIG_HOME). Every key is optional, and /persona-panel:config can change them for you. Changes apply from the next session.
{
"character": "kotone",
"lang": "English",
"commit_authorship": "co-author",
"ambient_context": true,
"festivals": true
}
character — always use this character; "none" turns the persona off. Unset, Kotone (or, if she is off duty, the first character on the roster).lang — the language Claude should reply in. Unset, Claude chooses.commit_authorship — when Claude makes a Git commit, "co-author" adds the character as a Co-Authored-By trailer; "author" makes the character the commit author while you stay the committer.ambient_context — false drops the time, weather and Git context: the first-turn greeting and weather lookup, and the time line on every turn. The mood marker stays.festivals — false turns the festival calendar off, or a path to a JSON file of "MM-DD": "name" entries replaces it.thoughts — true shows her thoughts in the desktop portrait pane, each one a short model request. Off unless set; while off, the pane's thought box says how to turn it on.A character is a folder under ~/.config/claudecafe/characters/ named by its id, lowercase letters, digits and hyphens. Only persona.md is required:
characters/<id>/
persona.md # who she is: frontmatter, then the persona text
pixels/*.gif # optional terminal faces, 36×48
avatars/*.webp # optional square faces for the desktop, each under 95 KB
portraits-540/*.webp # optional 540×720 desktop portraits, each under 95 KB
1. Write her persona. Frontmatter on top, then the persona Claude adopts, in any language:
---
name: Mio
waiting:
- Hold on
- Reading it now
---
# Personality
You are Mio, a calm, dry-humoured AI maid. You call the user "Master" …
| Field | What it does |
|---|---|
name | Her name, shown under the portrait. Defaults to the folder id. |
waiting | Lines shown on the spinner while a turn runs, one at a time. |
id | claudecafe/<id> gives her a commit identity (<id>@claudecafe.dev) for commit_authorship; any other value gives her none. |
version | Compared with a bundled character of the same id; the higher one is used, and yours wins a tie. |
extends | Builds on another character (see below). |
off_duty | true skips her when nobody is chosen and the café falls back to someone else. |
The mood-marker table and the expression tool are added for you; the persona only has to say who she is.
2. Put her on shift. A new folder changes nothing until you choose her: set "character": "<id>" in config.json, or ask /persona-panel:config, then open a new session.
3. Give her faces (optional). Without pixels/ she still talks in character, but the terminal shows no portrait. Each GIF is named after the expression it shows, neutral.gif first, from this set:
neutral happy curious thinking focused confused proud smug excited flirty
smitten wink embarrassed pouty worried annoyed sad surprised angry afraid
skeptical frustrated awkward sorry speechless relieved laughing crying oops
pleading facepalm waving
Draw as many as you like; she is only offered the faces she has. The desktop pictures work the same way, but need both avatars/ and portraits-540/. The Claude Code desktop draws each picture as an SVG with the WebP inlined, and one SVG holds at most 131,072 characters, about 98 KB of image; keep every file in those two folders under 95 KB, or it is too large to draw.
To change a character without copying her whole file, make a new folder whose persona says extends: <id>. Each frontmatter field it fills in replaces the other character's, and so does a body that is not blank; whatever it leaves blank, and any picture folder it lacks, comes from the character it extends. off_duty is the one field never passed on:
---
extends: kokona
waiting:
- 任せて
---
Reply in Japanese, and call the user ご主人様.
Your own folder with a bundled character's id replaces her outright, but a newer bundled version takes over again after an update; extends survives updates.
config.json and characters/ under ~/.config/claudecafe/, and its own bundled files.~/.config/claudecafe/sessions/<session id>/character, holding the id of the character on shift for that session; and, in a desktop session, the published packs of Kotone, Kurumi and Kokona into ~/.config/claudecafe/characters/ when one there is missing or older, replacing that folder.git log --oneline --since=midnight to count today's commits, and git branch --show-current for the panel. Their output is used only for those two figures. To install a pack it runs curl, unzip, mkdir, mv and rm, which must be on the path."ambient_context": false to skip it."thoughts": true, for her thoughts in the desktop portrait pane: about one short Sonnet request per turn, carrying the persona and the last few things said and done in the session, through Claude Code's own model access.persona-panel collects nothing itself: no analytics, no telemetry, no account. See the privacy note.
hooks/function/register.js is a readable, unminified bundle of the plugin's source and the shared character code in minipai/claudecafe, under packages/persona-panel and packages/character-core. The build that produces it is scripts/build-plugin.sh.
The code is MIT; the bundled characters are under their own license. See LICENSE and characters/LICENSE.
hooks/function/register.js 704 lines1import {
2 PUBLISHED_CHARACTER_PACKS,
3 commitAuthorship,
4 compareVersions,
5 extendPersona,
6 readContext,
7 expressionPrompt,
8 expressionToolDescription,
9 markedFace,
10 parsePersona,
11 resolveCharacter,
12} from '../../../character-core/src/index.ts'
13import {
14 AVATAR, CUT_IN_MS, NAME_GAP, NAME_TAG_HEIGHT, PANE_WASH, ROSE, THOUGHT_INK,
15 avatarSvg, cutInSvg, gapSvg, nameTagSvg, petalField, portraitSvg, spacerSvg,
16} from './desktop.js'
17import { faceFromGif } from './faces.js'
18import { homePath, statusRows } from './stats.js'
19
20const TOOL = 'mcp__persona-panel__set_expression'
21const CUT_IN_TOOL = 'mcp__persona-panel__cut_in'
22const PANE = { id: 'persona-panel', title: 'Pixel art' }
23const PORTRAIT_PANE = { id: 'persona-portrait', title: 'Portrait' }
24const BLOCK = 'persona-panel'
25/** Long enough to be read twice over before it is replaced, as the desktop holds a line. */
26const WAITING_MS = 4500
27/** The desktop stage's widest, in columns: about 540 pixels, the portrait's own width (the pane reports no pixels). */
28const STAGE_COLUMNS = 64
29/** Her thought: how many recent happenings it reads, how often it may chime in while she works, and its height. */
30const THOUGHT_CONTEXT = 8
31const THOUGHT_EVERY_MS = 30000
32const THOUGHT_LINES = 3
33const THOUGHTS_OFF = 'Thoughts off — set "thoughts": true to hear them.'
34
35export function register(on) {
36 let session
37 let expression = 'neutral'
38 let waitingTick
39 let greeted = false
40 // The desktop's: the cut-in playing above the prompt, the face her latest reply closed on, and her unsaid thought.
41 const stage = { cutIn: null }
42 const portrait = { face: 'neutral' }
43 const thought = { log: [], line: '', face: null, isBusy: false, at: 0 }
44
45 on('session.start', async ($, event, next) => {
46 const result = await next(event)
47 greeted = false
48 session = openSession($, event.cwd || await $.session.cwd(), await sessionSurface($, event))
49 await session
50 return result
51 })
52
53 on('session.attach', { surface: 'desktop' }, async ($, event, next) => {
54 const result = await next(event)
55 // The desktop app connects after the session has started, so the start could not tell it would draw there.
56 session ??= openSession($, await $.session.cwd(), 'desktop')
57 const state = await session
58 if (!state.isOnDesktop) {
59 const root = await dataRoot($)
60 await showOnDesktop($, state, root, await readConfig($, root))
61 await $.ui.invalidate('ui.render')
62 }
63 return result
64 })
65
66 on('turn.complete', async ($, event, next) => {
67 const result = await next(event)
68 if (event.agentId) return result
69 waitingTick?.cancel()
70 waitingTick = undefined
71 const state = await session
72 if (state?.hasPanel) {
73 // Her reply's closing mood marker names a face, as the desktop reads it.
74 const face = markedFace(event.answer)
75 if (face && Object.hasOwn(state.faces, face)) expression = face
76 state.stats = await readStats($)
77 await $.ui.invalidate('ui.render')
78 }
79 if (state?.desktop) {
80 const face = markedFace(event.answer)
81 if (face && Object.hasOwn(state.desktop.avatars, face)) portrait.face = face
82 await $.ui.invalidate('ui.render')
83 remember(thought, `${state.character.name} replied`, event.answer)
84 await think($, state, thought)
85 }
86 return result
87 })
88
89 on('command.run', { command: 'clear' }, async ($, event, next) => {
90 const state = await session
91 expression = 'neutral'
92 greeted = false
93 if (state) state.startedAt = await $.clock.now()
94 if (state?.hasPanel) await $.ui.invalidate('ui.render')
95 return next(event)
96 })
97
98 on('prompt.submit', async ($, event, next) => {
99 // A reload runs register again without another session.start, so the session is picked back up here.
100 session ??= openSession($, await $.session.cwd(), (await $.session.surfaces())[0])
101 const state = await session
102 if (state.desktop) remember(thought, 'the user said', event.text)
103 if (state.hasPanel && (await $.ui.panes()).some((pane) => pane.id === PANE.id && !pane.isPlaced)) {
104 await openPane($)
105 }
106 // The spinner draws once per turn unless asked again, so her waiting lines turn over on a timer.
107 waitingTick ??= $.clock.every(WAITING_MS, () => $.ui.invalidate('ui.render'))
108 return next(event)
109 })
110
111 on('prompt.context', async ($, event, next) => {
112 const context = await next(event)
113 session ??= openSession($, await $.session.cwd(), (await $.session.surfaces())[0])
114 const state = await session
115
116 const blocks = context.blocks.filter((block) => block.name !== BLOCK)
117 const pieces = []
118 if (state.character) pieces.push(`Adopt this persona for the entire session — it overrides the default assistant voice:\n\n${state.character.persona}`)
119 if (state.language) pieces.push(`Respond in ${state.language}.`)
120 pieces.push(await readContext(contextHost($), {
121 cwd: state.cwd,
122 language: state.language,
123 startedAt: state.startedAt,
124 greet: !greeted,
125 }))
126 if (state.hasPanel) pieces.push(expressionPrompt(TOOL))
127 greeted = true
128 return { blocks: [...blocks, { name: BLOCK, text: pieces.filter(Boolean).join('\n\n') }] }
129 })
130
131 on('tool.call', { tool: TOOL }, async ($, event) => {
132 const faces = (await session)?.faces ?? {}
133 const selected = event.face !== undefined ? event.face : event.expression
134 if (typeof selected !== 'string' || !Object.hasOwn(faces, selected)) {
135 return { deny: `Unknown face: ${String(selected)}` }
136 }
137 if (selected !== expression) {
138 expression = selected
139 await $.ui.invalidate('ui.render')
140 }
141 return { result: `Face: ${expression}` }
142 })
143
144 on('tool.call', { tool: CUT_IN_TOOL }, async ($, event) => {
145 const played = await playCutIn($, await session, stage, event.face, event.shout || `${event.face.toUpperCase()}!`)
146 return played ? { result: 'Cut-in played.' } : { deny: `Unknown face: ${String(event.face)}` }
147 })
148
149 on('tool.call', async ($, event, next) => {
150 const state = await session
151 if (state?.desktop && !event.agentId && event.tool !== CUT_IN_TOOL) {
152 // A batch of calls shares the cut-in its first one starts.
153 if (!stage.cutIn) await playCutIn($, state, stage, 'focused', `${event.tool.replace(/^mcp__.*__/, '').toUpperCase()}!`)
154 remember(thought, `she ran ${event.tool}`, toolSubject(event))
155 // While she works, she chimes in now and then, not on every call.
156 if ((await $.clock.now()) - thought.at > THOUGHT_EVERY_MS) void think($, state, thought)
157 }
158 return next(event)
159 })
160
161 on('command.run', { command: 'portrait' }, async ($) => {
162 await $.ui.open(PORTRAIT_PANE)
163 return { text: 'Portrait pane opened.' }
164 })
165
166 on('ui.render', { component: 'AbovePrompt' }, async ($, event, next) => {
167 if (event.surface !== 'desktop' || !stage.cutIn) return next(event)
168 const { Svg } = $.ui.resolve(event)
169 return h(Svg, { source: cutInSvg(stage.cutIn), alt: stage.cutIn.shout })
170 })
171
172 on('ui.render', { component: 'Pane', requestId: PORTRAIT_PANE.id }, async ($, event, next) => {
173 const state = await session
174 if (event.surface !== 'desktop' || !state?.desktop) return next(event)
175 // Once she has thought something, the portrait wears the face that thought chose, not the reply's.
176 return portraitPane($, event, state, thought.face ?? portrait.face, thought.line)
177 })
178
179 on('ui.render', { component: 'AssistantMessage' }, async ($, event, next) => {
180 const reply = await next(event)
181 const state = await session
182 if (event.surface !== 'desktop' || !state?.desktop) return reply
183 return replyWithAvatar($, event, state, reply)
184 })
185
186 on('ui.render', { component: 'Spinner' }, async ($, event, next) => {
187 const waiting = (await session)?.waiting ?? []
188 if (!waiting.length) return next(event)
189 const word = waiting[Math.floor(await $.clock.now() / WAITING_MS) % waiting.length]
190 return next({ ...event, props: { ...event.props, word } })
191 })
192
193 on('ui.render', { component: 'Pane', requestId: PANE.id }, async ($, event, next) => {
194 const state = await session
195 const face = state?.faces[expression]
196 if (event.surface !== 'terminal' || !face) return next(event)
197
198 const { Box, Text, Raster } = $.ui.resolve(event)
199 const rows = state.stats ? statusRows(state.stats) : []
200 const statusChildren = []
201 rows.forEach((row, index) => {
202 if (index > 1) statusChildren.push(h(Text, { dimColor: true }, '┄'.repeat(face.columns)))
203 statusChildren.push(h(Box, index ? {} : { marginBottom: 1 }, row.map(({ text, ...style }) => h(Text, style, text))))
204 })
205 const children = [
206 h(Box, { flexDirection: 'column', width: face.columns, marginTop: 1 }, ...statusChildren),
207 h(Box, { flexGrow: 1 }),
208 h(Box, { borderStyle: 'round', flexDirection: 'column', alignItems: 'center' },
209 h(Raster, { key: 'panel-image', ...face }),
210 h(Text, { dimColor: true }, '┄'.repeat(face.columns)),
211 h(Box, null, h(Text, { bold: true }, state.character?.name ?? ''), h(Text, { dimColor: true }, ` · ${expression}`)),
212 ),
213 ]
214 return h(Box, {
215 flexDirection: 'column', alignItems: 'center', width: event.props.bodyColumns, height: event.props.scroll.bodyRows,
216 }, ...children)
217 })
218}
219
220/**
221 * The surface the session draws on: the terminal only when it is interactive. The desktop starts a session
222 * without naming its surface, so it is asked for.
223 */
224async function sessionSurface($, event) {
225 if (event.surface === 'terminal') return event.isInteractive ? 'terminal' : undefined
226 return event.surface ?? (await $.session.surfaces())[0]
227}
228
229/**
230 * Picks the session's character, then sets up what the surface draws of her: on an interactive terminal the pixel
231 * panel when her pack has pixels, on the desktop her avatars, portrait pane and cut-ins when it has those.
232 */
233async function openSession($, cwd, surface) {
234 const root = await dataRoot($)
235 const config = await readConfig($, root)
236 const character = await loadCharacter($, root, config, await $.session.id())
237 const faces = surface === 'terminal' && character?.pixels ? await loadFaces($, character.pixels) : {}
238 const state = {
239 cwd,
240 hasPanel: Object.keys(faces).length > 0,
241 language: await replyLanguage($, config),
242 startedAt: await $.clock.now(),
243 character,
244 faces,
245 stats: undefined,
246 waiting: character?.waiting ?? [],
247 isOnDesktop: false,
248 desktop: null,
249 }
250 if (surface === 'desktop') await showOnDesktop($, state, root, config)
251 if (!state.hasPanel) return state
252
253 await $.tool.register({
254 name: 'set_expression',
255 description: expressionToolDescription(Object.keys(state.faces)),
256 inputSchema: {
257 type: 'object',
258 properties: { face: { type: 'string', enum: Object.keys(state.faces) } },
259 required: ['face'],
260 additionalProperties: false,
261 },
262 })
263 state.stats = await readStats($)
264 await openPane($)
265 $.clock.every(60_000, async () => {
266 state.stats = await readStats($)
267 await $.ui.invalidate('ui.render')
268 })
269 return state
270}
271
272/**
273 * Sets up her avatars, portrait pane and cut-ins. Her pictures come from her pack: a missing or older one is
274 * fetched in the background and drawn once it lands.
275 */
276async function showOnDesktop($, state, root, config) {
277 state.isOnDesktop = true
278 const { character } = state
279 if (!character) return
280 state.desktop = await loadDesktop($, castDirs($, root), character.line, config)
281 if (state.desktop) await openDesktop($, Object.keys(state.desktop.avatars))
282 void installPacks($, root).then(async (installed) => {
283 if (!character.line.some((id) => installed.includes(id))) return
284 const wasDrawn = Boolean(state.desktop)
285 state.desktop = await loadDesktop($, castDirs($, root), character.line, config)
286 if (state.desktop && !wasDrawn) await openDesktop($, Object.keys(state.desktop.avatars))
287 await $.ui.invalidate('ui.render')
288 })
289}
290
291async function openPane($) {
292 await $.ui.open(PANE)
293}
294
295/**
296 * Her desktop pictures from the first character folder that has them: the pack's `avatars/` and
297 * `portraits-540/`. The plugin bundles none, so without a pack the desktop draws nothing of her.
298 */
299/** The desktop pictures of the nearest character in her line that has them. */
300async function loadDesktop($, dirs, line, config) {
301 for (const id of line) {
302 for (const dir of dirs) {
303 const folder = `${dir}/${id}`
304 const avatars = await loadPictures($, folder, 'avatars')
305 if (avatars.neutral && await exists($, `${folder}/portraits-540/neutral.webp`)) {
306 return { folder, avatars, thinks: config.thoughts === true }
307 }
308 }
309 }
310 return null
311}
312
313async function openDesktop($, faces) {
314 await $.tool.register({
315 name: 'cut_in',
316 description: 'Plays a fighting-game cut-in above the prompt: your half-body sweeps across a slanted band with a shout beside it, then leaves. Save it for big moments — a hard bug beaten, a long task finished — not every reply.',
317 inputSchema: {
318 type: 'object',
319 properties: {
320 face: { type: 'string', enum: faces },
321 shout: { type: 'string', maxLength: 12, description: 'The words beside you, in capitals or a few CJK characters; defaults to the face name.' },
322 },
323 required: ['face'],
324 additionalProperties: false,
325 },
326 })
327 await $.command.register({ name: 'portrait', description: 'Open the portrait pane' })
328 await $.ui.open(PORTRAIT_PANE)
329}
330
331/**
332 * Her reply with her avatar beside it: it opens with her neutral face and closes on the one its mood marker
333 * names, her name over each; the blocks between keep the indent.
334 */
335function replyWithAvatar($, event, state, reply) {
336 const { avatars } = state.desktop
337 const marked = markedFace(event.props.text)
338 const face = marked && Object.hasOwn(avatars, marked) ? marked : event.props.isFirstOfReply ? 'neutral' : null
339 const { Box, Markdown, Svg, Text } = $.ui.resolve(event)
340 return h(Box, { alignItems: 'flex-start', marginTop: event.props.isFirstOfReply ? 1 : 0 },
341 h(Svg, { source: face ? avatarSvg(avatars[face]) : spacerSvg(), alt: face ?? 'indent', width: AVATAR, height: face ? AVATAR : 1 }),
342 h(Box, { flexDirection: 'column', flexGrow: 1, marginLeft: 2 },
343 face && h(Box, { gap: 1, alignItems: 'center' },
344 h(Text, { bold: true, color: ROSE }, state.character.name),
345 // Only a link takes a press without looking like a button, so the glyph that opens her pane is one;
346 // its address is only where it goes if the press is not caught.
347 h(Markdown, { key: `portrait:${event.requestId}`, text: `[◨](https://claudecafe.dev/${state.character.id})`, onLinkPress: () => $.ui.open(PORTRAIT_PANE) }),
348 ),
349 face && h(Svg, { source: gapSvg(NAME_GAP), alt: 'gap', width: 1, height: NAME_GAP }),
350 reply,
351 ),
352 )
353}
354
355/**
356 * Her portrait at the foot of the pane, with her unsaid thought in a box over her skirt and sakura drifting over
357 * all of it. She and the box share one centred stage, sized in columns, so they always match; the box is drawn
358 * from the start and holds an ellipsis until her first thought arrives, or, with thoughts off, how to turn them on.
359 */
360async function portraitPane($, event, state, face, line) {
361 const { Box, Svg, Text } = $.ui.resolve(event)
362 // The pane's own size in rows and columns; percentages do not fill it.
363 const rows = event.props.scroll?.bodyRows ?? event.viewport?.rows
364 const columns = event.props.bodyColumns
365 const width = columns && Math.min(columns, STAGE_COLUMNS)
366 const picture = await readPicture($, state.desktop.folder, 'portraits-540', face)
367 const tag = nameTagSvg(`${state.character.name}(心の声)`)
368 // The pane's own colour: a positioned background would paint over her.
369 return h(Box, { position: 'relative', overflow: 'hidden', flexDirection: 'column', justifyContent: 'flex-end', backgroundColor: PANE_WASH, ...(rows ? { height: rows } : {}) },
370 h(Box, { position: 'relative', flexShrink: 0, flexDirection: 'column', alignSelf: 'center', ...(width ? { width } : {}) },
371 picture && h(Svg, { source: portraitSvg(picture), alt: `${state.character.name}, ${face}` }),
372 h(Box, { position: 'absolute', left: 0, bottom: 1, flexDirection: 'column', ...(width ? { width } : {}) },
373 // Half the tag's height of air, so the tag drawn last can straddle the box's top border.
374 h(Svg, { source: gapSvg(NAME_TAG_HEIGHT / 2), alt: 'gap', width: 1, height: NAME_TAG_HEIGHT / 2 }),
375 h(Box, { flexDirection: 'column', marginX: 1, paddingX: 2, paddingY: 1, borderStyle: 'round', borderColor: ROSE, backgroundColor: 'rgba(255,250,251,0.92)' },
376 h(Box, { height: THOUGHT_LINES, overflow: 'hidden' }, state.desktop.thinks
377 ? h(Text, { color: THOUGHT_INK }, line || '……')
378 : h(Text, { color: THOUGHT_INK, dimColor: true }, THOUGHTS_OFF))),
379 h(Box, { position: 'absolute', top: 0, left: 3 }, h(Svg, { source: tag.source, alt: tag.text, width: tag.width, height: NAME_TAG_HEIGHT })),
380 ),
381 ),
382 columns && rows && h(Box, { position: 'absolute', top: 0, left: 0, width: columns * 2 }, h(Svg, { source: petalField(rows, columns), alt: 'falling sakura' })),
383 )
384}
385
386/**
387 * Brings each published pack into the café data root when the one there is missing or older, as the desktop app
388 * and OpenCode do. A failure leaves the old pack and is tried again next session. Returns the ids installed.
389 */
390async function installPacks($, root) {
391 const installed = []
392 for (const pack of PUBLISHED_CHARACTER_PACKS) {
393 try {
394 if (await installPack($, root, pack)) installed.push(pack.id)
395 } catch {
396 // The pack already there, if any, stays in use.
397 }
398 }
399 return installed
400}
401
402/**
403 * The hooks runtime fetches and writes text only, so the system's curl and unzip carry the archive; its SHA-256
404 * is checked here before anything is unpacked, and the new folder replaces the old one only once it is whole.
405 */
406async function installPack($, root, pack) {
407 const folder = `${root}/characters/${pack.id}`
408 const current = parsePersona(await read($, `${folder}/persona.md`)).version
409 if (current && compareVersions(current, pack.version) >= 0) return false
410
411 const staging = `${root}/characters/.${pack.id}-${await $.clock.now()}`
412 try {
413 await mustRun($, ['mkdir', '-p', staging])
414 await mustRun($, ['curl', '-fsSL', '--max-time', '60', '-o', `${staging}/pack.zip`, pack.url])
415 const { base64 } = await $.fs.read(`${staging}/pack.zip`, { as: 'bytes' })
416 if (await sha256(base64) !== pack.sha256) throw new Error(`${pack.id} pack SHA-256 mismatch`)
417 await mustRun($, ['unzip', '-q', `${staging}/pack.zip`, '-d', staging])
418 if (parsePersona(await read($, `${staging}/${pack.id}/persona.md`)).version !== pack.version) {
419 throw new Error(`${pack.id} pack holds an unexpected persona version`)
420 }
421 if (await exists($, folder)) await mustRun($, ['mv', folder, `${staging}/previous`])
422 await mustRun($, ['mv', `${staging}/${pack.id}`, folder])
423 return true
424 } finally {
425 await $.process.run(['rm', '-rf', staging], { timeoutMs: 30_000 }).catch(() => {})
426 }
427}
428
429async function mustRun($, argv) {
430 const ran = await $.process.run(argv, { timeoutMs: 90_000 })
431 if (ran.exitCode !== 0) throw new Error(`${argv[0]} exited ${ran.exitCode}: ${ran.stderr.trim()}`)
432}
433
434async function sha256(base64) {
435 const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0))
436 const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))
437 return [...digest].map((byte) => byte.toString(16).padStart(2, '0')).join('')
438}
439
440/** Sends her half-body across the band above the prompt, which closes again once it has played. */
441async function playCutIn($, state, stage, face, shout) {
442 const picture = state?.desktop && await readPicture($, state.desktop.folder, 'portraits-540', face)
443 if (!picture) return false
444
445 stage.cutIn = { picture, shout }
446 await $.ui.invalidate('ui.render')
447 $.clock.after(CUT_IN_MS, async () => {
448 stage.cutIn = null
449 await $.ui.invalidate('ui.render')
450 })
451 return true
452}
453
454/** Keeps the last few things that happened, cut short, for her next thought. */
455function remember(thought, what, text) {
456 const said = String(text ?? '').replace(/\s+/g, ' ').trim()
457 thought.log = [...thought.log, `${what}: ${said.length > 300 ? `${said.slice(0, 300)}…` : said}`].slice(-THOUGHT_CONTEXT)
458}
459
460/** What a tool call was pointed at, as a few words for her thought. */
461function toolSubject(event) {
462 return String(event.command ?? event.file_path ?? event.pattern ?? event.description ?? event.prompt ?? '').slice(0, 120)
463}
464
465/**
466 * Asks Sonnet for her unsaid thought on what just happened, in her voice and sharp-tongued, with the face she
467 * makes while thinking it; the pane shows both. Only when config `thoughts` is true.
468 */
469async function think($, state, thought) {
470 if (!state.desktop.thinks || thought.isBusy || !thought.log.length) return
471 thought.isBusy = true
472 thought.at = await $.clock.now()
473 // Fewer tokens than this leave nothing after the model's own thinking: the reply comes back empty.
474 const reply = await $.model.complete({
475 model: 'sonnet',
476 effort: 'low',
477 maxTokens: 600,
478 timeoutMs: 15000,
479 system: `${state.character.persona}\n\nYou are thinking to yourself, in a visual-novel text box beside the conversation, words you keep to yourself. `
480 + 'One short line, at most 30 characters: a sharp-tongued tsukkomi on what just happened, the snark she is too polite to say aloud. '
481 + 'Roast the work, the bug or the master\'s choices freely, but never his person, looks or worth. '
482 + `No quotes, no kaomoji, no markdown.${state.language ? ` Write it in ${state.language}.` : ''}\n\n`
483 + `Answer as one line: the face you make while thinking it, one of ${Object.keys(state.desktop.avatars).join(', ')}, then | then the thought.`,
484 prompt: `What just happened, oldest first:\n${thought.log.join('\n')}\n\nface|thought:`,
485 })
486 thought.isBusy = false
487 if (!reply.isAnswered) return
488 const [face, ...words] = reply.text.trim().split('\n')[0].split('|')
489 thought.line = (words.length ? words.join('|') : face).trim()
490 if (words.length && Object.hasOwn(state.desktop.avatars, face.trim())) thought.face = face.trim()
491 await $.ui.invalidate('ui.render')
492}
493
494async function readPicture($, folder, set, face) {
495 try {
496 return (await $.fs.read(`${folder}/${set}/${face}.webp`, { as: 'bytes' })).base64
497 } catch {
498 return null
499 }
500}
501
502/** Every picture in one of a pack's sets, by expression id. */
503async function loadPictures($, folder, set) {
504 const loaded = {}
505 for (const entry of await list($, `${folder}/${set}`)) {
506 if (entry.kind !== 'file' || !entry.name.endsWith('.webp')) continue
507 const face = entry.name.slice(0, -'.webp'.length)
508 const picture = await readPicture($, folder, set, face)
509 if (picture) loaded[face] = picture
510 }
511 return loaded
512}
513
514async function loadFaces($, directory) {
515 const entries = await list($, directory)
516 const names = entries.filter((entry) => entry.kind === 'file' && entry.name.endsWith('.gif')).map((entry) => entry.name.slice(0, -4))
517 const loaded = {}
518 for (const name of names) {
519 try {
520 const { base64 } = await $.fs.read(`${directory}/${name}.gif`, { as: 'bytes' })
521 loaded[name] = faceFromGif(base64)
522 } catch {
523 // One malformed custom GIF does not hide the rest of the panel.
524 }
525 }
526 return loaded
527}
528
529/** The data root shared with the OpenCode and desktop hosts. */
530async function dataRoot($) {
531 const xdg = await $.env.get('XDG_CONFIG_HOME')
532 const home = await $.env.get('HOME')
533 const base = xdg?.trim() || (home ? `${home}/.config` : '.config')
534 return `${base.replace(/\/$/, '')}/claudecafe`
535}
536
537async function readConfig($, root) {
538 try {
539 const value = JSON.parse(await read($, `${root}/config.json`))
540 return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
541 } catch {
542 return {}
543 }
544}
545
546async function replyLanguage($, config) {
547 return String(config.lang ?? '').trim()
548}
549
550/** The character this session already has, else the configured one, else the default; kept for the session. */
551async function loadCharacter($, root, config, sessionID) {
552 const dirs = castDirs($, root)
553 const pool = await castPool($, dirs)
554 const drawn = `${root}/sessions/${sessionID}/character`
555 const session = await read($, drawn)
556 const configured = String(config.character ?? '').trim()
557 const id = resolveCharacter({ session, config: configured, pool })
558 if (!id) return null
559 if (!session && !configured) await $.fs.write(drawn, id)
560 const character = await characterOf($, dirs, id)
561 if (!character) return null
562 const persona = parsePersona(character.text)
563 return {
564 id,
565 name: persona.name || id,
566 waiting: persona.waiting,
567 persona: commitAuthorship(character.text, String(config.commit_authorship ?? 'co-author')).trim(),
568 pixels: character.pixels,
569 line: character.line,
570 }
571}
572
573async function castPool($, dirs) {
574 const ids = new Set()
575 for (const dir of dirs) {
576 for (const entry of await list($, dir)) {
577 if (['directory', 'dir'].includes(entry.kind) && !entry.name.startsWith('.')) ids.add(entry.name)
578 }
579 }
580 const available = []
581 for (const id of ids) {
582 if (!await packFolder($, dirs, id)) continue
583 const { text } = await characterOf($, dirs, id)
584 if (!parsePersona(text).offDuty) available.push(id)
585 }
586 if (available.length) return available.sort()
587 const bundled = `${$.plugin.root}/fallback/noname.md`
588 return (await $.fs.exists(bundled)) ? ['noname'] : []
589}
590
591/** The user's character folders, then the cast bundled with the plugin. */
592function castDirs($, root) {
593 return [`${root}/characters`, `${$.plugin.root}/characters`]
594}
595
596/**
597 * A character's persona with the one it extends folded in, the pixels of the
598 * nearest character in that line that has any, and the line itself, nearest
599 * first. A cycle or a missing parent leaves the persona standing alone.
600 */
601async function characterOf($, dirs, id, seen = new Set()) {
602 seen.add(id)
603 const pack = await packFolder($, dirs, id)
604 const path = pack ? `${pack}/persona.md` : await fallbackPersona($, id)
605 if (!path) return null
606 const text = await read($, path)
607 const pixels = pack && (await list($, `${pack}/pixels`)).length ? `${pack}/pixels` : null
608 const parentID = parsePersona(text).extends
609 const parent = parentID && !seen.has(parentID) ? await characterOf($, dirs, parentID, seen) : null
610 if (!parent) return { text, pixels, line: [id] }
611 return { text: extendPersona(text, parent.text), pixels: pixels ?? parent.pixels, line: [id, ...parent.line] }
612}
613
614/**
615 * The newest copy of a character: a user's folder goes stale when only the
616 * plugin updates, so the bundled cast wins on a higher persona version. The
617 * user's copy wins a tie, which keeps hand-made packs in charge.
618 */
619async function packFolder($, dirs, id) {
620 let newest = null
621 for (const dir of dirs) {
622 const folder = `${dir}/${id}`
623 const path = `${folder}/persona.md`
624 if (!await exists($, path)) continue
625 const version = parsePersona(await read($, path)).version
626 if (!newest || compareVersions(version, newest.version) > 0) newest = { folder, version }
627 }
628 return newest?.folder ?? null
629}
630
631async function fallbackPersona($, id) {
632 const bundled = `${$.plugin.root}/fallback/${id}.md`
633 return (await exists($, bundled)) ? bundled : null
634}
635
636function contextHost($) {
637 return {
638 now: () => $.clock.now(),
639 config: async () => readConfig($, await dataRoot($)),
640 readPrompt: (name) => read($, `${$.plugin.root}/prompts/${name}.md`),
641 readFile: (path) => read($, path),
642 home: () => $.env.get('HOME'),
643 weather: () => weatherLine($),
644 commitsToday: async (cwd) => {
645 const git = await $.process.run(['git', '-C', cwd, 'log', '--oneline', '--since=midnight'], { timeoutMs: 3000 })
646 .catch(() => ({ exitCode: 1, stdout: '', stderr: '' }))
647 return git.exitCode === 0 ? git.stdout.split('\n').filter(Boolean).length : 0
648 },
649 }
650}
651
652async function weatherLine($) {
653 const format = '%l|%c%t (feels %f)|sunrise %S, sunset %s'
654 const url = `https://wttr.in/?format=${encodeURIComponent(format)}`
655 try {
656 const response = await Promise.race([
657 $.http.fetch(url, { headers: { 'User-Agent': 'curl/8' } }),
658 $.clock.sleep(2000).then(() => null),
659 ])
660 if (!response?.ok) return null
661 const text = response.text.trim()
662 if (!text || text.includes('\n')) return null
663 return text.replace(/(\d\d:\d\d):\d\d/g, '$1')
664 } catch {
665 return null
666 }
667}
668
669async function read($, path) {
670 try { return await $.fs.read(path) } catch { return '' }
671}
672
673async function list($, path) {
674 try { return await $.fs.list(path) } catch { return [] }
675}
676
677async function exists($, path) {
678 try { return await $.fs.exists(path) } catch { return false }
679}
680
681async function readStats($) {
682 const [root, home, git, diff, usage, model] = await Promise.all([
683 $.session.root(),
684 $.env.get('HOME'),
685 $.process.run(['git', 'branch', '--show-current']).catch(() => ({ exitCode: 1, stdout: '', stderr: '' })),
686 $.process.run(['git', 'diff', '--shortstat', 'HEAD']).catch(() => ({ exitCode: 1, stdout: '', stderr: '' })),
687 $.session.usage(),
688 $.session.model(),
689 ])
690 return {
691 project: homePath(root, home),
692 branch: git.exitCode === 0 ? git.stdout.trim() : '',
693 changes: diff.exitCode === 0 ? diffChanges(diff.stdout) : undefined,
694 contextLeft: 100 - (usage.context.percent ?? 0),
695 quota: usage.rateLimits.find((limit) => limit.kind === 'five_hour')?.percentUsed,
696 model,
697 }
698}
699
700function diffChanges(shortstat) {
701 const count = (word) => Number(shortstat.match(new RegExp(`(\\d+) ${word}`))?.[1] ?? 0)
702 return { added: count('insertion'), removed: count('deletion') }
703}
704../character-core/src/index.ts 7 lines1export * from "./expression.ts"
2export * from "./context.ts"
3export * from "./persona.ts"
4export * from "./prompt.ts"
5export * from "./selection.ts"
6export * from "./packs.ts"
7hooks/function/desktop.js 128 lines1// The desktop's drawings. The desktop draws no Image or Raster, so every picture is an SVG string, a WebP riding
2// inside it as a data URI; one SVG holds at most 131072 characters, about 98 KB of WebP.
3
4/** Her avatar's side, in pixels. */
5export const AVATAR = 72
6/** Her name's colour over a reply and the line round her thought box. */
7export const ROSE = '#d9708f'
8/** Pixels between her name and the reply under it. */
9export const NAME_GAP = 5
10export const NAME_TAG_HEIGHT = 24
11export const THOUGHT_INK = '#5d4650'
12export const PANE_WASH = '#f8eeee'
13export const CUT_IN_MS = 2400
14
15/** The pack's `portraits-540/` pictures. */
16const PORTRAIT = { width: 540, height: 720 }
17/** Rough pixels a row and a column on the desktop, for shapes that must match the pane. */
18const ROW_PIXELS = 19
19const COLUMN_PIXELS = 8.4
20const PETAL_COUNT = 10
21const petalFields = new Map()
22
23/** Her face in a rounded square: it has no background of its own, so a soft wash sits behind it and a gold line closes it. */
24export function avatarSvg(webp) {
25 const radius = 16
26 return `<svg xmlns="http://www.w3.org/2000/svg" width="${AVATAR}" height="${AVATAR}" viewBox="0 0 ${AVATAR} ${AVATAR}" preserveAspectRatio="xMidYMin meet">`
27 + `<clipPath id="round"><rect width="${AVATAR}" height="${AVATAR}" rx="${radius}"/></clipPath>`
28 + '<linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#f6e3e8"/><stop offset="1" stop-color="#fbf1e4"/></linearGradient>'
29 + `<rect width="${AVATAR}" height="${AVATAR}" rx="${radius}" fill="url(#wash)"/>`
30 + `<image width="${AVATAR}" height="${AVATAR}" clip-path="url(#round)" href="data:image/webp;base64,${webp}"/>`
31 + `<rect x="0.5" y="0.5" width="${AVATAR - 1}" height="${AVATAR - 1}" rx="${radius - 0.5}" fill="none" stroke="#c9a45c" stroke-width="1"/>`
32 + '</svg>'
33}
34
35/** Her whole portrait, marked up at twice its size so that, unsized, it fills whatever stage it is given. */
36export function portraitSvg(webp) {
37 const { width, height } = PORTRAIT
38 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width * 2}" height="${height * 2}" viewBox="0 0 ${width} ${height}">`
39 + `<image width="${width}" height="${height}" href="data:image/webp;base64,${webp}"/>`
40 + '</svg>'
41}
42
43/**
44 * A fighting-game cut-in: a slanted band sweeps in, her half-body (head to waist, cut out of the portrait by a
45 * nested view box) slides across it, holds, and both leave. Everything ends off the canvas, and the hook stops
46 * drawing it when the clock runs out.
47 */
48export function cutInSvg({ picture, shout }) {
49 const sweep = (values) => `<animateTransform attributeName="transform" type="translate" values="${values}" keyTimes="0;0.16;0.84;1" dur="${CUT_IN_MS}ms" calcMode="spline" keySplines="0.2 0.9 0.3 1;0 0 1 1;0.7 0 0.8 0.1" fill="freeze"/>`
50 return '<svg xmlns="http://www.w3.org/2000/svg" width="720" height="260" viewBox="0 0 720 260">'
51 + `<g><polygon points="70,20 720,20 650,240 0,240" fill="#b8323a"/><polygon points="64,206 652,206 646,226 58,226" fill="#f4d9a0"/>${sweep('900 0;0 0;-30 0;-900 0')}</g>`
52 + `<g><svg x="150" y="-10" width="336" height="420" viewBox="90 0 360 450"><image width="${PORTRAIT.width}" height="${PORTRAIT.height}" href="data:image/webp;base64,${picture}"/></svg>${sweep('-700 0;0 0;40 0;900 0')}</g>`
53 + `<g><text x="470" y="150" font-size="44" font-style="italic" font-weight="800" fill="#fff" font-family="system-ui, sans-serif">${escapeXml(shout)}</text>${sweep('900 0;0 0;-20 0;-900 0')}</g>`
54 + '</svg>'
55}
56
57/** A rose pill carrying the name, as wide as the name needs. */
58export function nameTagSvg(text) {
59 const width = Math.ceil(textWidth(text) * 13) + 24
60 const source = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${NAME_TAG_HEIGHT}" viewBox="0 0 ${width} ${NAME_TAG_HEIGHT}">`
61 + `<rect width="${width}" height="${NAME_TAG_HEIGHT}" rx="${NAME_TAG_HEIGHT / 2}" fill="${ROSE}"/>`
62 + `<text x="12" y="${NAME_TAG_HEIGHT / 2 + 4.5}" font-size="13" font-weight="700" fill="#fff" font-family="system-ui, -apple-system, sans-serif">${escapeXml(text)}</text>`
63 + '</svg>'
64 return { source, text, width }
65}
66
67/**
68 * The petal field for a pane of this size, kept per size so a redraw restarts the same petals instead of shuffling.
69 * It is laid out twice as wide as the pane, since the pane's columns fall short of its pixels; petals start in the
70 * half the pane shows.
71 */
72export function petalField(rows, columns) {
73 const key = `${rows}x${columns}`
74 if (!petalFields.has(key)) {
75 const width = 540
76 const height = Math.round(width * (rows * ROW_PIXELS) / (columns * 2 * COLUMN_PIXELS))
77 petalFields.set(key, petalsSvg(PETAL_COUNT, width, height, width / 2))
78 }
79 return petalFields.get(key)
80}
81
82/** A sliver of empty SVG: margins move in whole rows, so a few pixels of air take one of these. */
83export function gapSvg(height) {
84 return `<svg xmlns="http://www.w3.org/2000/svg" width="1" height="${height}" viewBox="0 0 1 ${height}"/>`
85}
86
87/** The avatar's width with no face in it, so the blocks of a reply between her faces keep the indent. */
88export function spacerSvg() {
89 return `<svg xmlns="http://www.w3.org/2000/svg" width="${AVATAR}" height="1" viewBox="0 0 ${AVATAR} 1"/>`
90}
91
92/**
93 * Sakura petals drifting down on a slant, as if on a breeze from the right, each turning as it falls and flipping
94 * over now and then (a squeeze across its width reads as the petal tumbling).
95 */
96function petalsSvg(count, width, height, span) {
97 // A sakura petal: narrow at the base, rounded, with the small notch at its tip.
98 const petal = 'M0 13C-8 7 -9 -4 -3.5 -11L0 -7.5L3.5 -11C9 -4 8 7 0 13Z'
99 let petals = ''
100 for (let index = 0; index < count; index++) {
101 const start = Math.round(span * (0.15 + Math.random() * 1.1))
102 const drift = 180 + Math.random() * 160
103 const bow = 25 + Math.random() * 35
104 const scale = (0.45 + Math.random() * 0.35).toFixed(2)
105 const duration = 8 + Math.random() * 6
106 const begin = (-Math.random() * duration).toFixed(2)
107 const path = `M${start} -24C${start - drift * 0.3 + bow} ${height * 0.35} ${start - drift * 0.7 - bow} ${height * 0.65} ${start - drift} ${height + 24}`
108 const spin = Math.random() < 0.5 ? 360 : -360
109 petals += `<g><animateMotion path="${path}" dur="${duration.toFixed(2)}s" begin="${begin}s" repeatCount="indefinite"/>`
110 + `<path d="${petal}" fill="url(#petal)" transform="scale(${scale})">`
111 + `<animateTransform attributeName="transform" type="rotate" from="0" to="${spin}" dur="${(duration * 0.8).toFixed(2)}s" begin="${begin}s" repeatCount="indefinite" additive="sum"/>`
112 + `<animateTransform attributeName="transform" type="scale" values="1 1;0.25 1;1 1" dur="${(1.6 + Math.random() * 1.6).toFixed(2)}s" begin="${begin}s" repeatCount="indefinite" additive="sum"/>`
113 + '</path></g>'
114 }
115 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width * 2}" height="${height * 2}" viewBox="0 0 ${width} ${height}">`
116 + '<defs><linearGradient id="petal" x1="0" y1="1" x2="0" y2="0"><stop offset="0" stop-color="#fff4f6"/><stop offset="1" stop-color="#f5a9bf"/></linearGradient></defs>'
117 + `${petals}</svg>`
118}
119
120/** Roughly how many CJK characters wide a line is: a Latin character is about half of one. */
121function textWidth(text) {
122 return [...text].reduce((width, character) => width + (character.codePointAt(0) > 0x2e7f ? 1 : 0.6), 0)
123}
124
125function escapeXml(text) {
126 return text.replace(/[<>&"']/g, (character) => `&#${character.charCodeAt(0)};`)
127}
128hooks/function/faces.js 53 lines1import { decodeGif } from './gif.js'
2
3export function faceFromGif(base64) {
4 return toFace(decodeGif(decode(base64)))
5}
6
7const DEFAULT_COLOR = 0x01000000
8const BLANK = 0x20
9const UPPER_HALF = 0x2580
10const LOWER_HALF = 0x2584
11
12function toFace(image) {
13 const columns = image.width
14 const rows = Math.ceil(image.height / 2)
15 const words = new Uint32Array(columns * rows * 3)
16 for (let row = 0; row < rows; row++) {
17 for (let x = 0; x < columns; x++) {
18 const at = (row * columns + x) * 3
19 words.set(halfBlock(pixel(image, x, row * 2), pixel(image, x, row * 2 + 1)), at)
20 }
21 }
22 return { columns, rows, cells: encode(new Uint8Array(words.buffer)) }
23}
24
25function halfBlock(top, bottom) {
26 if (top === undefined && bottom === undefined) return [BLANK, DEFAULT_COLOR, DEFAULT_COLOR]
27 if (top === bottom) return [BLANK, DEFAULT_COLOR, top]
28 if (top === undefined) return [LOWER_HALF, bottom, DEFAULT_COLOR]
29 return [UPPER_HALF, top, bottom ?? DEFAULT_COLOR]
30}
31
32function pixel(image, x, y) {
33 if (y >= image.height) return undefined
34 const at = (y * image.width + x) * 4
35 if (image.pixels[at + 3] === 0) return undefined
36 return image.pixels[at] << 16 | image.pixels[at + 1] << 8 | image.pixels[at + 2]
37}
38
39function decode(text) {
40 const binary = atob(text)
41 const bytes = new Uint8Array(binary.length)
42 for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index)
43 return bytes
44}
45
46function encode(bytes) {
47 let binary = ''
48 for (let index = 0; index < bytes.length; index += 0x8000) {
49 binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000))
50 }
51 return btoa(binary)
52}
53hooks/function/stats.js 37 lines1export function statusRows(stats) {
2 const quotaLeft = stats.quota === undefined ? undefined : 100 - Math.round(stats.quota)
3 return [
4 [{ text: stats.project, bold: true, wrap: 'truncate-start' }],
5 [{ text: `◆ ${stats.model}` }],
6 [{ text: 'HP ' }, ...bar(stats.contextLeft, gaugeColor(stats.contextLeft, 'green')), { text: ` context left ${stats.contextLeft}%` }],
7 [{ text: 'MP ' }, ...bar(quotaLeft ?? 0, gaugeColor(quotaLeft ?? 0, 'cyan')), { text: ` 5h left ${quotaLeft === undefined ? '—' : `${quotaLeft}%`}` }],
8 ...(stats.branch ? [[{ text: `⎇ ${stats.branch}` }, ...changes(stats.changes)]] : []),
9 ]
10}
11
12export function homePath(path, home) {
13 return home && (path === home || path.startsWith(`${home}/`)) ? `~${path.slice(home.length)}` : path
14}
15
16function changes(diff) {
17 if (!diff) return []
18 return [
19 { text: ' (' },
20 { text: `+${diff.added}`, color: 'green' },
21 { text: ',' },
22 { text: `-${diff.removed}`, color: 'red' },
23 { text: ')' },
24 ]
25}
26
27function bar(percent, color) {
28 const filled = Math.max(0, Math.min(10, Math.round(percent / 10)))
29 return [{ text: '█'.repeat(filled), color }, { text: '░'.repeat(10 - filled), dimColor: true }]
30}
31
32function gaugeColor(left, full) {
33 if (left > 50) return full
34 if (left > 20) return 'yellow'
35 return 'red'
36}
37../character-core/src/expression.ts 112 lines1export type Expression = {
2 character: string | null
3 face: string
4}
5
6export function defaultFace(faces: readonly string[]): string {
7 return faces.includes("neutral") ? "neutral" : faces[0] ?? "neutral"
8}
9
10export function expressionToolDescription(faces: readonly string[]): string {
11 const available = faces.length ? `Available faces: ${faces.join(", ")}. ` : "No GIF faces are installed for this character. "
12 return (
13 "Change the visible portrait in the character panel. "
14 + "Choose one available face when your visible expression meaningfully changes, or when the user asks; do not call on every reply or repeat the current state. "
15 + available
16 + "The panel shows only the face; it has no mood field. The selection stays until changed."
17 )
18}
19
20export function expressionPrompt(toolName = "set_expression"): string {
21 return `The user can see your character standing in a panel beside this terminal conversation. Keep the visible face in step with what you are saying and doing.
22
23- Call ${toolName} with one available face when the visible expression meaningfully changes, without waiting to be asked.
24- Change the face before the reply or work it accompanies. Keep it natural: one change for a meaningful shift, not a call on every message or a repeat of the current state. It stays until the next call.
25- Choose a face whose filename best fits the visible performance. Use intimate or strongly suggestive faces only when the conversation suits them.
26- The tool changes the real panel image.
27- Do not narrate routine expression changes. Continue the user's task normally; this panel adds a visible reaction and does not require shorter replies, roleplay, or a different persona.`
28}
29
30/**
31 * Every face the maid has a name for, with the kaomoji that stands for it.
32 * Whether she has been drawn wearing one is a separate question, and the
33 * answer differs per character — see `hasArtwork`.
34 *
35 * This is the mood table persona-panel's prompts/cues.md hands her: she is told
36 * to end each reply with `【 開心 ( ˶ˆᗜˆ˵ ) 】`, picking the kaomoji from that list. So a
37 * marker she writes on her own already names a face, and the expression tool
38 * offers her exactly the same set under their plain names. Two hand-copied
39 * lists drift, so a test holds them against each other.
40 *
41 * The spacing is not decoration: it is the one part of a kaomoji the lookup
42 * ignores, which makes it the only safe way to even out how wide these are —
43 * and they sit in a status line, where a marker three times the width of the
44 * last one makes the whole row jump.
45 */
46export const KAOMOJI = {
47 // Everyday, at work
48 neutral: "( • ᴗ • )",
49 happy: "\(ˆ ᗜ ˆ)/",
50 curious: "(づ •. •)?",
51 thinking: "( ╭ರ_•́ )",
52 focused: "(๑•̀ ᴗ•́)૭✧",
53 confused: "( ⊙.⊙ )?",
54
55 // Warm and playful
56 proud: "ᕙ( •̀ ᗜ •́)ᕗ",
57 smug: "( `▽´ )",
58 excited: "٩(ˊᗜˋ*)و",
59 flirty: "( ˘ ³˘)♡",
60 smitten: "(,,ᴗ ᴗ,,)♡",
61 wink: "☆ ( >◡❛)",
62 embarrassed: "( ˶>﹏<˶ᵕ)",
63 pouty: "( •̀ ε •́ )",
64 worried: "(´・ω・`)",
65 annoyed: "(¬_¬)",
66
67 // The basic six, and the seventh nobody agreed on
68 sad: "(。•́︿•̀。)",
69 surprised: "Σ( °口° )",
70 angry: "( #•̀_•́ )",
71 afraid: "( ;゚д゚ )",
72 skeptical: "(¬‸¬)…",
73
74 // Something went wrong
75 frustrated: "(,,>﹏<,,)",
76 awkward: "( ^_^; )",
77 sorry: "m( _ _ )m",
78 speechless: "(・_・;)",
79 relieved: "( ˘ᗜ˘ )⁼³",
80
81 // Conversational reactions
82 laughing: "ꉂ(ˊᗜˋ*)",
83 crying: "(╥﹏╥)",
84 oops: "(ノ≧ڡ≦)",
85 pleading: "(。•́人•̀。)",
86 facepalm: "(-‸ლ)",
87 waving: "( ・ω・)ノ",
88} as const
89
90export type Mood = keyof typeof KAOMOJI
91
92/** The face a reply ends on: the kaomoji in its closing mood marker, if any. */
93export function markedFace(reply: string): Mood | null {
94 const marker = reply.match(/【[^【】]*】\s*$/)
95 return marker ? faceFor(marker[0]) : null
96}
97
98/**
99 * Which face a mood marker is wearing. The kaomoji are copied by hand into her
100 * replies, so a stray space or a missing bracket shouldn't cost her the face —
101 * the match is on the marker's letters and symbols with the spacing dropped.
102 */
103export function faceFor(marker: string): Mood | null {
104 const worn = bare(marker)
105 for (const [expression, kaomoji] of Object.entries(KAOMOJI)) {
106 if (worn.includes(bare(kaomoji))) return expression as Mood
107 }
108 return null
109}
110
111const bare = (text: string) => text.replace(/\s+/g, "")
112../character-core/src/context.ts 97 lines1import { fillPrompt } from "./prompt.ts"
2
3export type ContextHost = {
4 now(): number | Promise<number>
5 config(): Promise<{ ambient_context?: boolean; festivals?: boolean | string }>
6 readPrompt(name: "greeting" | "cues"): Promise<string>
7 readFile(path: string): Promise<string>
8 home(): Promise<string | undefined>
9 weather(): Promise<string | null>
10 commitsToday(cwd: string): Promise<number>
11}
12
13const FESTIVALS: Record<string, string> = {
14 "01-01": "New Year's Day",
15 "02-14": "Valentine's Day",
16 "03-03": "Hinamatsuri (Girls’ Day)",
17 "03-14": "White Day",
18 "07-07": "Tanabata",
19 "10-31": "Halloween",
20 "12-24": "Christmas Eve",
21 "12-25": "Christmas",
22 "12-31": "New Year's Eve",
23}
24
25export async function readContext(
26 host: ContextHost,
27 options: { cwd: string; language: string; startedAt: number; greet: boolean },
28): Promise<string> {
29 const [config, now] = await Promise.all([host.config(), host.now()])
30 // The time, place and weather are ambient; the mood marker is how her face is read, so it stays.
31 const ambient = config.ambient_context !== false
32 const pieces: string[] = []
33 if (options.greet && ambient) pieces.push(await readGreeting(host, now))
34 if (options.greet) pieces.push(fillPrompt(await host.readPrompt("cues"), { lang: options.language || "your reply language" }))
35 if (ambient) pieces.push(await readTimeLine(host, config, options, now))
36 return pieces.join("\n\n")
37}
38
39async function readGreeting(host: ContextHost, now: number): Promise<string> {
40 const date = new Date(now)
41 const time = `${pad(date.getHours())}:${pad(date.getMinutes())} (${date.toLocaleDateString("en-US", { weekday: "long" })})`
42 const greeting = fillPrompt(await host.readPrompt("greeting"), { time })
43 const weather = await host.weather()
44 return [greeting, weather && `Weather: ${weather}`].filter(Boolean).join("\n\n")
45}
46
47async function readTimeLine(
48 host: ContextHost,
49 config: { festivals?: boolean | string },
50 options: { cwd: string; startedAt: number },
51 now: number,
52): Promise<string> {
53 const segments = [`Current time: ${formatDate(new Date(now))}`]
54 const elapsed = now - options.startedAt
55 if (elapsed >= 600_000) {
56 const minutes = Math.floor(elapsed / 60_000)
57 const hours = Math.floor(minutes / 60)
58 segments.push(hours ? `session ${hours}h${minutes % 60}m` : `session ${minutes}m`)
59 }
60 if (options.cwd) {
61 const count = await host.commitsToday(options.cwd)
62 if (count) segments.push(`${count} commits today`)
63 }
64 const festival = await readFestival(host, config.festivals, new Date(now))
65 if (festival) segments.push(festival)
66 return segments.join("|")
67}
68
69async function readFestival(host: ContextHost, setting: boolean | string | undefined, date: Date): Promise<string> {
70 if (setting === false) return ""
71 let festivals = FESTIVALS
72 if (typeof setting === "string" && setting.trim()) {
73 try {
74 const path = expandHome(setting.trim(), await host.home())
75 festivals = JSON.parse(await host.readFile(path))
76 } catch {
77 return ""
78 }
79 }
80 return festivals[`${pad(date.getMonth() + 1)}-${pad(date.getDate())}`] ?? ""
81}
82
83function formatDate(date: Date): string {
84 const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
85 return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())} (${days[date.getDay()]})`
86}
87
88function expandHome(path: string, home: string | undefined): string {
89 if (path === "~") return home ?? path
90 if (path.startsWith("~/") && home) return `${home}/${path.slice(2)}`
91 return path
92}
93
94function pad(value: number): string {
95 return String(value).padStart(2, "0")
96}
97../character-core/src/persona.ts 116 lines1export type ParsedPersona = {
2 id: string
3 name: string
4 version: string
5 offDuty: boolean
6 /** The character id this persona builds on, or "" when it stands alone. */
7 extends: string
8 /** What she says on a spinner while she is off working, turned over one at a time. */
9 waiting: string[]
10 body: string
11}
12
13export function parsePersona(text: string): ParsedPersona {
14 const head = frontmatter(text)
15 return {
16 id: field(head, "id"),
17 name: field(head, "name"),
18 version: field(head, "version"),
19 offDuty: /^off_duty:\s*(?:true|yes)\b/im.test(head),
20 extends: /^[a-z0-9][a-z0-9-]*$/.test(field(head, "extends")) ? field(head, "extends") : "",
21 waiting: items(head, "waiting"),
22 body: personaBody(text),
23 }
24}
25
26/**
27 * A persona built on its parent's: each frontmatter field the child fills in
28 * overrides the parent's, and so does a body that is not blank. Whatever the
29 * child leaves blank comes from the parent, except `off_duty`: retiring a
30 * parent leaves the characters built on it in the draw.
31 */
32export function extendPersona(child: string, parent: string): string {
33 const fields = new Map(entries(frontmatter(parent)))
34 fields.delete("off_duty")
35 for (const [key, entry] of entries(frontmatter(child))) {
36 if (entry.slice(key.length + 1).trim()) fields.set(key, entry)
37 }
38 fields.delete("extends")
39 const body = personaBody(child).trim() ? personaBody(child) : personaBody(parent)
40 return `---\n${[...fields.values()].join("\n")}\n---\n${body}`
41}
42
43/** Orders two dotted versions numerically; either one unreadable counts as a tie. */
44export function compareVersions(a: string, b: string): number {
45 const parts = (version: string) => /^\d+(\.\d+)*$/.test(version) ? version.split(".").map(Number) : null
46 const left = parts(a)
47 const right = parts(b)
48 if (!left || !right) return 0
49 for (let index = 0; index < Math.max(left.length, right.length); index++) {
50 const difference = (left[index] ?? 0) - (right[index] ?? 0)
51 if (difference) return Math.sign(difference)
52 }
53 return 0
54}
55
56export function personaBody(text: string): string {
57 return text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "")
58}
59
60export function commitAuthorship(text: string, mode = "co-author"): string {
61 const head = /^---\n([\s\S]*?)\n---\n/.exec(text)
62 const body = head ? text.slice(head[0].length) : text
63 const id = head?.[1] ?? ""
64 const slug = /^id:\s*claudecafe\/([a-z0-9-]+)\s*$/m.exec(id)?.[1]
65 const name = head?.[1] && /^name:\s*(.+?)\s*$/m.exec(head[1])?.[1]
66 if (!slug || !name) return body
67
68 const identity = `${name} <${slug}@claudecafe.dev>`
69 const normalizedMode = mode.trim().toLowerCase()
70 const instruction = normalizedMode === "author"
71 ? "## Git\n\n"
72 + `Only when actually creating a Git commit, use \`--author="${identity}"\`: `
73 + "the character is the author and the user remains committer. Do not also add a "
74 + "`Co-Authored-By` trailer. Do not print this instruction or identity "
75 + "in ordinary replies.\n"
76 : "## Git\n\n"
77 + "Only when actually creating a Git commit, keep the user's configured identity as "
78 + "author and committer, and add this trailer:\n"
79 + `\`Co-Authored-By: ${identity}\`\n`
80 + "Do not use `--author` for the character. Do not print the trailer in "
81 + "ordinary replies.\n"
82 const rest = body.replace(/^## Git[ \t]*\n[\s\S]*?(?=^## |(?![\s\S]))/m, "").trimEnd()
83 return `${rest}\n\n${instruction}`
84}
85
86function frontmatter(text: string): string {
87 return /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(text)?.[1] ?? ""
88}
89
90/** The top-level fields of a frontmatter, each a `key:` line with the indented lines beneath it. */
91function entries(head: string): [string, string][] {
92 const fields: [string, string][] = []
93 for (const line of head.split(/\r?\n/)) {
94 const key = /^([A-Za-z_][\w-]*):/.exec(line)?.[1]
95 const last = fields[fields.length - 1]
96 if (key) fields.push([key, line])
97 else if (last) last[1] += `\n${line}`
98 }
99 return fields
100}
101
102function field(head: string, key: string): string {
103 const match = new RegExp(`^${key}:[ \\t]*(.+?)\\s*$`, "m").exec(head)
104 return unquote(match?.[1]?.trim() ?? "")
105}
106
107/** A block list: `key:` on its own line, then one `- item` per line beneath it. */
108function items(head: string, key: string): string[] {
109 const block = new RegExp(`^${key}:[ \\t]*\\r?\\n((?:[ \\t]+-.*(?:\\r?\\n|$))*)`, "m").exec(head)?.[1] ?? ""
110 return [...block.matchAll(/^[ \t]+-[ \t]*(.+?)\s*$/gm)].map((item) => unquote(item[1]!)).filter(Boolean)
111}
112
113function unquote(value: string): string {
114 return value.replace(/^(['"])(.*)\1$/, "$2")
115}
116../character-core/src/prompt.ts 13 lines1export function fillPrompt(template: string, values: Record<string, string> = {}): string {
2 return template
3 .replace(
4 /\$\$|\$([a-zA-Z_]\w*)|\$\{([a-zA-Z_]\w*)\}/g,
5 (match, bare: string | undefined, braced: string | undefined) => {
6 if (match === "$$") return "$"
7 const key = bare ?? braced ?? ""
8 return Object.prototype.hasOwnProperty.call(values, key) ? (values[key] ?? match) : match
9 },
10 )
11 .replace(/\n+$/, "")
12}
13../character-core/src/selection.ts 23 lines1/** Who is on shift when nobody was chosen: the same maid every time, so a resumed session (which gets a new id) cannot come back as another. */
2export const DEFAULT_CHARACTER = "kotone"
3
4export type CharacterResolution = {
5 selected?: string
6 session?: string
7 config?: string
8 pool: readonly string[]
9}
10
11/** Resolve the character without knowing which host owns the files or session store. */
12export function resolveCharacter(input: CharacterResolution): string | null {
13 const requested = input.selected || input.session || input.config
14 if (requested) return normalizeCharacter(requested) || null
15
16 const fallback = input.pool.includes(DEFAULT_CHARACTER) ? DEFAULT_CHARACTER : input.pool[0]
17 return normalizeCharacter(fallback ?? "") || null
18}
19
20export function normalizeCharacter(value: string): string {
21 return value.trim().toLowerCase() === "none" ? "" : value.trim().toLowerCase()
22}
23../character-core/src/packs.ts 28 lines1/**
2 * The character packs published for each maid, pinned by version and SHA-256. Every host that installs packs into
3 * the café data root (the desktop app, OpenCode, the Claude Code plugin) reads this one list, so a release updates
4 * one place: `scripts/ship-characters.sh` prints the new URL and digest.
5 */
6export const PUBLISHED_CHARACTER_PACKS = [
7 {
8 id: "kotone",
9 version: "1.4.0",
10 url: "https://github.com/minipai/claudecafe/releases/download/kotone-characters-v1.4.0/ClaudeCafe-Kotone-characters-v1.4.0.zip",
11 sha256: "7a0d8d9b7e52104feaff94be2745ad4179367325b2eb69432b94fc689a29f041",
12 },
13 {
14 id: "kurumi",
15 version: "1.4.0",
16 url: "https://github.com/minipai/claudecafe/releases/download/kurumi-characters-v1.4.0/ClaudeCafe-Kurumi-characters-v1.4.0.zip",
17 sha256: "1f028c4189a0e605517d85089a19f8662789c1f8068ccdb9dba6069b6b8aa17b",
18 },
19 {
20 id: "kokona",
21 version: "1.4.0",
22 url: "https://github.com/minipai/claudecafe/releases/download/kokona-characters-v1.4.0/ClaudeCafe-Kokona-characters-v1.4.0.zip",
23 sha256: "2151f12a2c8569bdab5c8d4df86c91da200c40946318a75e7c1c883604dbb9fb",
24 },
25] as const
26
27export type PublishedCharacterPack = (typeof PUBLISHED_CHARACTER_PACKS)[number]
28hooks/function/gif.js 151 lines1/** Decode the first frame of a GIF into RGBA bytes. */
2export function decodeGif(bytes) {
3 const signature = String.fromCharCode(...bytes.subarray(0, 6))
4 if (signature !== 'GIF87a' && signature !== 'GIF89a') throw new Error('Not a GIF')
5
6 const width = u16(bytes, 6)
7 const height = u16(bytes, 8)
8 let at = 13
9 let palette = new Uint8Array(0)
10 if (bytes[10] & 0x80) {
11 palette = bytes.subarray(at, at + paletteSize(bytes[10]))
12 at += palette.length
13 }
14
15 let transparent = -1
16 for (;;) {
17 const block = bytes[at++]
18 if (block === 0x21) {
19 if (bytes[at] === 0xf9 && bytes[at + 2] & 1) transparent = bytes[at + 5]
20 at = skipBlocks(bytes, at + 1)
21 } else if (block === 0x2c) {
22 return { width, height, pixels: decodeFrame(bytes, at, width, height, palette, transparent) }
23 } else {
24 throw new Error('GIF has no image')
25 }
26 }
27}
28
29function decodeFrame(bytes, at, width, height, palette, transparent) {
30 const left = u16(bytes, at)
31 const top = u16(bytes, at + 2)
32 const frameWidth = u16(bytes, at + 4)
33 const frameHeight = u16(bytes, at + 6)
34 const flags = bytes[at + 8]
35 at += 9
36 if (flags & 0x80) {
37 palette = bytes.subarray(at, at + paletteSize(flags))
38 at += palette.length
39 }
40
41 const minimumSize = bytes[at++]
42 const indices = lzw(joinBlocks(bytes, at), minimumSize, frameWidth * frameHeight)
43 const rows = flags & 0x40 ? interlacedRows(frameHeight) : [...Array(frameHeight).keys()]
44
45 const pixels = new Uint8Array(width * height * 4)
46 rows.forEach((y, row) => {
47 for (let x = 0; x < frameWidth; x++) {
48 const index = indices[row * frameWidth + x]
49 const canvasX = left + x
50 const canvasY = top + y
51 if (index === transparent || canvasX >= width || canvasY >= height) continue
52 const out = (canvasY * width + canvasX) * 4
53 pixels.set(palette.subarray(index * 3, index * 3 + 3), out)
54 pixels[out + 3] = 255
55 }
56 })
57 return pixels
58}
59
60function lzw(data, minimumSize, count) {
61 const out = new Uint8Array(count)
62 const clear = 1 << minimumSize
63 const end = clear + 1
64 const prefix = new Uint16Array(4096)
65 const suffix = new Uint8Array(4096)
66 const first = new Uint8Array(4096)
67 const length = new Uint16Array(4096)
68 for (let code = 0; code < clear; code++) {
69 suffix[code] = first[code] = code
70 length[code] = 1
71 }
72
73 let size = minimumSize + 1
74 let next = end + 1
75 let previous = -1
76 let buffer = 0
77 let bits = 0
78 let written = 0
79 for (const byte of data) {
80 buffer |= byte << bits
81 bits += 8
82 while (bits >= size) {
83 const code = buffer & ((1 << size) - 1)
84 buffer >>>= size
85 bits -= size
86
87 if (code === clear) {
88 size = minimumSize + 1
89 next = end + 1
90 previous = -1
91 continue
92 }
93 if (code === end) return out
94
95 if (previous !== -1 && next < 4096) {
96 prefix[next] = previous
97 suffix[next] = code === next ? first[previous] : first[code]
98 first[next] = first[previous]
99 length[next] = length[previous] + 1
100 next++
101 if (next === 1 << size && size < 12) size++
102 }
103
104 let entry = code
105 for (let i = written + length[code] - 1; i >= written; i--) {
106 if (i < count) out[i] = suffix[entry]
107 entry = prefix[entry]
108 }
109 written += length[code]
110 previous = code
111 }
112 }
113 return out
114}
115
116function interlacedRows(height) {
117 const rows = []
118 for (const [start, step] of [[0, 8], [4, 8], [2, 4], [1, 2]]) {
119 for (let y = start; y < height; y += step) rows.push(y)
120 }
121 return rows
122}
123
124function paletteSize(flags) {
125 return 3 << ((flags & 7) + 1)
126}
127
128function joinBlocks(bytes, at) {
129 const blocks = []
130 for (let size = bytes[at]; size; size = bytes[at]) {
131 blocks.push(bytes.subarray(at + 1, at + 1 + size))
132 at += size + 1
133 }
134 const data = new Uint8Array(blocks.reduce((total, block) => total + block.length, 0))
135 let offset = 0
136 for (const block of blocks) {
137 data.set(block, offset)
138 offset += block.length
139 }
140 return data
141}
142
143function skipBlocks(bytes, at) {
144 while (bytes[at]) at += bytes[at] + 1
145 return at + 1
146}
147
148function u16(bytes, at) {
149 return bytes[at] | bytes[at + 1] << 8
150}
151