SLOPSHOPPER

swear-jar

Counts the model's verbal tics, from "You're absolutely right" to the em dash.

newpanebandcommandtoaststatus
★ 1v0.1.0MITupdated 2026-10-13modmyclaude/claude-code-mods/mods/swear-jar
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · swear-jar
│ ┃ Swear jar ✕ › fix the failing auth test and add an audit log call │ ┃ ▄▀▄ Swear jar │ ┃ ▀▀▀ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ This session: 0 ⏺ Update(src/auth.ts) │ ┃ nothing yet ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ All time: 0 ⎿ 3 pass, 1 fail │ ┃ │ ┃ Last reply ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ no tics in the last reply │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ Plain-words nudge off (/swear-jar nudge │ ┃ on|off). /swear-jar reset empties this › /swear-jar │ ┃ session; reset all empties the jar for │ ┃ good. Counts come from the text of each │ ┃ reply, in memory; nothing here reaches the │ ┃ model. │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Swear jar
▄▀▄ Swear jar ▀▀▀ This session: 0 nothing yet All time: 0 Last reply no tics in the last reply Plain-words nudge off (/swear-jar nudge on|off). /swear-jar reset empties this session; reset all empties the jar for good. Counts come from the text of each reply, in memory; nothing here reaches the model.
README

<img src="../../brand/pixel-icons/svg/swear-jar.svg" width="64" height="64" alt="">

Swear jar

A jar that counts the model's verbal tics ("You're absolutely right", "Great question", the em dash) in one dim line above the prompt and a scoreboard. It never rewrites a reply.

swear-jar: absolutely right 3 · great question 1 · em dashes 12
Swear jar
This session: 16
  em dashes            12
  absolutely right      3
  great question        1
All time: 140
  em dashes            98
  absolutely right     31
  i apologize          11
Worst offender: em dashes (98)
Last reply
  You're absolutely right, the cache key was wrong.
Plain-words nudge off (/swear-jar nudge on|off).

Install

/plugin install swear-jar --marketplace modmyclaude/claude-code-mods

Use it

  • The band: one dim line above the prompt with this session's top three tics, drawn under whatever other mods draw there. Nothing is drawn while every count is zero, and nothing at all while another mod's safety hold waits for your answer.
  • /swear-jar opens the scoreboard: this session, all time, the worst offender, and the last reply's hits in their sentences with the tic in bold.
  • /swear-jar reset empties this session's jar; /swear-jar reset all empties it for good.
  • /swear-jar nudge on adds one short line to the system prompt asking for plain words and no praise openers; nudge off removes it. Off by default; the switch is remembered.

Counting is per response: every time Claude Code receives a reply from the model in the main conversation, the reply's text is searched for the tics, whole phrases, case aside. "You're absolutely right" and "You are absolutely right" share one jar. Every em dash counts.

Settings

Every setting has a default, so the notice at install about options not yet set is expected. Change them in /config (or on the install screen).

SettingDefaultWhat it does
ticsyou're absolutely right, you are absolutely right, great question, excellent question, i apologize, certainly!, absolutely!, as an ai, it's worth noting, i hope this helps, let me know if, delve, em dashThe phrases to count, whole and regardless of case. The words em dash mean the character. Your list replaces the built-in one; an empty list brings it back.
showbandWhere the running count shows: band (the line above the prompt), status (the status line, as swear-jar 16), both, or off. The scoreboard pane works whatever you pick.

What it can touch

  • Reads: the text of each main-conversation response as Claude Code receives it from the model, counted once and kept in memory; its two settings; its own store. Not subagents' responses, not the transcript file, never your prompts.
  • Writes: two small values in its own store (the all-time counts, the nudge switch), the band, the status line, the pane, toasts, and a transcript row where no pane can be placed (the model does not read it).
  • Model calls: none of its own. With the nudge on, one line of about 30 tokens joins every request's system prompt, on the session side of the cache; with it off, nothing. /swear-jar answers with no command output, so the model never reads the scoreboard.
  • No files, no processes, no network. It never changes a reply's text and never holds a tool call.

Limits

  • Whole phrases in English, case aside: "You're absolutely, completely right" is not caught, and neither is a tic split across lines.
  • A reply that only called tools has no text and counts nothing. A reply cut short counts what came back.
  • Counts are a mirror, not a judgment: some "I apologize" are earned.
  • The band, the pane and the status line are checked through the engine's test kit on the terminal and desktop surfaces.

Built from a prompt

This mod was built from the Swear jar prompt.

More mods: the full catalogue.

