SLOPSHOPPER

Persona Panel

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.

newpanebandspinnerrowsguard
★ 2v0.12.0MITupdated 2026-10-04minipai/claudecafe/packages/persona-panel
A shopper browsing a rack in a slop shop
README

persona-panel

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.

Requirements

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).

Install

/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.

What you get

  • A persona. The character's persona file is added to Claude's context every turn, so Claude answers in the character's voice.
  • A sense of time. The first turn gets a short greeting cue with the local time and weather; every turn gets the current time, how long the session has run, how many commits the project has today, and the day's festival if any.
  • A mood marker. Replies end with a one-line mood and a kaomoji from a fixed table. The kaomoji on a completed reply also selects the portrait's expression.
  • The portrait panel. A pixel portrait beside the conversation, with the project, model, context left, rate-limit left and branch above it. Claude changes the face through the set_expression tool or the reply's mood marker.
  • On the desktop. Her avatar beside each reply, wearing the face the reply signs off with, and her name, whose ◨ opens a portrait pane: her portrait with sakura drifting down. With 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.
  • Two skills. /persona-panel:config views or changes the settings; /persona-panel:look has the character describe what they look like right now.

Settings

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.

Making a character

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" …
FieldWhat it does
nameHer name, shown under the portrait. Defaults to the folder id.
waitingLines shown on the spinner while a turn runs, one at a time.
idclaudecafe/<id> gives her a commit identity (<id>@claudecafe.dev) for commit_authorship; any other value gives her none.
versionCompared with a bundled character of the same id; the higher one is used, and yours wins a tie.
extendsBuilds on another character (see below).
off_dutytrue 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.

Building on an existing character

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.

What it reads, runs and sends

  • Reads config.json and characters/ under ~/.config/claudecafe/, and its own bundled files.
  • Writes one file per session, ~/.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.
  • Downloads those packs, in a desktop session and only when needed, from the project's GitHub releases, checking each archive's SHA-256 before unpacking it.
  • Runs two local Git commands in the project: 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.
  • Sends one request per session to wttr.in, a third-party weather service, for the local weather in the greeting. The request carries no data of yours, but wttr.in sees your IP address and uses it to guess your location. Set "ambient_context": false to skip it.
  • Asks the model, only with "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.
  • Adds to Claude's context the persona, the greeting, the time line, the weather line and the mood-marker cue. Like the rest of the conversation, that context goes to the model provider Claude Code is using.

persona-panel collects nothing itself: no analytics, no telemetry, no account. See the privacy note.

Source

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.

License

The code is MIT; the bundled characters are under their own license. See LICENSE and characters/LICENSE.

Source 12 files
hooks/function/register.js 704 lines
1import {
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 lines
1export * from "./expression.ts"
2export * from "./context.ts"
3export * from "./persona.ts"
4export * from "./prompt.ts"
5export * from "./selection.ts"
6export * from "./packs.ts"
7
hooks/function/desktop.js 128 lines
1// 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}
128
hooks/function/faces.js 53 lines
1import { 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}
53
hooks/function/stats.js 37 lines
1export 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 lines
1export 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 lines
1import { 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 lines
1export 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 lines
1export 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 lines
1/** 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 lines
1/**
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]
28
hooks/function/gif.js 151 lines
1/** 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