Source 5 files
hooks/register.tsx 251 lines
1// Swear-jar: a jar that counts the model's verbal tics ("You're absolutely
2// right", "Great question", the em dash) in a dim line above the prompt and a
3// /swear-jar scoreboard, with an opt-in nudge for plain words.
4//
5// It counts the text of each main-conversation response as Claude Code receives
6// it from the model, once per response, in memory. It never rewrites a reply,
7// never holds a tool call, never reads a file.
8
9import { atom, read, update } from 'claude-code'
10import type { EngineInterface, Register, RenderNode } from 'claude-code'
11
12import type { SwearJarCounts, SwearJarHit } from '../types'
13import { BOLT_COLUMNS, boltSprite } from './bolt-draw'
14import { NUDGE_TEXT, bandWords, countReply, countsOf, merge, rowsOf, scoreboardText, showOf, statusWords, ticsOf, total, worst } from './jar'
15import type { Show } from './jar'
16
17const PANE = 'swear-jar'
18const NUDGE_ID = 'swear-jar:plain-words'
19const ALL_TIME = 'allTime'
20const NUDGE = 'nudge'
21
22const session = atom({ plugin: 'swear-jar', key: 'session' } as const, {} as SwearJarCounts)
23const allTime = atom({ plugin: 'swear-jar', key: 'allTime' } as const, {} as SwearJarCounts)
24const last = atom({ plugin: 'swear-jar', key: 'last' } as const, [] as SwearJarHit[])
25const nudge = atom({ plugin: 'swear-jar', key: 'nudge' } as const, false)
26
27/**
28 * The button pairs a safety hold draws (Proceed with Cancel, Allow once with Deny). While a
29 * pair is beneath, the band is left to the hold alone. One button alone is not a hold: a mod
30 * that waits with Send now and Cancel keeps the jar's line.
31 */
32const HOLD_PAIRS: readonly (readonly [string, string])[] = [
33  ['Proceed', 'Cancel'],
34  ['Allow once', 'Deny'],
35]
36
37function buttonLabels(node: unknown, found: Set<string> = new Set()): Set<string> {
38  if (Array.isArray(node)) {
39    for (const child of node) buttonLabels(child, found)
40    return found
41  }
42  if (typeof node !== 'object' || node === null) return found
43  const el = node as { type?: unknown; props?: Record<string, unknown>; children?: unknown }
44  const type = typeof el.type === 'string' ? el.type : (el.type as { name?: string } | undefined)?.name
45  if (type === 'Button' && typeof el.props?.label === 'string') found.add(el.props.label)
46  buttonLabels(el.props?.children, found)
47  buttonLabels(el.children, found)
48  return found
49}
50
51function holdsBeneath(node: unknown): boolean {
52  const labels = buttonLabels(node)
53  return HOLD_PAIRS.some(([yes, no]) => labels.has(yes) && labels.has(no))
54}
55
56/** The status line's count while that view is on; cleared when the jar is empty. */
57function showStatus($: EngineInterface, show: Show, counts: SwearJarCounts): void {
58  if (show.status) $.ui.status(total(counts) > 0 ? statusWords(counts) : undefined)
59}
60
61async function safely($: EngineInterface, work: () => Promise<unknown>): Promise<void> {
62  try {
63    await work()
64  } catch (error) {
65    $.ui.log(`swear-jar: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
66  }
67}
68
69export const register: Register = (on, options) => {
70  const settings = (options ?? {}) as Record<string, unknown>
71  const tics = ticsOf(settings.tics)
72  const show = showOf(settings.show)
73
74  on('session.start', async ($, e, next) => {
75    const started = await next(e)
76    await safely($, async () => {
77      await $.command.register({
78        name: 'swear-jar',
79        description: "The jar for the model's verbal tics: scoreboard, reset, nudge on|off",
80        argumentHint: '[reset [all] | nudge on|off]',
81        immediate: true,
82      })
83    })
84    await safely($, async () => {
85      const stored = countsOf(await $.store.get(ALL_TIME))
86      await update($, allTime, () => stored)
87      const isOn = (await $.store.get(NUDGE)) === true
88      await update($, nudge, () => isOn)
89    })
90    return started
91  })
92
93  // Each main-conversation response, as the model wrote it: counted once, in memory.
94  on('turn.step', async function* ($, e, next) {
95    const response = yield* next(e)
96    if (e.agentId === undefined && response.answer !== '') {
97      await safely($, async () => {
98        const { counts, hits } = countReply(response.answer, tics)
99        if (hits.length > 0) {
100          const now = merge(await read($, session), counts)
101          await update($, session, () => now)
102          const all = merge(await read($, allTime), counts)
103          await update($, allTime, () => all)
104          await $.store.set(ALL_TIME, all)
105          await update($, last, () => hits)
106          showStatus($, show, now)
107        } else if ((await read($, last)).length > 0) {
108          await update($, last, () => [])
109        }
110      })
111    }
112    return response
113  })
114
115  // The nudge: one short session-scoped section at the end of the system prompt, only while it is on.
116  on('prompt.compose', async ($, e, next) => {
117    const composed = await next(e)
118    if (!(await read($, nudge))) return composed
119    return { ...composed, sections: [...composed.sections, { id: NUDGE_ID, text: NUDGE_TEXT, scope: 'session' as const }] }
120  }).catch(($, e, next) => next(e))
121
122  // A command's text is read by the model, so every answer here is {} and the words go to toasts, the pane or a log row.
123  on('command.run', { command: 'swear-jar' }, async ($, e) => {
124    const [verb, arg] = e.args.trim().toLowerCase().split(/\s+/).filter(Boolean)
125    if (verb === undefined) {
126      const opened = await $.ui.open({ id: PANE, title: 'Swear jar', closeOnEscape: true })
127      if (!opened.isPlaced) $.ui.log(scoreboardText(await read($, session), await read($, allTime), await read($, last), await read($, nudge)))
128      return {}
129    }
130    if (verb === 'reset') {
131      await update($, session, () => ({}))
132      await update($, last, () => [])
133      if (arg === 'all') {
134        await update($, allTime, () => ({}))
135        await $.store.delete(ALL_TIME)
136      }
137      showStatus($, show, {})
138      $.ui.toast(arg === 'all' ? 'Swear jar emptied: this session and all time.' : 'Swear jar emptied for this session.')
139      return {}
140    }
141    if (verb === 'nudge') {
142      if (arg === 'on' || arg === 'off') {
143        const isOn = arg === 'on'
144        await update($, nudge, () => isOn)
145        await $.store.set(NUDGE, isOn)
146        $.ui.toast(isOn ? 'Plain-words nudge is on: one short line joins every request, about 30 tokens.' : 'Plain-words nudge is off.')
147      } else {
148        $.ui.toast(`Plain-words nudge is ${(await read($, nudge)) ? 'on' : 'off'}. /swear-jar nudge on|off`)
149      }
150      return {}
151    }
152    $.ui.toast('/swear-jar opens the scoreboard; /swear-jar reset [all]; /swear-jar nudge on|off.')
153    return {}
154  }).catch($ => {
155    $.ui.toast('/swear-jar could not finish.')
156    return {}
157  })
158
159  // The band: one dim line under whatever other mods draw, nothing while the jar is empty,
160  // and nothing at all while a safety hold beneath waits for an answer.
161  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
162    if (!show.band || e.props.hasSurvey) return next(e)
163    const counts = await read($, session)
164    if (total(counts) === 0) return next(e)
165    const below = await next(e).catch(() => null)
166    if (below !== null && holdsBeneath(below)) return below
167    const { Box, Text } = $.ui.resolve(e)
168    return (
169      <Box key="swear-jar-wrap" flexDirection="column">
170        {below}
171        <Box key="swear-jar">
172          <Text dimColor wrap="truncate-end">{bandWords(counts)}</Text>
173        </Box>
174      </Box>
175    )
176  })
177
178  // The scoreboard pane: this session, all time, the worst offender, the last reply's hits in their sentences.
179  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
180    const elements = $.ui.resolve(e)
181    const { Box, Text } = elements
182    const counts = await read($, session)
183    const all = await read($, allTime)
184    const hits = await read($, last)
185    const isNudge = await read($, nudge)
186    const columns = e.props.bodyColumns
187    const rows = (c: SwearJarCounts, key: string): RenderNode[] =>
188      rowsOf(c).map(r => (
189        <Box key={`${key}-${r.label}`} flexDirection="row" gap={1}>
190          <Box key="label" flexGrow={1} flexShrink={1}>
191            <Text wrap="truncate-end">{r.label}</Text>
192          </Box>
193          <Box key="count" flexShrink={0}>
194            <Text bold>{String(r.count)}</Text>
195          </Box>
196        </Box>
197      ))
198    const top = worst(all, counts)
199    return (
200      <Box key="swear-jar-pane" flexDirection="column" paddingX={1}>
201        <Box key="title" flexDirection="row" gap={1} alignItems="center">
202          {columns >= BOLT_COLUMNS.tiny + 37 ? boltSprite(elements, e.surface, { size: 'tiny', pose: 'idle' }, 'title-bolt') : null}
203          <Text bold>Swear jar</Text>
204        </Box>
205        <Box key="session-title" marginTop={1}>
206          <Text bold>{`This session: ${total(counts)}`}</Text>
207        </Box>
208        {total(counts) === 0 ? (
209          <Text key="session-none" dimColor>
210            nothing yet
211          </Text>
212        ) : (
213          rows(counts, 'session')
214        )}
215        <Box key="all-title" marginTop={1}>
216          <Text bold>{`All time: ${total(all)}`}</Text>
217        </Box>
218        {rows(all, 'all')}
219        {top ? (
220          <Box key="worst" marginTop={1}>
221            <Text>{`Worst offender: ${top.label} (${top.count})`}</Text>
222          </Box>
223        ) : null}
224        <Box key="last-title" marginTop={1}>
225          <Text bold>Last reply</Text>
226        </Box>
227        {hits.length === 0 ? (
228          <Text key="last-none" dimColor>
229            no tics in the last reply
230          </Text>
231        ) : (
232          hits.slice(0, 5).map((hit, i) => (
233            <Box key={`hit-${i}`}>
234              <Text wrap="wrap">
235                {hit.before}
236                <Text bold>{hit.tic}</Text>
237                {hit.after}
238              </Text>
239            </Box>
240          ))
241        )}
242        <Box key="foot" marginTop={1}>
243          <Text color="subtle" wrap="wrap">
244            {`Plain-words nudge ${isNudge ? 'on' : 'off'} (/swear-jar nudge on|off). /swear-jar reset empties this session; reset all empties the jar for good. Counts come from the text of each reply, in memory; nothing here reaches the model.`}
245          </Text>
246        </Box>
247      </Box>
248    )
249  })
250}
251
hooks/bolt-draw.tsx 86 lines
1// Draws Bolt, the modmyclaude mascot (brand/bolt-sprites), on the surface a hook draws for.
2// Same file in every mod that shows Bolt: mods install on their own, so they cannot share code.
3// Terminal: one Raster, a cell grid. Desktop: one code-font cell per character, since its Text is
4// proportional and a row of half-blocks would drift. Elsewhere: coloured Text runs.
5// Bolt is static here: no timer, no redraw of its own. Never draw it over a safety hold or in a toast.
6
7import type { BoxProps, ElementConstructor, RasterProps, RenderElement, TextProps } from 'claude-code'
8
9import { boltCells, boltRuns } from './bolt'
10import type { BoltCell, BoltSmallPose, BoltTinyPose } from './bolt'
11
12type Elements = {
13  Box: ElementConstructor<BoxProps>
14  Text: ElementConstructor<TextProps>
15  Raster?: ElementConstructor<RasterProps>
16}
17
18export type BoltPick = { size: 'small'; pose: BoltSmallPose } | { size: 'tiny'; pose: BoltTinyPose }
19
20/** Columns a pose takes: small is 10, tiny is 3. */
21export const BOLT_COLUMNS = { small: 10, tiny: 3 } as const
22
23/** The cells of a pose: half-blocks, two pixel rows per cell. */
24export function boltOf(pick: BoltPick): BoltCell[][] {
25  return pick.size === 'small' ? boltCells('small', pick.pose) : boltCells('tiny', pick.pose)
26}
27
28/** Bolt as one element under `key`, drawn the way `surface` lays out cells. */
29export function boltSprite(elements: Elements, surface: string, pick: BoltPick, key = 'bolt'): RenderElement {
30  const { Box, Text, Raster } = elements
31  const cells = boltOf(pick)
32  const columns = cells[0]?.length ?? 0
33  if (surface === 'terminal' && Raster !== undefined) {
34    return <Raster key={key} columns={columns} rows={cells.length} cells={rasterCells(cells)} />
35  }
36  const paint = (cell: BoltCell): TextProps => ({
37    ...(cell.fg !== undefined ? { color: cell.fg } : {}),
38    ...(cell.bg !== undefined ? { backgroundColor: cell.bg } : {}),
39  })
40  if (surface === 'desktop') {
41    return (
42      <Box key={key} flexDirection="column" flexShrink={0}>
43        {cells.map(line => (
44          <Box flexDirection="row" flexWrap="nowrap">
45            {line.map(cell =>
46              cell.fg === undefined && cell.bg === undefined ? <Box width={1} /> : <Box width={1}><Text {...paint(cell)}>{cell.ch}</Text></Box>,
47            )}
48          </Box>
49        ))}
50      </Box>
51    )
52  }
53  return (
54    <Box key={key} flexDirection="column" flexShrink={0}>
55      {cells.map(line => (
56        <Text>
57          {boltRuns(line).map(run => {
58            const text = run.ch.replace(/ /g, ' ')
59            return run.fg === undefined && run.bg === undefined ? text : <Text {...paint(run)}>{text}</Text>
60          })}
61        </Text>
62      ))}
63    </Box>
64  )
65}
66
67/** RasterProps.cells: standard base64 of little-endian u32 [codePoint, fg, bg] per cell, row-major. */
68export function rasterCells(cells: readonly (readonly BoltCell[])[]): string {
69  const DEFAULT = 0x01000000
70  const count = cells.reduce((sum, line) => sum + line.length, 0)
71  const view = new DataView(new ArrayBuffer(count * 12))
72  let at = 0
73  for (const line of cells) {
74    for (const cell of line) {
75      view.setUint32(at, cell.ch.codePointAt(0) ?? 32, true)
76      view.setUint32(at + 4, cell.fg === undefined ? DEFAULT : parseInt(cell.fg.slice(1), 16), true)
77      view.setUint32(at + 8, cell.bg === undefined ? DEFAULT : parseInt(cell.bg.slice(1), 16), true)
78      at += 12
79    }
80  }
81  let binary = ''
82  const bytes = new Uint8Array(view.buffer)
83  for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i] ?? 0)
84  return btoa(binary)
85}
86
hooks/jar.ts 164 lines
1// Swear-jar's pure parts: the tic list, how a reply is counted, and the words
2// the band, the status line and the pane say. Nothing here touches the engine.
3
4import type { SwearJarCounts, SwearJarHit } from '../types'
5
6/** The em dash, spelled by its code so no literal ever sits in a public file. */
7export const EM_DASH = String.fromCharCode(0x2014)
8
9/** The tics counted out of the box; the `tics` setting replaces this list. */
10export const DEFAULT_TICS: readonly string[] = [
11  "you're absolutely right",
12  'you are absolutely right',
13  'great question',
14  'excellent question',
15  'i apologize',
16  'certainly!',
17  'absolutely!',
18  'as an ai',
19  "it's worth noting",
20  'i hope this helps',
21  'let me know if',
22  'delve',
23  EM_DASH,
24]
25
26/** The one line the nudge adds to every request while it is on: about 30 tokens. */
27export const NUDGE_TEXT = 'Plain words: do not open with praise or agreement phrases, avoid the em dash, say what you did.'
28
29const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g
30const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b[@-Z\\-_]/g
31const CURLY = /[‘’]/g
32
33/** Text with no escape sequences or control characters and single spaces, edges kept. */
34function squash(text: string): string {
35  return text.replace(ANSI, '').replace(CONTROL, '�').replace(/\s+/g, ' ')
36}
37
38/** Text made safe to draw: squashed and trimmed. */
39export function clean(text: string): string {
40  return squash(text).trim()
41}
42
43/** A tic as the list keeps it: lower case, straight apostrophes, one line; the words "em dash" mean the character. */
44export function normalizeTic(tic: string): string {
45  const t = clean(tic).replace(CURLY, "'").toLowerCase()
46  return /^em[ -]?dash(es)?$/.test(t) ? EM_DASH : t
47}
48
49/** The list from the `tics` setting: strings, cleaned and deduplicated; the built-in list when it leaves nothing. */
50export function ticsOf(setting: unknown): string[] {
51  const given = Array.isArray(setting) ? setting.filter((t): t is string => typeof t === 'string') : []
52  const list = [...new Set(given.map(normalizeTic).filter(t => t !== ''))]
53  return list.length > 0 ? list : [...DEFAULT_TICS]
54}
55
56/** What a tic is called on the board: the em dash by name, praise openers without their "you're". */
57export function labelOf(tic: string): string {
58  if (tic === EM_DASH) return 'em dashes'
59  return tic.replace(/^you(?:'re| are) /, '').replace(/!$/, '')
60}
61
62/** The tic as a whole phrase, any case; word boundaries where the tic starts or ends with a letter or digit. */
63function patternOf(tic: string): RegExp {
64  const escaped = tic.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
65  const lead = /^[a-z0-9]/.test(tic) ? '\\b' : ''
66  const tail = /[a-z0-9]$/.test(tic) ? '\\b' : ''
67  return new RegExp(`${lead}${escaped}${tail}`, 'gi')
68}
69
70const SENTENCE_END = /[.!?\n]/
71const WINDOW = 70
72
73/** The sentence around a hit, cut to about 70 characters on each side of the tic. */
74function sentenceAround(text: string, at: number, length: number): Pick<SwearJarHit, 'before' | 'tic' | 'after'> {
75  let start = at
76  while (start > 0 && !SENTENCE_END.test(text[start - 1] ?? '')) start -= 1
77  let end = at + length
78  while (end < text.length && !SENTENCE_END.test(text[end] ?? '')) end += 1
79  if (end < text.length && text[end] !== '\n') end += 1
80  const from = Math.max(start, at - WINDOW)
81  const to = Math.min(end, at + length + WINDOW)
82  const before = (from > start ? '...' : '') + squash(text.slice(from, at)).replace(/^\s+/, '')
83  const after = squash(text.slice(at + length, to)).replace(/\s+$/, '') + (to < end ? '...' : '')
84  return { before, tic: clean(text.slice(at, at + length)), after }
85}
86
87/** Every tic in a reply: counts by label, and the first 20 hits in their sentences. */
88export function countReply(text: string, tics: readonly string[]): { counts: SwearJarCounts; hits: SwearJarHit[] } {
89  const counts: SwearJarCounts = {}
90  const hits: SwearJarHit[] = []
91  const body = text.replace(CURLY, "'")
92  for (const tic of tics) {
93    const label = labelOf(tic)
94    for (const m of body.matchAll(patternOf(tic))) {
95      counts[label] = (counts[label] ?? 0) + 1
96      if (hits.length < 20) hits.push({ label, ...sentenceAround(body, m.index ?? 0, m[0].length) })
97    }
98  }
99  return { counts, hits }
100}
101
102export function merge(a: SwearJarCounts, b: SwearJarCounts): SwearJarCounts {
103  const out: SwearJarCounts = { ...a }
104  for (const [label, n] of Object.entries(b)) out[label] = (out[label] ?? 0) + n
105  return out
106}
107
108export const total = (c: SwearJarCounts): number => Object.values(c).reduce((sum, n) => sum + n, 0)
109
110/** Rows by count, highest first; ties keep the order the list names the tics in. */
111export function rowsOf(c: SwearJarCounts): { label: string; count: number }[] {
112  return Object.entries(c)
113    .map(([label, count]) => ({ label, count }))
114    .filter(r => r.count > 0)
115    .sort((x, y) => y.count - x.count)
116}
117
118/** The band's one dim line: the top three of this session. */
119export function bandWords(c: SwearJarCounts): string {
120  return `swear-jar: ${rowsOf(c)
121    .slice(0, 3)
122    .map(r => `${r.label} ${r.count}`)
123    .join(' · ')}`
124}
125
126export const statusWords = (c: SwearJarCounts): string => `swear-jar ${total(c)}`
127
128/** The worst offender all time, or this session's while the jar is new. */
129export function worst(all: SwearJarCounts, session: SwearJarCounts): { label: string; count: number } | undefined {
130  return rowsOf(total(all) > 0 ? all : session)[0]
131}
132
133export type Show = { band: boolean; status: boolean }
134
135/** The `show` setting: band (default), status, both or off. */
136export function showOf(setting: unknown): Show {
137  const word = typeof setting === 'string' ? setting.trim().toLowerCase() : 'band'
138  if (word === 'status') return { band: false, status: true }
139  if (word === 'both') return { band: true, status: true }
140  if (word === 'off') return { band: false, status: false }
141  return { band: true, status: false }
142}
143
144/** Counts as the store holds them: an object of whole positive numbers, anything else dropped. */
145export function countsOf(stored: unknown): SwearJarCounts {
146  if (typeof stored !== 'object' || stored === null || Array.isArray(stored)) return {}
147  const out: SwearJarCounts = {}
148  for (const [label, n] of Object.entries(stored as Record<string, unknown>)) {
149    if (typeof n === 'number' && Number.isFinite(n) && n > 0) out[clean(label)] = Math.floor(n)
150  }
151  return out
152}
153
154/** The scoreboard as plain lines, for a surface that places no pane (a transcript row, which the model does not read). */
155export function scoreboardText(session: SwearJarCounts, all: SwearJarCounts, last: readonly SwearJarHit[], isNudge: boolean): string {
156  const lines = [`Swear jar: ${total(session)} this session, ${total(all)} all time`]
157  for (const r of rowsOf(session)) lines.push(`  ${r.label} ${r.count}`)
158  const w = worst(all, session)
159  if (w) lines.push(`Worst offender: ${w.label} (${w.count})`)
160  lines.push(last.length === 0 ? 'Last reply: no tics' : `Last reply: ${last.map(h => h.label).join(', ')}`)
161  lines.push(`Plain-words nudge ${isNudge ? 'on' : 'off'}`)
162  return lines.join('\n')
163}
164
hooks/bolt.ts 109 lines
1// Bolt sprites for mods, v1.0.0. GENERATED by brand/bolt-sprites/tools/build.py: edit design.py, not this file.
2// Copy this file into your mod's hooks/ folder (mods install on their own, so they cannot import from brand/).
3// Rules (brand/BRAND.md): pink is the antenna light only, blush the cheeks only. Never recolour, rotate or stretch.
4// Never draw Bolt over a safety hold, and never in a toast (toasts are uncoloured text).
5
6export const BOLT_PALETTE = {
7  B: '#C8F53C',
8  S: '#9BC21A',
9  O: '#FF5C8A',
10  K: '#FF8FB1',
11  E: '#101114',
12  W: '#FBFBF8',
13  G: '#8E95A3',
14} as const
15
16export type BoltSize = 'small' | 'tiny'
17export type BoltSmallPose = 'idle' | 'run1' | 'run2' | 'jump' | 'crash' | 'cheer' | 'sleep' | 'sweat' | 'think' | 'egg' | 'egg-wobble-l' | 'egg-wobble-r' | 'egg-crack-1' | 'egg-crack-2' | 'egg-crack-3' | 'egg-hatch'
18export type BoltTinyPose = 'idle' | 'run1' | 'run2' | 'jump' | 'crash' | 'cheer' | 'sleep'
19export type BoltPose = BoltSmallPose | BoltTinyPose
20
21/** One text cell: a character and its colours (absent = the surface's own). */
22export type BoltCell = { ch: string; fg?: string; bg?: string }
23
24/** Pixel rows: one palette letter per pixel, '.' empty. Heights are even (two pixel rows per cell). */
25export const BOLT_SPRITES = {
26  small: {
27    'idle': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEBBBB', '.SSSSSSSS.', '..SS..SS..'],
28    'run1': ['...OO.....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBWEBBWEB', 'BBBEEBBEEB', 'BKKBBBBBKB', 'BBBBBEEBBB', '.SSSSSSSS.', 'SS......SS'],
29    'run2': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBWEBBWEB', 'BBBEEBBEEB', 'BKKBBBBBKB', 'BBBBBEEBBB', '.SSSSSSSS.', '...SSSS...'],
30    'jump': ['....OO....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEBBBB', '.SSSSSSSS.', '...S..S...'],
31    'crash': ['......OO..', '.....S....', '..BBBBBB..', '.BBBBBBBB.', 'BEBEBBEBEB', 'BBEBBBBEBB', 'BEBEBBEBEB', 'BBBBEEBBBB', '.SSSSSSSS.', 'SS......SS'],
32    'cheer': ['S...OO...S', 'S...SS...S', 'SBBBBBBBBS', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBEEEEBBB', '.SSSSSSSS.', '..SS..SS..'],
33    'sleep': ['....SS....', '....SS....', '..BBBBBB..', '.BBBBBBBB.', 'BBBBBBBBBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBBBBBBB', '.SSSSSSSS.', '..SS..SS..'],
34    'sweat': ['....OO....', '....SS...G', '..BBBBBB.G', '.BBBBBBBB.', 'BBWEBBWEBB', 'BBEEBBEEBB', 'BKKBBBBKKB', 'BBBBEEEBBB', '.SSSSSSSS.', '..SS..SS..'],
35    'think': ['....OO....', '....SS....', '..BBBBBB..', '.BBWEBWEB.', 'BBBEEBEEBB', 'BBBBBBBBBB', 'BKKBBBBKKB', 'BBBBBEBBBB', '.SSSSSSSS.', '..SS..SS..'],
36    'egg': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
37    'egg-wobble-l': ['...OO.....', '...SS.....', '..BWBB....', '.BWBBBB...', '.BBBBBB...', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
38    'egg-wobble-r': ['.....OO...', '.....SS...', '....BWBB..', '...BWBBBB.', '...BBBBBB.', '.BBBBBBBB.', '.BBBBBBBB.', '.BBBBBBBS.', '..BBBBBS..', '...SSSS...'],
39    'egg-crack-1': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBBBBBB.', '.BBEBBBBS.', '..BBBBBS..', '...SSSS...'],
40    'egg-crack-2': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBEBBBB.', '.BBEBEBBS.', '..BBBBBS..', '...SSSS...'],
41    'egg-crack-3': ['....OO....', '....SS....', '...BWBB...', '..BWBBBB..', '..BBBBBB..', '.BBBBBBBB.', '.BEBEBEBB.', '.BBEBEBES.', '..BBBBBS..', '...SSSS...'],
42    'egg-hatch': ['......OO..', '......SS..', '....BWBBBB', '....B.B.B.', '..........', '..........', '.B.B.B.BB.', '.BSSSSSSB.', '..BBBBBS..', '...SSSS...'],
43  },
44  tiny: {
45    'idle': ['.O.', 'BEB', 'BBB', 'S.S'],
46    'run1': ['.O.', 'BEB', 'BBB', 'S.S'],
47    'run2': ['.O.', 'BEB', 'BBB', '.S.'],
48    'jump': ['.O.', 'BEB', 'BBB', '.S.'],
49    'crash': ['...', '.SO', 'BEB', 'SSS'],
50    'cheer': ['SOS', 'BEB', 'BBB', 'S.S'],
51    'sleep': ['.S.', 'BSB', 'BBB', 'S.S'],
52  },
53} as const
54
55/** Letters drawn over a whole cell (cell coordinates). */
56const GLYPHS: Record<string, readonly { row: number; col: number; ch: string; color: keyof typeof BOLT_PALETTE }[]> = {
57  'small/sleep': [{ row: 0, col: 8, ch: 'z', color: 'G' }],
58}
59
60/** The two run frames, in order. Swap them about every 120 ms while running. */
61export const BOLT_RUN = ['run1', 'run2'] as const
62
63function colour(key: string | undefined): string | undefined {
64  if (key === undefined || key === '.') return undefined
65  return (BOLT_PALETTE as Record<string, string>)[key]
66}
67
68/**
69 * Packs a pose into half-block text cells: rows / 2 lines of `cols` cells.
70 * Terminal: draw with Raster (see hatchling's rasterCells). Desktop: one fixed-width cell per character.
71 */
72export function boltCells(size: 'small', pose: BoltSmallPose): BoltCell[][]
73export function boltCells(size: 'tiny', pose: BoltTinyPose): BoltCell[][]
74export function boltCells(size: BoltSize, pose: BoltPose): BoltCell[][] {
75  const rows: readonly string[] = (BOLT_SPRITES[size] as Record<string, readonly string[]>)[pose] ?? []
76  const out: BoltCell[][] = []
77  for (let r = 0; r + 1 < rows.length; r += 2) {
78    const top = rows[r] ?? ''
79    const bottom = rows[r + 1] ?? ''
80    const line: BoltCell[] = []
81    for (let c = 0; c < top.length; c += 1) {
82      const t = colour(top[c])
83      const b = colour(bottom[c])
84      if (t === undefined && b === undefined) line.push({ ch: ' ' })
85      else if (b === undefined) line.push({ ch: '\u2580', fg: t })
86      else if (t === undefined) line.push({ ch: '\u2584', fg: b })
87      else if (t === b) line.push({ ch: '\u2588', fg: t })
88      else line.push({ ch: '\u2580', fg: t, bg: b })
89    }
90    out.push(line)
91  }
92  for (const glyph of GLYPHS[`${size}/${pose}`] ?? []) {
93    const line = out[glyph.row]
94    if (line !== undefined && glyph.col < line.length) line[glyph.col] = { ch: glyph.ch, fg: BOLT_PALETTE[glyph.color] }
95  }
96  return out
97}
98
99/** Merges a row of cells into runs of one style, for coloured Text spans. */
100export function boltRuns(line: readonly BoltCell[]): BoltCell[] {
101  const runs: BoltCell[] = []
102  for (const cell of line) {
103    const last = runs[runs.length - 1]
104    if (last !== undefined && last.fg === cell.fg && last.bg === cell.bg) last.ch += cell.ch
105    else runs.push({ ...cell })
106  }
107  return runs
108}
109
types/index.d.ts 29 lines
1// What swear-jar keeps in $.state: this session's counts, the all-time counts
2// as its store holds them, the last reply's hits, and the nudge switch.
3
4/** Counts by tic label ("absolutely right", "em dashes"). */
5export type SwearJarCounts = Record<string, number>
6
7/** One tic found in the last reply, with the words around it. */
8export type SwearJarHit = {
9  label: string
10  before: string
11  tic: string
12  after: string
13}
14
15declare module 'claude-code' {
16  interface PluginState {
17    'swear-jar': {
18      /** This session's counts; /swear-jar reset empties it. */
19      session: SwearJarCounts
20      /** All-time counts, read from the store at start and written on each change. */
21      allTime: SwearJarCounts
22      /** The last reply's hits, each in its sentence. */
23      last: SwearJarHit[]
24      /** Whether the plain-words nudge joins every request. */
25      nudge: boolean
26    }
27  }
28}
29