Zsemle, a pixel-art companion above the prompt (golden retriever, cat, slime, paperclip): watches every usage limit (50/75/90% warnings, pace forecast, resets…

A pixel-art companion for Claude Code that sits above your prompt and keeps an eye on your usage limits.
Zsemle watches the 5-hour and weekly windows, the context, API errors and your session cost, tells you in a little speech bubble when something matters, and stops the work at 95% so you never hit the wall mid-task. It also guards file writes, sniffs after deploys, warns about port clashes, and gets visibly tired as your limit runs low. Pick one of 15 figures; it works in the terminal and in the desktop app's Code tab alike.
Magyar leírás lent.


The same figures as the terminal draws them (quadrant-block characters, two colors per cell):

| name | figure | its own trick |
|---|---|---|
dog | golden retriever puppy (default) | wags its tail, barks when a long turn ends |
cat | grey tabby cat | meows, hisses at a blocked write |
slime | jelly slime | wobbles when happy |
paperclip | paperclip with a face | hops |
penguin | penguin | waddles when your tests pass |
turtle | turtle | pulls its head in during turns longer than 3 minutes |
hamster | hamster | runs in its wheel while the model works |
owl | owl | after 22:00 it tells you to close the day |
cube | Rubik's cube with eyes | clicks |
duck | rubber duck | quacks, listens to your bug explanation |
mug | coffee mug | steams while the model works, empties as the limit runs low |
cactus | potted cactus | blooms on green tests, wilts on errors |
dragon | little dragon | roars, breathes fire after a deploy |
ghost | ghost | fades when you are idle |
robot | robot | beeps, its antenna blinks while the model works |
Every figure has its own sound (barks, meows, quacks, hoots, a roar, beeps...), played when a long turn ends (/zsemle bark to test it, /zsemle mute to silence it).
Every figure has 9 poses (awake, blink, speaking, yawn, sad, happy, alert, angry, tired):

and some have special frames (work animation, long turn, fatigue levels):

Switch figures with /zsemle skin (a pane with previews: click one), /zsemle skin penguin, the bubble's skin button, or the zsemle.skin row in /config (there the figures go by their short ids: zsemle is the dog, pingvin the penguin and so on; /zsemle skin lists both).
| signal | what Zsemle does |
|---|---|
| 5-hour, weekly and spend limits | heads-up at 50%, stronger at 75%, red at 90%; at 95% it stops tool calls and prompts until the window resets (/zsemle wake overrides for the session). Every threshold crossed raises a toast with the reset time. |
| pace | if the window runs out before it resets at the current pace, it tells you roughly when |
| reset | cheers when a window is full again: the moment its reset time passes, even in an idle session, and only once |
| fresh data | a limit reading older than 15 minutes shows its age on the limit line (data 25 min ago); a window past its reset reads 0% (reset) until the next response brings the real number |
| API errors | explains rate limit, overload, output token limit, billing, login, unavailable model and server errors in plain words |
| automatic model switch | tells you when the engine fell back to another model |
| automatic compaction | tells you when the context was compacted, warns at 90% before it happens |
| context | tired at 60%, asks for a status note before /compact at 80% |
| cost | on API-key use (no subscription windows), speaks up at 1, 5, 10, 20, 50, 100 and 200 dollars |
| status line | 5h 18% · week 52% · ctx 34% under the prompt (/zsemle bar off hides it) |
Also: a sound when a turn longer than 3 minutes finishes, a content guard (curly quotes as JS string delimiters, emoji in code, secrets such as API keys, tokens and private keys outside .env, and optionally em/en dashes), a deploy sniff that reminds the model to check the live state, a call-out when the same command keeps failing, a port guard for two dev servers on one port, a nudge to commit when many files are changed, a break reminder after 90 minutes, sleep when idle, pets and daily stats.
All on by default; switch any of them off in /config or with /zsemle feature <name> off, for example /zsemle feature commitGuard off. /zsemle features lists them with their state.
| feature | what it does |
|---|---|
fatigueLook | the figure gets tired as the worst window fills (heavier eyes from 75%), fresh again after a reset: you can read the limit at a glance |
budgetPlanner | splits the weekly window into daily shares (the rest over the days left) and warns in the evening when today went over; /zsemle limit shows today's share |
modelAdvice | a simple task (rename, format, typo, translate...) on a big model at a high limit: suggests /model sonnet |
contextSaver | above 70% context, asks the model for shorter answers and smaller file reads |
commitGuard | stops a git commit (Bash or PowerShell tool) once when no test ran since the last edit; repeat the commit within 2 minutes to go ahead. A commit into a throwaway repository (made by git init in the same command, or in a temp folder) is not stopped |
loopWatch | speaks up when the model edits the same file a fifth time in one turn |
morningBrief | the first prompt of the day carries a short brief: yesterday's day summary and today's budget |
lessonSniff | when the same error comes back in a third turn, suggests recording it as a lesson |
daySummary | when you sign off ("thanks, that's all"), asks for a short summary of the day and keeps it for the next morning's brief |
Two more need no switch: /zsemle ask <question> answers from your limits and stats with a small model, and /zsemle week shows a weekly activity chart (tool calls per hour) in a pane.
Zsemle is a Claude Code mod (function-hooks plugin). It needs a recent Claude Code with plugin hooks support (tested on 2.1.286), in the terminal or the desktop app's Code tab.
/plugin marketplace add Parais4/parais-mods
/plugin install zsemle@parais-mods
/plugin and reload). Zsemle appears above the prompt on the right./config and set zsemle.language to hu for Hungarian, pick a zsemle.skin, and switch features on or off.Update: /plugin marketplace update parais-mods, then start a new session. Remove: /plugin uninstall zsemle@parais-mods.
Run it from a local folder instead (for hacking on it): clone this repo and start Claude Code with claude --plugin-dir plugins/zsemle, or point the CLAUDE_CODE_PLUGIN_DIRS environment variable at that folder. Check it with claude plugin validate plugins/zsemle and claude plugin test plugins/zsemle.
| English | Hungarian | what |
|---|---|---|
/zsemle | status and help | |
/zsemle limit | every window, the pace, today's budget, the context and the cost | |
/zsemle skin | the figure picker pane | |
/zsemle skin <name> | /zsemle skin <név> | wear a figure, e.g. penguin (next steps through them) |
/zsemle ask <q> | /zsemle kerdes <k> | ask Zsemle (small model) |
/zsemle week | /zsemle heti | weekly activity chart |
/zsemle summary | /zsemle napzaro | a summary of this session (small model) |
/zsemle features | /zsemle kapcsolok | the optional features and their state |
/zsemle feature <name> off/on | /zsemle kapcsolo <név> ki/be | switch one, e.g. commitGuard |
/zsemle stats | /zsemle stat | today's stats |
/zsemle ok | "got it": hides the bubble until there is news (or the bubble's ok button) | |
/zsemle mute / sound | nemit / hang | sound off/on |
/zsemle pet | simi | a pat (or the ♥ button next to the figure or in the bubble) |
/zsemle hide / show | elrejt / mutat | hide or show the figure (show also opens a folded one) |
| (button) | fold in the bubble or next to the quiet figure folds Zsemle down to one small button in the band; pressing that button opens it again | |
/zsemle bar off/on | sor ki/be | the limit line under the prompt |
/zsemle guard off/on | or ki/be | the content guard for this session |
/zsemle wake | ebreszt | let work go on past 95% in this session |
/zsemle bark | ugass | test the sound |
In /config, or in ~/.claude/settings.json under pluginConfigs.zsemle.options:
| option | default | what |
|---|---|---|
language | en | en or hu |
skin | zsemle | the default figure, by its short id (zsemle dog, cirmi cat, trutyi slime, kapocs paperclip, pingvin penguin, teknos turtle, horcsog hamster, bagoly owl, rubik cube, gumikacsa duck, bogre mug, kaktusz cactus, sarkany dragon, szellem ghost, robot robot) |
guardDashes | false | the content guard also blocks em and en dashes |
| feature switches | true | see Optional features |
Example:
"pluginConfigs": {
"zsemle": {
"options": { "language": "hu", "skin": "bogre", "commitGuard": false }
}
}
/plugin that zsemle is enabled, and /zsemle show in case it was hidden. A survey or dialog above the prompt hides it for that moment.skin, no limit). The session runs an older version: start a new one./zsemle mute turns it off.data 3 h ago), and a window past its reset reads 0% (reset). The next answer brings the fresh numbers./zsemle feature commitGuard off.date +%z or PowerShell); in Hungarian Zsemle uses Budapest time.Zsemle runs inside your Claude Code session and sends nothing anywhere on its own. /zsemle ask and /zsemle summary make one small model call through your own Claude Code session (counted in your usage). It runs local commands only to look: git status --porcelain (commit sniff), netstat -ano or lsof (port guard), date or PowerShell (time zone), and PowerShell's SoundPlayer for the sound on Windows. Its state (stats, choices, the day summary) stays in Claude Code's plugin store on your machine.
Everything Zsemle does, for reviewers and the curious. It is a Claude Code mod: one hooks module (hooks/register.tsx) with pure helpers beside it, no network code of its own, no files written.
Hooks and what each one does
| hook | what it does with what it sees | does it change anything? |
|---|---|---|
session.start | registers /zsemle, reads the usage figures, loads the user's choices from the plugin store, starts a 250 ms timer that redraws the figure | no |
session.measure | reads the rate-limit windows, context fill and cost; raises toasts at thresholds, pace forecasts and resets | no |
classic.StopFailure | reads the error kind of a turn that ended on an API error and explains it in the bubble | no |
classic.PostModelSwitch | notices an automatic model fallback and tells the user | no |
classic.SubagentStart, classic.SubagentStop | reads the subagent id to add or remove a mini figure | no |
session.compact | passes the compaction on (next) and tells the user when it was automatic | no |
tool.call | sees every tool call. It denies a call only in these cases, with the reason as the answer, never standing in for the tool otherwise: at 95% of a usage window (until /zsemle wake); when a file write (Write, Edit, MultiEdit, NotebookEdit) would add a secret, an emoji in code, a curly quote as a JS string delimiter, or (option guardDashes) an em/en dash; once for a git commit with no test since the last edit (commitGuard). Every other call goes on through next and its result is returned; after a Bash call it may append a short note for the model (check the live state after a deploy, a command keeps failing, two dev servers on one port, a repeated error worth a lesson, the same file edited a fifth time) | denies as listed; adds notes to the tool result |
prompt.submit | reads the prompt text only to spot a sign-off ("thanks, that's all") or a simple task; drops the prompt at 95% of a window (until /zsemle wake); may add notes for the model (see below) | adds notes; drops at 95% |
turn.start, turn.complete | times turns; keeps the answer of a day-summary turn as the day summary; plays the figure's sound after a turn over 3 minutes | no |
command.run (/zsemle) | answers the commands listed above | no |
ui.render, ui.message | draws the figure, its bubble, the skin picker and the weekly chart; takes clicks on them | no |
What it puts in the prompts and tool results it passes on. Short plain-text notes for the model, each from a feature the user can switch off: the context is above 80% (update the status note, then suggest /compact), above 70% (work lean: short answers, small file reads), the morning brief (yesterday's summary and today's budget, to tell the user), the day summary request when the user signs off, a deploy just ran (check the live state), the same command failed several times (find the root cause), two dev servers share a port (with the PIDs), an error came back a third time (suggest recording a lesson), a file edited a fifth time in one turn (stop and look at the whole).
Programs it runs, and why. Each with fixed arguments:
git status --porcelain in the session folder, every 2 minutes while working: counts uncommitted files for the commit reminder.netstat -ano (Windows) or lsof -nP -iTCP -sTCP:LISTEN (elsewhere), every 2 minutes and after a dev-server command: finds two processes on one dev port.date +%z (or powershell -NoProfile -Command "Get-Date -Format zzz" on Windows), once at session start in English: the time zone for the times it shows.powershell -NoProfile -Command "(New-Object Media.SoundPlayer '<plugin folder>\sounds\<figure>.wav').PlaySync()" when a long turn ends: Windows terminals have no other way to play the bundled sound. The path is always one of the plugin's own WAV files. Elsewhere the sound goes through Claude Code's own audio call.What it sends, and where. Nothing on its own. Two commands make one model call each through the user's own Claude Code session ($.model.complete, a small model), only when the user runs them: /zsemle ask <question> sends the question with the user's limit report and today's stats; /zsemle summary sends the last 60 messages of the session, each cut to 400 characters. Both count toward the user's own usage. No telemetry, no analytics, no third parties.
Secrets. Zsemle reads no credentials. The content guard only matches key-shaped patterns (for example sk-ant-..., AKIA..., ghp_..., private-key headers) in the text a tool is about to write, to stop a leak; it never stores or sends what it matches.
Reflective code. The tool-call hook reads the input of file-writing tools as a plain record (file_path, content, new_string, old_string, edits, new_source) because their shapes differ per tool; Object.hasOwn and prefixed keys keep the error-signature counter safe from prototype keys.
Other plugins. It calls no other plugin and needs none.
Made by Parais Gergely. Zsemle is a fan-made mod, not affiliated with or endorsed by Anthropic. The bark sound is CC0 (see sounds/CREDITS.md), the chime is generated. Code under the MIT license, see LICENSE. Privacy policy: PRIVACY.md. Terms of use: TERMS.md. Support: issues.
Pixelgrafikus társfigura a Claude Code promptja fölött, amely figyeli a használati limiteket.
Figyeli az 5 órás és a heti keretet, a kontextust, az API-hibákat és a költséget. Szövegbuborékban szól, ha valami fontos történik, és 95%-nál leállítja a munkát, hogy ne feladat közben fogyjon el a keret. Őrzi a fájlírásokat, deploy után szimatol, szól a portütközésről, és láthatóan elfárad, ahogy fogy a limit. 15 figura közül választhatsz, terminálban és a desktop alkalmazás Code fülén is működik.
Telepítés: a Claude Code-ban
/plugin marketplace add Parais4/parais-mods
/plugin install zsemle@parais-mods
majd indíts új munkamenetet. Magyar nyelvhez a /config-ban a zsemle.language legyen hu.
Figurák: zsemle (kutya), cirmi (macska), trutyi (slime), kapocs (gemkapocs), pingvin, teknos, horcsog, bagoly, rubik, gumikacsa, bogre, kaktusz, sarkany, szellem, robot. Váltás: /zsemle skin (panel előnézettel) vagy /zsemle skin macska.
Választható funkciók (alapból mind bekapcsolva; kikapcsolás: /zsemle kapcsolo <név> ki vagy a /config): fáradtság-megjelenés, heti költségvetés napi adagra, modelltanács, kontextusfék 70% fölött, commit-őr, hurokfigyelő, reggeli brief, tanulság-szimat, napzáró összefoglaló. A teljes lista: /zsemle kapcsolok.
Parancsok: /zsemle (súgó), /zsemle limit, /zsemle kerdes <kérdés>, /zsemle heti, /zsemle napzaro, /zsemle stat, /zsemle nemit, /zsemle simi, /zsemle sor ki|be, /zsemle or ki|be, /zsemle ebreszt.
Ha nem látszik: telepítés vagy frissítés után indíts új munkamenetet; a futó munkamenet a régi változattal megy tovább.
hooks/register.tsx 1597 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register, Timer } from 'claude-code'
3
4import type { Limit } from '../types'
5import { asLang, lang, parseOffset, setLang, setUtcOffset, tr } from './i18n'
6import {
7 ageText,
8 apiErrorNote,
9 autoCompactNote,
10 chaseNote,
11 CONTEXT_TIRED_AT,
12 CONTEXT_COMPACT_AT,
13 CONTEXT_CRITICAL_AT,
14 contextCriticalNote,
15 costCrossed,
16 costNote,
17 crossingNote,
18 crossings,
19 deployNote,
20 EMPTY_STATS,
21 FAIL_STREAK,
22 frustrationNote,
23 INTERRUPTS_TO_NOTE,
24 recentInterrupts,
25 formatDuration,
26 guardReason,
27 IDLE_AFTER_MS,
28 isDeployCommand,
29 isTestCommand,
30 current,
31 dueResets,
32 judge,
33 localTime,
34 shortModel,
35 modelSwitchNote,
36 paceNote,
37 refillNote,
38 resetNote,
39 resets,
40 REST_AFTER_MS,
41 BREAK_GAP_MS,
42 BARK_AFTER_MS,
43 shouldBark,
44 SPRITE_COLUMNS,
45 SPRITE_ROWS,
46 statsText,
47 STOP_AT,
48 worstForecast,
49 writeParts,
50 spriteRuns,
51 spriteSvg,
52 miniRuns,
53 miniSvg,
54 MINI_COLUMNS,
55 MINI_ROWS,
56 commitBucket,
57 commitNote,
58 COMMIT_SNIFF_AT,
59 contextSaveNote,
60 contextSavePromptNote,
61 isClosingPrompt,
62 isDevServerCommand,
63 isProjectCard,
64 parseLsof,
65 parseNetstat,
66 porcelainCount,
67 portClashes,
68 portModelNote,
69 portNote,
70 queueCount,
71 reflectNote,
72 reflectPromptNote,
73 SAVE_RESET_BELOW,
74 SCAN_EVERY_MS,
75} from './logic'
76import type { DayStats, Frame, PortClash, Pose, Verdict } from './logic'
77import {
78 askSystem,
79 briefNote,
80 budgetLine,
81 budgetOverNote,
82 commitGuardNote,
83 CONTEXT_SAVER_AT,
84 contextSaverNote,
85 dailyBudget,
86 daySummaryNote,
87 errorSignature,
88 fatigueLevel,
89 featureLabel,
90 FEATURES,
91 featureWord,
92 findFeature,
93 isBigModel,
94 isCommitCommand,
95 isThrowawayCommit,
96 isSimpleTask,
97 LESSON_AFTER,
98 lessonBubble,
99 lessonNote,
100 LOOP_EDITS,
101 loopBubble,
102 loopNote,
103 modelAdviceNote,
104 parseProjectTable,
105 summaryOf,
106 weekChart,
107} from './features'
108import type { Budget, Feature } from './features'
109import { asSkin, DEFAULT_SKIN, findSkin, labelOf, nextSkin, SKIN_IDS, skinOf, skinWord, voiceOf } from './skins'
110import type { SkinId, Voice } from './skins'
111
112const limits = atom({ plugin: 'zsemle', key: 'limits' } as const, [] as Limit[])
113// When the limit readings last arrived, and the resets already told by the clock.
114const limitsReadAt = atom({ plugin: 'zsemle', key: 'limitsReadAt' } as const, 0)
115const announcedResets = atom({ plugin: 'zsemle', key: 'announcedResets' } as const, [] as string[])
116const isHidden = atom({ plugin: 'zsemle', key: 'isHidden' } as const, false)
117const isFolded = atom({ plugin: 'zsemle', key: 'isFolded' } as const, false)
118const isWoken = atom({ plugin: 'zsemle', key: 'isWoken' } as const, false)
119const isGuardOff = atom({ plugin: 'zsemle', key: 'isGuardOff' } as const, false)
120
121const COAT = '#c8985c'
122const HEART = '#e87887'
123const SEC = 1000
124const MIN = 60 * SEC
125const SKIN_PANE = 'zsemle-skins'
126const WEEK_PANE = 'zsemle-week'
127const DAY = 86400000
128
129function help(): string {
130 if (lang() === 'en') {
131 return [
132 '/zsemle ok got it (hides the bubble)',
133 '/zsemle limit every limit, the pace, the context and the cost right now',
134 '/zsemle skin figure picker pane (also in /config: zsemle.skin)',
135 `/zsemle skin <name> figure: ${SKIN_IDS.map(skinWord).join(', ')}`,
136 '/zsemle stats stats for the day',
137 '/zsemle mute sound off (the button too)',
138 '/zsemle sound sound back on',
139 '/zsemle pet a pat',
140 '/zsemle hide hides the figure',
141 '/zsemle show shows it again',
142 '/zsemle bar off|on the limit line under the prompt off/on',
143 '/zsemle wake let work go on past 95% in this session',
144 '/zsemle guard off|on content guard (dashes, curly quotes, emoji, secrets) off/on',
145 '/zsemle bark test the sound',
146 '/zsemle ask <q> ask Zsemle (limits, stats, the session) with a small model',
147 '/zsemle week weekly activity chart',
148 '/zsemle summary a day summary of this session',
149 '/zsemle features the optional features and their switches',
150 '/zsemle feature <name> off|on switch one',
151 ].join('\n')
152 }
153 return [
154 '/zsemle ok oké, értettem (eltünteti a buborékot)',
155 '/zsemle stat napi statisztika',
156 '/zsemle limit az összes limit, tempó, kontextus és költség most',
157 '/zsemle skin figura-választó panel (a /config-ban is: zsemle.skin)',
158 `/zsemle skin <név> figura: ${SKIN_IDS.join(', ')}`,
159 '/zsemle nemit hang ki (a gombbal is)',
160 '/zsemle hang hang vissza',
161 '/zsemle simi simogatás',
162 '/zsemle elrejt elrejti a figurát',
163 '/zsemle mutat újra megjeleníti',
164 '/zsemle sor ki|be a limit-sor a prompt alatt ki/be',
165 '/zsemle ebreszt 95% felett is továbbenged ebben a munkamenetben',
166 '/zsemle or ki|be tartalomőr (gondolatjel, idézőjel, emoji, titok) ki/be',
167 '/zsemle ugass próba-hang',
168 '/zsemle kerdes <k> kérdezd Zsemlét (limit, statisztika, munkamenet) egy kis modellel',
169 '/zsemle heti heti aktivitás-grafikon',
170 '/zsemle napzaro napzáró összefoglaló erről a munkamenetről',
171 '/zsemle kapcsolok a választható funkciók és kapcsolóik',
172 '/zsemle kapcsolo <név> ki|be egy funkció ki/be',
173 ].join('\n')
174}
175
176// Every command word, Hungarian and English, mapped to one action.
177const COMMANDS: Record<string, string> = {
178 limit: 'limit',
179 limitek: 'limit',
180 limits: 'limit',
181 stat: 'stat',
182 stats: 'stat',
183 nemit: 'mute',
184 'némít': 'mute',
185 mute: 'mute',
186 hang: 'sound',
187 sound: 'sound',
188 unmute: 'sound',
189 ok: 'ok',
190 simi: 'pet',
191 pet: 'pet',
192 elrejt: 'hide',
193 hide: 'hide',
194 mutat: 'show',
195 show: 'show',
196 'sor ki': 'bar off',
197 'bar off': 'bar off',
198 'sor be': 'bar on',
199 'bar on': 'bar on',
200 ebreszt: 'wake',
201 'ébreszt': 'wake',
202 wake: 'wake',
203 'or ki': 'guard off',
204 'őr ki': 'guard off',
205 'guard off': 'guard off',
206 'or be': 'guard on',
207 'őr be': 'guard on',
208 'guard on': 'guard on',
209 ugass: 'bark',
210 bark: 'bark',
211}
212
213// Transient state of this load. A reload starts it over; limits and switches
214// live in $.state; mute, skin and daily stats in $.store.
215const S = {
216 skin: DEFAULT_SKIN as SkinId,
217 reflectQueue: '',
218 guardDashes: false,
219 projectTable: '',
220 features: Object.fromEntries(FEATURES.map(f => [f, true])) as Record<Feature, boolean>,
221 turnStartedAt: 0,
222 turnId: '',
223 turnEdits: new Map<string, number>(),
224 lastEditAt: 0,
225 lastTestAt: 0,
226 commitWarnCmd: '',
227 commitWarnAt: 0,
228 isContextSaverSent: false,
229 adviceAt: 0,
230 isSummaryPending: false,
231 lastModelSeen: '',
232 // Running subagents by id, with when each started: one mini figure each.
233 agents: new Map<string, number>(),
234 nextAgentCheckAt: 0,
235 startedAt: new Map<string, number>(),
236 isWorking: false,
237 lastActivity: 0,
238 workStart: 0,
239 restUntil: 0,
240 restShownAt: 0,
241 wagUntil: 0,
242 droopUntil: 0,
243 heartUntil: 0,
244 sniffUntil: 0,
245 growlUntil: 0,
246 growlText: '',
247 chaseUntil: 0,
248 barkUntil: 0,
249 barkPoseUntil: 0,
250 barkText: '',
251 // A limit-ish event worth a bubble of its own: an API error, an automatic
252 // model switch or compaction, a window that reset, a cost milestone.
253 alertUntil: 0,
254 alertText: '',
255 alertTone: 'yellow' as Tone,
256 alertPose: 'droop' as Pose,
257 contextPct: null as number | null,
258 isContextCriticalSent: false,
259 costUsd: 0,
260 pacedWindows: new Set<string>(),
261 lastFail: '',
262 failCount: 0,
263 isMuted: false,
264 isStatusOff: false,
265 lastStatus: '',
266 statsDay: '',
267 stats: { ...EMPTY_STATS } as DayStats,
268 lastKey: '',
269 ackedKey: '',
270 tick: null as Timer | null,
271 isSaveNeeded: false,
272 isSaveNoteSent: false,
273 isSaved: false,
274 reflectCount: 0,
275 reflectAskUntil: 0,
276 dirtyCount: 0,
277 portClash: null as PortClash | null,
278 nextScanAt: 0,
279 isScanning: false,
280 // Marveen-atvetel 40: quota snapshot, hook-state watch, interrupts.
281 quotaKey: '',
282 quotaAt: 0,
283 seenInjection: -1,
284 seenHookErrBytes: -1,
285 interrupts: [] as number[],
286}
287
288const voice = (): Voice => voiceOf(S.skin)
289
290// The host already labels status lines and toasts with the plugin's name
291// ("zsemle"), so the default figure's name would be said twice; other figures
292// keep theirs, it tells which one is speaking.
293const tag = (sep: string): string => (voice().name.toLowerCase() === 'zsemle' ? '' : `${voice().name}${sep}`)
294
295type Tone = 'red' | 'yellow' | 'coat' | 'heart' | 'dim' | 'plain'
296// `key` names the message: once acknowledged it stays hidden until a message
297// with another key comes. `quiet` messages never open the bubble.
298type View = { pose: Frame; message: string; tone: Tone; key: string; quiet?: true }
299
300const alternate = (now: number, a: Frame, b: Frame, ms = 250): Frame => (Math.floor(now / ms) % 2 === 0 ? a : b)
301
302const isOn = (f: Feature) => S.features[f]
303
304/** No work for 20 minutes. */
305const isIdle = (now: number) => !S.isWorking && S.lastActivity > 0 && now - S.lastActivity >= IDLE_AFTER_MS
306
307/** Shows `text` in the bubble for `ms`, with a pose and a tone, and as a toast. */
308function alert($: EngineInterface, now: number, text: string, opts: { tone?: Tone; pose?: Pose; ms?: number; toast?: boolean } = {}): void {
309 S.alertText = text
310 S.alertTone = opts.tone ?? 'yellow'
311 S.alertPose = opts.pose ?? 'droop'
312 S.alertUntil = now + (opts.ms ?? 3 * MIN)
313 if (opts.toast !== false) $.ui.toast(`${tag(': ')}${text}`, { timeoutMs: 8000 })
314 $.ui.invalidate('ui.render')
315}
316
317/** What the figure shows right now: the most urgent message wins, the pose follows it. */
318function view(now: number, verdict: Verdict, isStopped: boolean, all: readonly Limit[]): View {
319 const v = voice()
320 const tired = S.contextPct !== null && S.contextPct >= CONTEXT_TIRED_AT
321 const idle = isIdle(now)
322 const skin = skinOf(S.skin)
323 // Fatigue: the resting face tires as the worst window fills, and is fresh again after a reset.
324 const level = isOn('fatigueLook') ? fatigueLevel(all) : 0
325 const rest: Frame = skin.levels !== undefined && skin.levels.length === 4 ? `level${level}` : level >= 2 ? 'tired' : 'awake'
326 const work = skin.workFrames ?? []
327
328 let base: Frame = rest
329 if (now < S.wagUntil || now < S.heartUntil) base = alternate(now, rest, 'wag')
330 else if (now < S.droopUntil) base = 'droop'
331 else if (S.isWorking && skin.longTurn !== undefined && S.turnStartedAt > 0 && now - S.turnStartedAt >= BARK_AFTER_MS) base = 'long'
332 else if (S.isWorking && work.length > 0) base = `work${Math.floor(now / 300) % work.length}`
333 else if ((tired || level >= 3) && now % 20000 < 1500) base = 'yawn'
334 else if (now % 4700 < (level >= 1 ? 700 : 300)) base = 'blink'
335
336 if (isStopped) return { pose: 'blink', message: `${verdict.message} ${v.snore}...`, tone: 'red', key: 'stop' }
337 if (now < S.growlUntil) return { pose: 'growl', message: `${v.growl} ${S.growlText}`, tone: 'red', key: `growl:${S.growlUntil}` }
338 if (now < S.alertUntil) {
339 const pose = S.alertPose === 'wag' ? alternate(now, 'awake', 'wag') : S.alertPose
340 return { pose, message: S.alertText, tone: S.alertTone, key: `alert:${S.alertUntil}` }
341 }
342 if (verdict.level === 'critical') {
343 return { pose: alternate(now, 'droop', 'awake', 900), message: verdict.message, tone: 'red', key: `critical:${verdict.worst?.kind ?? ''}` }
344 }
345 if (now < S.chaseUntil) {
346 return {
347 pose: 'droop',
348 message: tr(
349 'Körbe-körbe járunk: ugyanaz a parancs többször elbukott. Állj meg, keresd a gyökérokot.',
350 'We are going in circles: the same command failed several times. Stop and find the root cause.',
351 ),
352 tone: 'yellow',
353 key: `chase:${S.chaseUntil}`,
354 }
355 }
356 const pace = verdict.level === 'stop' ? null : worstForecast(all, now)
357 if (pace !== null) {
358 return {
359 pose: alternate(now, 'sniff', 'awake', 600),
360 message: paceNote(pace, now),
361 tone: 'yellow',
362 key: `pace:${pace.limit.kind}:${pace.limit.resetsAt ?? ''}`,
363 }
364 }
365 if (S.portClash !== null) {
366 return {
367 pose: alternate(now, 'sniff', 'awake', 400),
368 message: portNote(S.portClash),
369 tone: 'yellow',
370 key: `port:${S.portClash.port}:${S.portClash.pids.join(',')}`,
371 }
372 }
373 if (now < S.heartUntil) return { pose: base, message: `♥ ♥ ♥ ${v.pet}`, tone: 'heart', key: `heart:${S.heartUntil}` }
374 if (now < S.sniffUntil) {
375 return {
376 pose: alternate(now, 'sniff', 'awake', 400),
377 message: `${v.sniff} ${tr('Deploy volt: kérdezd vissza az élő állapotot.', 'A deploy ran: check the live state.')}`,
378 tone: 'yellow',
379 key: `sniff:${S.sniffUntil}`,
380 }
381 }
382 if (now < S.barkUntil) {
383 return { pose: now < S.barkPoseUntil ? 'bark' : base, message: S.barkText, tone: 'coat', key: `bark:${S.barkUntil}` }
384 }
385 if (now < S.restUntil) {
386 return {
387 pose: base,
388 message: tr('Már 90 perce dolgozol. Tarts 5 perc szünetet!', 'You have been at it for 90 minutes. Take a 5 minute break!'),
389 tone: 'yellow',
390 key: `rest:${S.restShownAt}`,
391 }
392 }
393 if (now < S.reflectAskUntil && S.reflectCount > 0) {
394 return { pose: base, message: reflectNote(S.reflectCount), tone: 'yellow', key: `reflect:${S.reflectAskUntil}` }
395 }
396 if (idle) {
397 const tail = S.reflectCount > 0 ? ` ${reflectNote(S.reflectCount)}` : ''
398 return {
399 pose: 'blink',
400 message: `${tr('Alszom... 20 perce nem dolgoztunk.', 'Sleeping... no work for 20 minutes.')} ${v.snore}${tail}`,
401 tone: 'dim',
402 key: `idle:${S.lastActivity}`,
403 }
404 }
405 if (verdict.level === 'high' || verdict.level === 'warn') {
406 return { pose: base, message: verdict.message, tone: 'yellow', key: `${verdict.level}:${verdict.worst?.kind ?? ''}` }
407 }
408 if (S.isSaveNeeded && S.contextPct !== null) {
409 return {
410 pose: base === rest && now % 8000 < 1500 ? 'yawn' : base,
411 message: contextSaveNote(S.contextPct, S.isSaved),
412 tone: S.isSaved ? 'coat' : 'yellow',
413 key: `context:${S.isSaved}`,
414 }
415 }
416 if (S.dirtyCount >= COMMIT_SNIFF_AT) {
417 return { pose: base, message: commitNote(S.dirtyCount), tone: 'yellow', key: `commit:${commitBucket(S.dirtyCount)}` }
418 }
419 if (now < S.wagUntil) return { pose: base, message: v.happy, tone: 'coat', key: `wag:${S.wagUntil}` }
420 if (now < S.droopUntil) {
421 return { pose: base, message: tr('Hoppá, elbukott egy parancs.', 'Oops, a command failed.'), tone: 'dim', key: `droop:${S.droopUntil}` }
422 }
423 return { pose: base, message: verdict.message, tone: 'plain', key: 'ok', quiet: true }
424}
425
426const showsBubble = (v: View) => v.quiet !== true && v.key !== S.ackedKey
427
428function statusText(verdict: Verdict): string {
429 const ctx = S.contextPct === null ? '' : `${tr('ktx', 'ctx')} ${Math.round(S.contextPct)}%`
430 // Off a subscription there are no windows: the session's cost is the limit to watch.
431 const cost = verdict.status === '' && S.costUsd > 0 ? tr(`${S.costUsd.toFixed(2)} $`, `$${S.costUsd.toFixed(2)}`) : ''
432 return [verdict.status, ctx, cost].filter(s => s !== '').join(' · ')
433}
434
435function activity(now: number): void {
436 if (S.lastActivity === 0 || now - S.lastActivity > BREAK_GAP_MS) {
437 S.workStart = now
438 S.restShownAt = 0
439 }
440 S.lastActivity = now
441}
442
443function toLimits(raw: readonly { kind: string; percentUsed: number; resetsAt?: string }[]): Limit[] {
444 return raw.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt }))
445}
446
447async function currentView($: EngineInterface, now: number): Promise<{ v: View; verdict: Verdict }> {
448 const all = await read($, limits)
449 const verdict = judge(all, now, await read($, limitsReadAt))
450 const isStopped = verdict.level === 'stop' && !(await read($, isWoken))
451 return { v: view(now, verdict, isStopped, all), verdict }
452}
453
454async function bumpStats($: EngineInterface, change: (s: DayStats) => DayStats): Promise<void> {
455 const day = localTime(await $.clock.now()).day
456 if (S.statsDay !== day) {
457 const saved = (await $.store.get(`stats:${day}`)) as Partial<DayStats> | undefined
458 S.stats = { ...EMPTY_STATS, ...(saved ?? {}) }
459 S.statsDay = day
460 }
461 S.stats = change(S.stats)
462 await $.store.set(`stats:${day}`, S.stats)
463}
464
465async function playBark($: EngineInterface): Promise<void> {
466 if (S.isMuted) return
467 const asset = voice().sound
468 const root = $.plugin.root
469 if (/^[A-Za-z]:[\\/]/.test(root)) {
470 // Windows terminals have no player for $.audio.play, so PowerShell plays it.
471 const file = `${root.replace(/[\\/]+$/, '')}\\${asset.replace(/\//g, '\\')}`.replace(/'/g, "''")
472 await $.process.run(
473 ['powershell', '-NoProfile', '-NonInteractive', '-Command', `(New-Object Media.SoundPlayer '${file}').PlaySync()`],
474 { timeoutMs: 10000 },
475 )
476 return
477 }
478 await playAsset($, asset)
479}
480
481/** Plays one of the bundled sounds; every asset is named as fixed text. */
482async function playAsset($: EngineInterface, asset: string): Promise<void> {
483 switch (asset) {
484 case 'sounds/bark.wav':
485 await $.audio.play({ asset: 'sounds/bark.wav' })
486 return
487 case 'sounds/chime.wav':
488 await $.audio.play({ asset: 'sounds/chime.wav' })
489 return
490 case 'sounds/meow.wav':
491 await $.audio.play({ asset: 'sounds/meow.wav' })
492 return
493 case 'sounds/blub.wav':
494 await $.audio.play({ asset: 'sounds/blub.wav' })
495 return
496 case 'sounds/ding.wav':
497 await $.audio.play({ asset: 'sounds/ding.wav' })
498 return
499 case 'sounds/penguin.wav':
500 await $.audio.play({ asset: 'sounds/penguin.wav' })
501 return
502 case 'sounds/hum.wav':
503 await $.audio.play({ asset: 'sounds/hum.wav' })
504 return
505 case 'sounds/squeak.wav':
506 await $.audio.play({ asset: 'sounds/squeak.wav' })
507 return
508 case 'sounds/hoot.wav':
509 await $.audio.play({ asset: 'sounds/hoot.wav' })
510 return
511 case 'sounds/click.wav':
512 await $.audio.play({ asset: 'sounds/click.wav' })
513 return
514 case 'sounds/quack.wav':
515 await $.audio.play({ asset: 'sounds/quack.wav' })
516 return
517 case 'sounds/clink.wav':
518 await $.audio.play({ asset: 'sounds/clink.wav' })
519 return
520 case 'sounds/boing.wav':
521 await $.audio.play({ asset: 'sounds/boing.wav' })
522 return
523 case 'sounds/roar.wav':
524 await $.audio.play({ asset: 'sounds/roar.wav' })
525 return
526 case 'sounds/ooo.wav':
527 await $.audio.play({ asset: 'sounds/ooo.wav' })
528 return
529 case 'sounds/beep.wav':
530 await $.audio.play({ asset: 'sounds/beep.wav' })
531 return
532 default:
533 return
534 }
535}
536
537async function pet($: EngineInterface): Promise<void> {
538 S.heartUntil = (await $.clock.now()) + 3 * SEC
539 $.ui.invalidate('ui.render')
540 await bumpStats($, s => ({ ...s, pets: s.pets + 1 }))
541}
542
543async function setMuted($: EngineInterface, muted: boolean): Promise<void> {
544 S.isMuted = muted
545 await $.store.set('muted', muted)
546 $.ui.invalidate('ui.render')
547}
548
549/** Wears another figure: kept across sessions, drawn at once on every surface. */
550async function chooseSkin($: EngineInterface, id: SkinId): Promise<void> {
551 S.skin = id
552 await $.store.set('skin', id)
553 S.lastKey = ''
554 $.ui.invalidate('ui.render')
555}
556
557/**
558 * The figure to wear: the one last chosen with /zsemle skin, unless the
559 * /config row (zsemle.skin) changed since, which then wins.
560 */
561async function loadSkin($: EngineInterface, options: PluginOptions): Promise<SkinId> {
562 const fromConfig = asSkin(options.skin)
563 const seen = await $.store.get('skinConfigSeen')
564 if (seen !== fromConfig) {
565 await $.store.set('skinConfigSeen', fromConfig)
566 await $.store.set('skin', fromConfig)
567 return fromConfig
568 }
569 const stored = await $.store.get('skin')
570 return stored === undefined ? fromConfig : asSkin(stored)
571}
572
573/** The machine's UTC offset for English times (Hungarian keeps Budapest's own rules). */
574async function measureOffset($: EngineInterface): Promise<void> {
575 try {
576 const run = isWindowsRoot($.plugin.root)
577 ? await $.process.run(['powershell', '-NoProfile', '-NonInteractive', '-Command', 'Get-Date -Format zzz'], { timeoutMs: 8000 })
578 : await $.process.run(['date', '+%z'], { timeoutMs: 8000 })
579 setUtcOffset(run.exitCode === 0 ? parseOffset(run.stdout) : null)
580 } catch {
581 setUtcOffset(null)
582 }
583}
584
585/** "Oké, értettem": hides the message on show until another one comes. */
586async function ack($: EngineInterface): Promise<void> {
587 const { v } = await currentView($, await $.clock.now())
588 S.ackedKey = v.key
589 $.ui.invalidate('ui.render')
590}
591
592const isWindowsRoot = (root: string) => /^[A-Za-z]:[\\/]/.test(root)
593
594async function scanCommits($: EngineInterface): Promise<void> {
595 const cwd = await $.session.cwd()
596 const run = await $.process.run(['git', 'status', '--porcelain'], { cwd, timeoutMs: 8000 })
597 S.dirtyCount = run.exitCode === 0 ? porcelainCount(run.stdout) : 0
598}
599
600async function scanPorts($: EngineInterface): Promise<PortClash | null> {
601 const isWindows = isWindowsRoot($.plugin.root)
602 const run = isWindows
603 ? await $.process.run(['netstat', '-ano'], { timeoutMs: 8000 })
604 : await $.process.run(['lsof', '-nP', '-iTCP', '-sTCP:LISTEN'], { timeoutMs: 8000 })
605 const use = isWindows ? parseNetstat(run.stdout) : parseLsof(run.stdout)
606 S.portClash = portClashes(use)[0] ?? null
607 return S.portClash
608}
609
610async function refreshReflect($: EngineInterface): Promise<number> {
611 S.reflectCount = 0
612 return S.reflectCount
613}
614
615
616async function scanAll($: EngineInterface): Promise<void> {
617 await Promise.all([
618 scanCommits($).catch(() => undefined),
619 scanPorts($).catch(() => undefined),
620 refreshReflect($),
621 ])
622}
623
624/** The status line under the prompt: the figure's name and every limit at a glance. */
625function pushStatus($: EngineInterface, verdict: Verdict): void {
626 const body = statusText(verdict)
627 const line = S.isStatusOff || body === '' ? '' : `${tag(' · ')}${body}`
628 if (line === S.lastStatus) return
629 S.lastStatus = line
630 $.ui.status(line === '' ? undefined : line)
631}
632
633// A stopped (killed) subagent sends no SubagentStop, so the session's own list
634// is asked every couple of seconds while a mini figure is out.
635const AGENT_CHECK_MS = 2000
636
637async function pruneAgents($: EngineInterface, now: number): Promise<void> {
638 if (S.agents.size === 0 || now < S.nextAgentCheckAt) return
639 S.nextAgentCheckAt = now + AGENT_CHECK_MS
640 const listed = await $.agent.list()
641 for (const a of listed) if (a.status !== 'running' && S.agents.delete(a.id)) $.ui.invalidate('ui.render')
642}
643
644const resetKey = (l: Limit) => `${l.kind}:${l.resetsAt ?? ''}`
645
646/** A window whose reset time passed with no fresh reading: told once, by the clock. */
647async function announceResets($: EngineInterface, now: number): Promise<void> {
648 const due = dueResets(await read($, limits), now)
649 if (due.length === 0) return
650 const told = await read($, announcedResets)
651 const fresh = due.filter(l => !told.includes(resetKey(l)))
652 if (fresh.length === 0) return
653 await update($, announcedResets, list => [...list, ...fresh.map(resetKey)].slice(-20))
654 S.wagUntil = now + 6 * SEC
655 alert($, now, fresh.map(l => refillNote(l.kind)).join(' '), { tone: 'coat', pose: 'wag', ms: MIN })
656}
657
658async function onTick($: EngineInterface): Promise<void> {
659 const now = await $.clock.now()
660 await pruneAgents($, now).catch(() => undefined)
661 await announceResets($, now).catch(() => undefined)
662 const working = now - S.lastActivity < BREAK_GAP_MS
663 if (working && S.workStart > 0 && now - S.workStart >= REST_AFTER_MS && now >= S.restUntil && now - S.restShownAt >= 30 * MIN) {
664 S.restUntil = now + 5 * MIN
665 S.restShownAt = now
666 $.ui.toast(`${tag(': ')}${tr('már 90 perce dolgozol, tarts egy kis szünetet!', 'you have been at it for 90 minutes, take a short break!')}`)
667 }
668 if (working && !S.isScanning && now >= S.nextScanAt) {
669 S.isScanning = true
670 S.nextScanAt = now + SCAN_EVERY_MS
671 void scanAll($).finally(() => {
672 S.isScanning = false
673 })
674 }
675 if (working && Math.floor(now / 1000) % 30 === 0) void eveningChecks($, now).catch(() => undefined)
676 const { v, verdict } = await currentView($, now)
677 pushStatus($, verdict)
678 const minis = S.agents.size > 0 ? `${S.agents.size}:${Math.floor(now / MINI_STEP_MS)}` : ''
679 const key = `${S.skin}|${v.pose}|${v.message}|${v.tone}|${statusText(verdict)}|${S.isMuted}|${showsBubble(v)}|${minis}`
680 if (key !== S.lastKey) {
681 S.lastKey = key
682 $.ui.invalidate('ui.render')
683 }
684}
685
686/** Every limit at once, for /zsemle limit. */
687async function limitReport($: EngineInterface): Promise<string> {
688 const now = await $.clock.now()
689 const all = current(await read($, limits), now)
690 const verdict = judge(all, now)
691 const lines = [tr(`${voice().name} limit-jelentése:`, `${voice().name}'s limit report:`)]
692 if (all.length === 0) {
693 lines.push(
694 tr(
695 ' limitablak: nincs adat (nem előfizetéses használat, vagy még nem jött válasz)',
696 ' limit windows: no reading (not a subscription, or no response yet)',
697 ),
698 )
699 }
700 for (const l of all) {
701 const one = judge([l], now)
702 lines.push(` ${one.status.padEnd(14)} ${one.level === 'ok' ? tr('rendben', 'fine') : one.message}`)
703 const f = worstForecast([l], now)
704 if (f !== null) lines.push(` ${''.padEnd(14)} ${paceNote(f, now)}`)
705 }
706 const readAt = await read($, limitsReadAt)
707 if (all.length > 0 && readAt > 0) lines.push(` ${tr('limitadat:', 'limit data:').padEnd(15)}${ageText(now - readAt)}`)
708 const budget = await todayBudget($, now, all)
709 if (budget !== null) lines.push(` ${budgetLine(budget)}`)
710 const ctx = S.contextPct === null ? tr('nincs adat', 'no reading') : `${Math.round(S.contextPct)}%`
711 lines.push(` ${tr('kontextus:', 'context:').padEnd(15)}${ctx}`)
712 if (S.costUsd > 0) {
713 lines.push(
714 tr(` költség: ${S.costUsd.toFixed(2)} $ ebben a munkamenetben`, ` cost: $${S.costUsd.toFixed(2)} this session`),
715 )
716 }
717 if (verdict.level === 'stop' && (await read($, isWoken))) {
718 lines.push(tr(` (${STOP_AT}% felett is engedek: /zsemle ebreszt volt)`, ` (going on past ${STOP_AT}%: /zsemle wake was given)`))
719 }
720 return lines.join('\n')
721}
722
723/** Local midnight of `now`, from the local clock reading. */
724function dayStart(now: number): number {
725 const hm = localTime(now).hm
726 const [hour, minute] = hm.split(':').map(Number)
727 return now - ((hour ?? 0) * 3600 + (minute ?? 0) * 60) * 1000 - (now % 60000)
728}
729
730/** The weekly use at the day's first reading, kept per local day. */
731async function noteBudgetStart($: EngineInterface, now: number, all: readonly Limit[]): Promise<void> {
732 const week = all.find(l => l.kind === 'seven_day')
733 if (week === undefined) return
734 const key = `budget:${localTime(now).day}`
735 if ((await $.store.get(key)) === undefined) await $.store.set(key, week.percentUsed)
736}
737
738async function todayBudget($: EngineInterface, now: number, all: readonly Limit[]): Promise<Budget | null> {
739 if (!isOn('budgetPlanner')) return null
740 const week = all.find(l => l.kind === 'seven_day')
741 if (week === undefined) return null
742 const start = await $.store.get(`budget:${localTime(now).day}`)
743 return dailyBudget(week, typeof start === 'number' ? start : week.percentUsed, dayStart(now))
744}
745
746/** In the evening: the owl's good night, and a word when today went past its weekly share. */
747async function eveningChecks($: EngineInterface, now: number): Promise<void> {
748 const t = localTime(now)
749 const hour = Number(t.hm.slice(0, 2))
750 const skin = skinOf(S.skin)
751 if (skin.nightOwl === true && (hour >= 22 || hour < 4) && (await $.store.get('nightDay')) !== t.day) {
752 await $.store.set('nightDay', t.day)
753 const night = voice().night ?? tr('Késő van, ideje lezárni a napot.', 'It is late, time to close the day.')
754 alert($, now, night, { tone: 'coat', pose: 'sniff', ms: 5 * MIN })
755 }
756 if (hour >= 20 && (await $.store.get('budgetWarned')) !== t.day) {
757 const b = await todayBudget($, now, current(await read($, limits), now))
758 if (b !== null && b.usedToday > b.allowance) {
759 await $.store.set('budgetWarned', t.day)
760 alert($, now, budgetOverNote(b), { pose: 'tired', ms: 5 * MIN })
761 }
762 }
763}
764
765/** Tool calls per local hour, for the weekly chart. */
766async function bumpActivity($: EngineInterface, now: number): Promise<void> {
767 const t = localTime(now)
768 const key = `act:${t.day}`
769 const hours = ((await $.store.get(key)) as number[] | undefined) ?? Array.from({ length: 24 }, () => 0)
770 const hour = Number(t.hm.slice(0, 2))
771 hours[hour] = (hours[hour] ?? 0) + 1
772 await $.store.set(key, hours)
773}
774
775/** The first prompt of the local day carries a short brief for the model to pass on. */
776async function morningBrief($: EngineInterface, now: number): Promise<string> {
777 if (!isOn('morningBrief')) return ''
778 const t = localTime(now)
779 if (Number(t.hm.slice(0, 2)) < 4 || (await $.store.get('briefDay')) === t.day) return ''
780 await $.store.set('briefDay', t.day)
781 const yesterday = (await $.store.get(`summary:${localTime(now - DAY).day}`)) as string | undefined
782 let projects: ReturnType<typeof parseProjectTable> = []
783 const b = await todayBudget($, now, current(await read($, limits), now))
784 return briefNote({ yesterday: yesterday ?? null, projects, budget: b === null ? null : budgetLine(b), reflect: await refreshReflect($) })
785}
786
787/** A simple task on a big model at a high limit: suggest a smaller one, at most every 20 minutes. */
788async function adviseModel($: EngineInterface, now: number, text: string, verdict: Verdict): Promise<void> {
789 if (!isOn('modelAdvice') || now - S.adviceAt < 20 * MIN || !isSimpleTask(text)) return
790 if (verdict.worst === null || verdict.worst.percentUsed < 50) return
791 const model = await $.session.model().catch(() => S.lastModelSeen)
792 if (!isBigModel(model)) return
793 S.adviceAt = now
794 alert($, now, modelAdviceNote(shortModel(model)), { pose: 'sniff', ms: 2 * MIN })
795}
796
797/** Counts a failing command's error across turns and sessions; the count when it reaches LESSON_AFTER in a new turn. */
798async function sniffLesson($: EngineInterface, text: string): Promise<{ signature: string; count: number } | null> {
799 const signature = errorSignature(text)
800 if (signature === null) return null
801 const seen = ((await $.store.get('errsig')) as Record<string, { n: number; turn: string }> | undefined) ?? {}
802 // Prefixed keys: an error text such as __proto__ never reaches the object's prototype.
803 const key = `sig:${signature}`
804 const entry = Object.hasOwn(seen, key) ? (seen[key] as { n: number; turn: string }) : { n: 0, turn: '' }
805 if (entry.turn === S.turnId) return null
806 entry.n += 1
807 entry.turn = S.turnId
808 seen[key] = entry
809 // Keep the store small: the 200 most recent signatures.
810 const keys = Object.keys(seen)
811 if (keys.length > 200) for (const k of keys.slice(0, keys.length - 200)) delete seen[k]
812 await $.store.set('errsig', seen)
813 return entry.n === LESSON_AFTER ? { signature, count: entry.n } : null
814}
815
816/** The facts the companion answers from. */
817async function askFacts($: EngineInterface): Promise<string> {
818 await bumpStats($, s => s)
819 const model = await $.session.model().catch(() => S.lastModelSeen)
820 return [await limitReport($), statsText(S.statsDay, S.stats, voice().name), `model: ${model}`, `figure: ${labelOf(S.skin)}`].join('\n\n')
821}
822
823/** The day summary of this session, written by a small model from the transcript. */
824async function summarize($: EngineInterface): Promise<string> {
825 const messages = await $.session.messages()
826 const text = messages
827 .slice(-60)
828 .map(m => `${m.role}: ${m.text.slice(0, 400)}`)
829 .join('\n')
830 const r = await $.model.complete({
831 model: 'haiku',
832 system: tr(
833 'Foglald össze magyarul 3-5 pontban a munkamenetet: mi készült el, mi maradt nyitva, mi a következő lépés. Tömören, gondolatjel nélkül.',
834 'Sum up the session in English in 3-5 points: what got done, what is still open, what comes next. Briefly, no em or en dashes.',
835 ),
836 prompt: text === '' ? tr('(üres munkamenet)', '(empty session)') : text,
837 maxTokens: 500,
838 timeoutMs: 60000,
839 })
840 if (!r.isAnswered) return tr(`Nem sikerült összefoglalni (${r.reason}).`, `Could not summarize (${r.reason}).`)
841 await $.store.set(`summary:${localTime(await $.clock.now()).day}`, r.text.trim())
842 return r.text.trim()
843}
844
845/** The commands of the optional features; null when the argument is none of them. */
846async function featureCommands($: EngineInterface, arg: string, raw: string): Promise<string | null> {
847 if (['kapcsolok', 'kapcsolók', 'features', 'funkciok', 'funkciók'].includes(arg)) {
848 const list = FEATURES.map(f => ` ${S.features[f] ? tr('be', 'on ') : tr('ki', 'off')} ${tr(featureWord(f), f).padEnd(14)} ${featureLabel(f)}`).join('\n')
849 return `${tr('Funkciók (/zsemle kapcsolo <név> ki|be):', 'Features (/zsemle feature <name> off|on):')}\n${list}`
850 }
851 const sw = /^(?:kapcsolo|kapcsoló|feature|funkcio|funkció)\s+(\S+)\s+(ki|be|off|on)$/.exec(arg)
852 if (sw !== null) {
853 const f = findFeature(sw[1] ?? '')
854 if (f === null) return tr(`Nincs ilyen funkció: ${sw[1]}. Lista: /zsemle kapcsolok`, `No such feature: ${sw[1]}. List: /zsemle features`)
855 const value = sw[2] === 'be' || sw[2] === 'on'
856 S.features[f] = value
857 await $.store.set(`feature:${f}`, value)
858 $.ui.invalidate('ui.render')
859 return `${featureLabel(f)}: ${value ? tr('bekapcsolva', 'on') : tr('kikapcsolva', 'off')}.`
860 }
861 if (['heti', 'het', 'hét', 'week', 'weekly'].includes(arg)) {
862 await $.ui.open({ id: WEEK_PANE, title: tr('Zsemle: heti aktivitás', 'Zsemle: weekly activity') })
863 return tr('A heti grafikon a panelen.', 'The weekly chart is in the pane.')
864 }
865 if (['napzaro', 'napzáró', 'summary'].includes(arg)) return summarize($)
866 const ask = /^(?:kerdes|kérdés|ask)\s+([\s\S]+)$/i.exec(raw.trim())
867 if (ask !== null) {
868 const r = await $.model.complete({ model: 'haiku', system: askSystem(await askFacts($)), prompt: ask[1] ?? '', maxTokens: 400, timeoutMs: 30000 })
869 return r.isAnswered ? `${voice().name}: ${r.text.trim()}` : tr(`${voice().name} most nem tud válaszolni (${r.reason}).`, `${voice().name} cannot answer now (${r.reason}).`)
870 }
871 return null
872}
873
874const MINI_STEP_MS = 300
875const MINI_MAX = 6
876// A subagent that never reported its stop is dropped after this long.
877const AGENT_STALE_MS = 3 * 60 * MIN
878
879type Mini = { id: string; frame: Frame; left: number; lift: number }
880
881// How far a mini runs and hops. Its slot always takes both in full (the margin it does not use is put on the other
882// side), so the row never changes width: a moving row slid the bubble's buttons from under the pointer on the desktop.
883const MINI_RUN = 3
884const MINI_HOP = 1
885const miniMargins = (m: Mini) => ({ marginLeft: m.left, marginRight: MINI_RUN - m.left, marginBottom: m.lift, marginTop: MINI_HOP - m.lift })
886
887/** The mini figures to draw now: each runs, hops or sits, by its order, in its own rhythm. */
888function minis(now: number): { shown: Mini[]; more: number } {
889 for (const [id, started] of S.agents) if (now - started > AGENT_STALE_MS) S.agents.delete(id)
890 const ids = [...S.agents.keys()]
891 const step = Math.floor(now / MINI_STEP_MS)
892 const shown = ids.slice(0, MINI_MAX).map((id, i): Mini => {
893 const phase = step + i * 3
894 const kind = i % 3
895 if (kind === 0) {
896 // Runs back and forth over three cells.
897 const pos = phase % 6
898 return { id, frame: phase % 2 === 0 ? 'awake' : 'wag', left: pos < 3 ? pos : 6 - pos, lift: 0 }
899 }
900 if (kind === 1) {
901 // Hops in place.
902 return { id, frame: phase % 2 === 0 ? 'wag' : 'awake', left: 1, lift: phase % 2 }
903 }
904 // Sits and blinks now and then.
905 return { id, frame: phase % 9 === 0 ? 'blink' : 'awake', left: 1, lift: 0 }
906 })
907 return { shown, more: Math.max(0, ids.length - MINI_MAX) }
908}
909
910export const register: Register = (on, options) => {
911 setLang(asLang(options.language))
912 S.reflectQueue = typeof options.reflectQueue === 'string' ? options.reflectQueue.trim() : ''
913 S.guardDashes = options.guardDashes === true
914 S.projectTable = typeof options.projectTable === 'string' ? options.projectTable.trim() : ''
915 for (const f of FEATURES) S.features[f] = options[f] !== false
916
917 on('session.start', async ($, e, next) => {
918 await $.command.register({
919 name: 'zsemle',
920 description: tr(
921 'Zsemle, a társfigura: limit, skin, stat, nemit, hang, simi, elrejt, mutat, ebreszt, or ki|be, ugass',
922 'Zsemle, the companion: limit, skin, stats, mute, sound, pet, hide, show, wake, guard off|on, bark',
923 ),
924 })
925 const now = await $.clock.now()
926 activity(now)
927 S.isMuted = (await $.store.get('muted')) === true
928 S.isStatusOff = (await $.store.get('statusOff')) === true
929 S.skin = await loadSkin($, options)
930 for (const f of FEATURES) {
931 const stored = await $.store.get(`feature:${f}`)
932 if (typeof stored === 'boolean') S.features[f] = stored
933 }
934 if (lang() === 'en') void measureOffset($)
935 try {
936 const usage = await $.session.usage()
937 await update($, limits, () => toLimits(usage.rateLimits))
938 if (usage.rateLimits.length > 0 && (await read($, limitsReadAt)) === 0) await update($, limitsReadAt, () => now)
939 S.contextPct = usage.context.percent ?? null
940 S.costUsd = usage.cost?.usd ?? 0
941 } catch {
942 // No reading yet; session.measure fills it after the first response.
943 }
944 void refreshReflect($)
945 S.tick?.cancel()
946 S.tick = $.clock.every(250, () => void onTick($).catch(() => undefined))
947
948 return next(e)
949 })
950
951 on('session.measure', async ($, e, next) => {
952 const now = await $.clock.now()
953 if (e.changed.includes('context') && e.context.percent !== undefined) {
954 S.contextPct = e.context.percent
955 if (S.contextPct >= CONTEXT_COMPACT_AT && !S.isSaveNeeded) {
956 S.isSaveNeeded = true
957 S.isSaveNoteSent = false
958 S.isSaved = false
959 } else if (S.contextPct < SAVE_RESET_BELOW) {
960 // After a /compact: the next climb asks again.
961 S.isSaveNeeded = false
962 S.isSaveNoteSent = false
963 S.isSaved = false
964 S.isContextCriticalSent = false
965 S.isContextSaverSent = false
966 }
967 if (S.contextPct >= CONTEXT_CRITICAL_AT && !S.isContextCriticalSent) {
968 S.isContextCriticalSent = true
969 alert($, now, contextCriticalNote(S.contextPct), { pose: 'yawn', ms: MIN })
970 }
971 }
972 if (e.changed.includes('rateLimits')) {
973 const before = await read($, limits)
974 const wasStopped = judge(before, now).level === 'stop'
975 const fresh = toLimits(e.rateLimits)
976 await update($, limits, () => fresh)
977 await update($, limitsReadAt, () => now)
978 await noteBudgetStart($, now, fresh)
979 const verdict = judge(fresh, now)
980
981 // A reset the clock already told is not celebrated again.
982 const told = await read($, announcedResets)
983 const back = resets(before, fresh).filter(l => !told.includes(resetKey(before.find(b => b.kind === l.kind) ?? l)))
984 if (back.length > 0) {
985 S.wagUntil = now + 6 * SEC
986 alert($, now, back.map(resetNote).join(' '), { tone: 'coat', pose: 'wag', ms: MIN })
987 }
988 for (const c of crossings(before, fresh)) {
989 $.ui.toast(`${tag(': ')}${crossingNote(c, now)}`, { timeoutMs: 8000 })
990 }
991 const pace = worstForecast(fresh, now)
992 const paceKey = pace === null ? '' : `${pace.limit.kind}:${pace.limit.resetsAt ?? ''}`
993 if (pace !== null && !S.pacedWindows.has(paceKey)) {
994 S.pacedWindows.add(paceKey)
995 $.ui.toast(`${tag(': ')}${paceNote(pace, now)}`, { timeoutMs: 10000 })
996 }
997 if (verdict.level !== 'stop' && wasStopped) {
998 // The window reset: wake up on our own and drop any override.
999 await update($, isWoken, () => false)
1000 }
1001 }
1002 if (e.changed.includes('cost') && e.cost !== undefined) {
1003 const step = costCrossed(S.costUsd, e.cost.usd)
1004 S.costUsd = e.cost.usd
1005 // On a subscription the windows are the limit; the dollars are only notional.
1006 if (step !== null && (await read($, limits)).length === 0) {
1007 alert($, now, costNote(step, e.cost.usd), { tone: 'yellow', pose: 'sniff', ms: MIN })
1008 }
1009 }
1010
1011 return next(e)
1012 })
1013
1014 // A tool call the user interrupted: two within ten minutes reads as frustration (Marveen-atvetel 40).
1015 on('classic.PostToolUseFailure', async ($, e, next) => {
1016 if (isOn('fatigueLook') && (e as { is_interrupt?: boolean }).is_interrupt === true) {
1017 const now = await $.clock.now()
1018 S.interrupts = [...recentInterrupts(S.interrupts, now), now]
1019 if (S.interrupts.length === INTERRUPTS_TO_NOTE) alert($, now, frustrationNote(S.interrupts.length), { pose: 'droop', ms: MIN, toast: false })
1020 }
1021 return next(e)
1022 })
1023
1024 // An API error ended the turn: the rate limit, an overload, the output cap...
1025 on('classic.StopFailure', async ($, e, next) => {
1026 const now = await $.clock.now()
1027 const note = apiErrorNote(e.error)
1028 alert($, now, note.message, { tone: note.isSevere ? 'red' : 'yellow', pose: 'droop', ms: 5 * MIN })
1029
1030 return next(e)
1031 })
1032
1033 // A subagent started or finished: one more or one fewer mini figure.
1034 on('classic.SubagentStart', async ($, e, next) => {
1035 S.agents.set(e.agent_id, await $.clock.now())
1036 $.ui.invalidate('ui.render')
1037 return next(e)
1038 })
1039
1040 on('classic.SubagentStop', async ($, e, next) => {
1041 S.agents.delete(e.agent_id)
1042 $.ui.invalidate('ui.render')
1043 return next(e)
1044 })
1045
1046 // The engine changed the model by itself (fallback after an overload or a limit).
1047 on('classic.PostModelSwitch', async ($, e, next) => {
1048 if (e.source === 'auto' && e.from_model !== e.to_model) {
1049 alert($, await $.clock.now(), modelSwitchNote(e.from_model, e.to_model), { pose: 'sniff', ms: 2 * MIN })
1050 }
1051
1052 return next(e)
1053 })
1054
1055 on('session.compact', async ($, e, next) => {
1056 const done = await next(e)
1057 if (e.trigger === 'auto' && e.agentId === undefined && !('skip' in done)) {
1058 alert($, await $.clock.now(), autoCompactNote(), { pose: 'yawn', ms: 2 * MIN })
1059 }
1060 return done
1061 })
1062
1063 on('tool.call', async ($, e, next) => {
1064 const now = await $.clock.now()
1065 const verdict = judge(await read($, limits), now)
1066 if (verdict.level === 'stop' && !(await read($, isWoken))) {
1067 return {
1068 deny: tr(
1069 `Zsemle leállította a munkát: ${verdict.message} ` +
1070 'Ne próbáld újra és ne hívj több eszközt; zárd le a kört egy rövid összefoglalóval arról, hol tartasz.',
1071 `Zsemle stopped the work: ${verdict.message} ` +
1072 'Do not retry and call no more tools; close the turn with a short summary of where you are.',
1073 ),
1074 }
1075 }
1076 activity(now)
1077 await bumpStats($, s => ({ ...s, tools: s.tools + 1 }))
1078 await bumpActivity($, now)
1079
1080 // Shell commands come through the Bash tool or, where Claude Code runs PowerShell, the PowerShell tool.
1081 const isShell = e.tool === 'Bash' || e.tool === 'PowerShell'
1082 const bashCmd = isShell ? String((e as unknown as { command?: unknown }).command ?? '').trim() : ''
1083 if (isOn('commitGuard') && bashCmd !== '' && isCommitCommand(bashCmd) && !isThrowawayCommit(bashCmd) && S.lastEditAt > S.lastTestAt) {
1084 const isRepeat = S.commitWarnCmd === bashCmd && now - S.commitWarnAt < 2 * MIN
1085 if (!isRepeat) {
1086 S.commitWarnCmd = bashCmd
1087 S.commitWarnAt = now
1088 alert($, now, tr('Commit előtt: a módosítás óta nem futott teszt.', 'Before the commit: no test ran since the last edit.'), { pose: 'sniff', ms: MIN, toast: false })
1089 return { deny: commitGuardNote() }
1090 }
1091 }
1092
1093 const parts = writeParts(e.tool, e as unknown as Record<string, unknown>)
1094 if (parts !== null && !(await read($, isGuardOff))) {
1095 const reason = guardReason(parts.path, parts.added, parts.removed, { dashes: S.guardDashes })
1096 if (reason !== null) {
1097 S.growlUntil = now + 8 * SEC
1098 S.growlText = reason
1099 $.ui.invalidate('ui.render')
1100 await bumpStats($, s => ({ ...s, growls: s.growls + 1 }))
1101 return {
1102 deny: tr(
1103 `Zsemle morog és megállította az írást (${parts.path}): ${reason} Javítsd és írd újra. (Kikapcsolás: /zsemle or ki)`,
1104 `Zsemle growls and stopped the write (${parts.path}): ${reason} Fix it and write again. (Turn off: /zsemle guard off)`,
1105 ),
1106 }
1107 }
1108 }
1109
1110 const ran = await next(e)
1111 if (parts !== null && ran.deny === undefined && ran.isError !== true && S.isSaveNeeded && isProjectCard(parts.path)) {
1112 S.isSaved = true
1113 $.ui.invalidate('ui.render')
1114 }
1115 if (parts !== null && ran.deny === undefined && ran.isError !== true) {
1116 S.lastEditAt = await $.clock.now()
1117 const edits = (S.turnEdits.get(parts.path) ?? 0) + 1
1118 S.turnEdits.set(parts.path, edits)
1119 if (isOn('loopWatch') && edits === LOOP_EDITS) {
1120 alert($, S.lastEditAt, loopBubble(parts.path, edits), { pose: 'droop', ms: MIN, toast: false })
1121 return { ...ran, context: [...(ran.context ?? []), loopNote(parts.path, edits)] }
1122 }
1123 }
1124 if (!isShell || ran.deny !== undefined) return ran
1125
1126 const cmd = String((e as unknown as { command?: unknown }).command ?? '').trim()
1127 const after = await $.clock.now()
1128 if (ran.isError === true) {
1129 S.droopUntil = after + 20 * SEC
1130 S.failCount = cmd === S.lastFail ? S.failCount + 1 : 1
1131 S.lastFail = cmd
1132 const notes: string[] = []
1133 const lesson = isOn('lessonSniff') ? await sniffLesson($, String(ran.text ?? '')) : null
1134 if (lesson !== null) {
1135 notes.push(lessonNote(lesson.signature, lesson.count))
1136 alert($, after, lessonBubble(lesson.count), { pose: 'sniff', ms: 2 * MIN, toast: false })
1137 }
1138 if (S.failCount >= FAIL_STREAK) {
1139 S.chaseUntil = after + MIN
1140 $.ui.invalidate('ui.render')
1141 notes.push(chaseNote())
1142 }
1143 return notes.length > 0 ? { ...ran, context: [...(ran.context ?? []), ...notes] } : ran
1144 }
1145
1146 if (cmd === S.lastFail) S.failCount = 0
1147 if (isTestCommand(cmd)) {
1148 S.lastTestAt = after
1149 S.wagUntil = after + 4 * SEC
1150 await bumpStats($, s => ({ ...s, tests: s.tests + 1 }))
1151 }
1152 if (isDeployCommand(cmd)) {
1153 S.sniffUntil = after + 2 * MIN
1154 await bumpStats($, s => ({ ...s, deploys: s.deploys + 1 }))
1155 $.ui.invalidate('ui.render')
1156 return { ...ran, context: [...(ran.context ?? []), deployNote()] }
1157 }
1158 if (isDevServerCommand(cmd)) {
1159 // A server started in the background binds its port a little later: look again then.
1160 $.clock.after(8 * SEC, () => void scanPorts($).catch(() => undefined))
1161 const clash = await scanPorts($).catch(() => null)
1162 if (clash !== null) {
1163 $.ui.invalidate('ui.render')
1164 return { ...ran, context: [...(ran.context ?? []), portModelNote(clash)] }
1165 }
1166 }
1167 return ran
1168 })
1169
1170 on('prompt.submit', async ($, e, next) => {
1171 const now = await $.clock.now()
1172 activity(now)
1173 const verdict = judge(await read($, limits), now)
1174 const isCommand = e.text.trimStart().startsWith('/')
1175 if (!isCommand && verdict.level === 'stop' && !(await read($, isWoken))) {
1176 return {
1177 drop: tr(
1178 `Zsemle alszik: ${verdict.message} Ha mégis folytatnád: /zsemle ebreszt`,
1179 `Zsemle is asleep: ${verdict.message} To go on anyway: /zsemle wake`,
1180 ),
1181 }
1182 }
1183
1184 const notes: string[] = []
1185 if (!isCommand) {
1186 const brief = await morningBrief($, now)
1187 if (brief !== '') notes.push(brief)
1188 await adviseModel($, now, e.text, verdict)
1189 if (isOn('contextSaver') && S.contextPct !== null && S.contextPct >= CONTEXT_SAVER_AT && !S.isContextSaverSent) {
1190 S.isContextSaverSent = true
1191 notes.push(contextSaverNote())
1192 }
1193 if (isOn('daySummary') && isClosingPrompt(e.text)) {
1194 S.isSummaryPending = true
1195 notes.push(daySummaryNote())
1196 }
1197 }
1198 if (S.isSaveNeeded && !S.isSaved && !S.isSaveNoteSent) {
1199 S.isSaveNoteSent = true
1200 notes.push(contextSavePromptNote())hooks/i18n.ts 50 lines1// The mod's language and the user's clock offset. Every text the mod shows or
2// tells the model comes in Hungarian and English through `tr`; the hooks
3// module sets the language from the `language` option at load. No `$` here.
4
5export type Lang = 'hu' | 'en'
6
7export const LANGS: readonly Lang[] = ['hu', 'en']
8
9const current = {
10 lang: 'hu' as Lang,
11 // The machine's UTC offset as measured at session start; null until then.
12 offsetMs: null as number | null,
13}
14
15export function setLang(lang: Lang): void {
16 current.lang = lang
17}
18
19export function lang(): Lang {
20 return current.lang
21}
22
23/** An option value as a language, Hungarian when it names none. */
24export function asLang(value: unknown): Lang {
25 return value === 'en' ? 'en' : 'hu'
26}
27
28/** The text in the current language. */
29export function tr(hu: string, en: string): string {
30 return current.lang === 'en' ? en : hu
31}
32
33export function setUtcOffset(ms: number | null): void {
34 current.offsetMs = ms
35}
36
37export function utcOffset(): number | null {
38 return current.offsetMs
39}
40
41/** A `date +%z` or PowerShell `Get-Date -Format zzz` answer ("+0200", "+02:00", "-05:00") in ms; null when unreadable. */
42export function parseOffset(text: string): number | null {
43 const m = /([+-])(\d{2}):?(\d{2})/.exec(text.trim())
44 if (m === null) return null
45 const sign = m[1] === '-' ? -1 : 1
46 const minutes = Number(m[2]) * 60 + Number(m[3])
47 if (minutes > 14 * 60) return null
48 return sign * minutes * 60 * 1000
49}
50hooks/logic.ts 1065 lines1// Pure helpers for the zsemle mod: limit verdicts, the texts (Hungarian and
2// English, through `tr`), the content guard, command recognition, local time
3// and the sprite drawing. No `$` here, so the tests can exercise all of it directly.
4
5import type { Limit } from '../types'
6import { lang, tr, utcOffset } from './i18n'
7import { DEFAULT_SKIN, POSE_NAMES, skinOf } from './skins'
8import type { Pose, SkinId } from './skins'
9
10export type { Pose, SkinId } from './skins'
11
12export const WARN_AT = 50
13export const HIGH_AT = 75
14export const CRITICAL_AT = 90
15export const STOP_AT = 95
16/** The limit thresholds Zsemle speaks up at, lowest first. */
17export const THRESHOLDS = [WARN_AT, HIGH_AT, CRITICAL_AT, STOP_AT] as const
18export const BARK_AFTER_MS = 3 * 60 * 1000
19export const IDLE_AFTER_MS = 20 * 60 * 1000
20export const REST_AFTER_MS = 90 * 60 * 1000
21export const BREAK_GAP_MS = 15 * 60 * 1000
22export const CONTEXT_TIRED_AT = 60
23export const CONTEXT_COMPACT_AT = 80
24export const CONTEXT_CRITICAL_AT = 90
25export const FAIL_STREAK = 3
26
27const NAMES: Record<string, [string, string]> = {
28 five_hour: ['5 órás', '5-hour'],
29 seven_day: ['heti', 'weekly'],
30 spend_limit: ['költési', 'spend'],
31}
32
33const SHORT: Record<string, [string, string]> = {
34 five_hour: ['5 órás', '5h'],
35 seven_day: ['heti', 'week'],
36 spend_limit: ['költés', 'spend'],
37}
38
39const pick = (pair: [string, string] | undefined, fallback: string) => (pair === undefined ? fallback : tr(pair[0], pair[1]))
40
41export function limitName(kind: string): string {
42 return pick(NAMES[kind], kind)
43}
44
45// Hungarian definite article: "az" before a vowel sound. Digits count by how
46// they are read: 1 (egy) and 5 (öt) start with a vowel.
47export function article(word: string): 'a' | 'az' {
48 return /^[aáeéiíoóöőuúüű15]/i.test(word) ? 'az' : 'a'
49}
50
51/** "az 5 órás" in Hungarian, "the 5-hour" in English. */
52const theName = (kind: string) => {
53 const name = limitName(kind)
54 return lang() === 'en' ? `the ${name}` : `${article(name)} ${name}`
55}
56
57const capital = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
58
59// ---- Local time -------------------------------------------------------------
60// The module has no reliable time zone. In Hungarian, Central European time is
61// computed: UTC+1, and UTC+2 from the last Sunday of March 01:00 UTC to the
62// last Sunday of October 01:00 UTC. In English, the machine's offset measured
63// at session start (i18n's utcOffset), UTC until it is known.
64
65function lastSundayUtc(year: number, month: number): number {
66 const last = new Date(Date.UTC(year, month + 1, 0, 1, 0, 0))
67 return last.getTime() - last.getUTCDay() * 86400000
68}
69
70export function budapestOffsetMs(ms: number): number {
71 const year = new Date(ms).getUTCFullYear()
72 const isSummer = ms >= lastSundayUtc(year, 2) && ms < lastSundayUtc(year, 9)
73 return (isSummer ? 2 : 1) * 3600000
74}
75
76const pad = (n: number) => String(n).padStart(2, '0')
77
78export type LocalTime = { day: string; hm: string; weekday: number }
79
80function at(ms: number, offsetMs: number): LocalTime {
81 const d = new Date(ms + offsetMs)
82 return {
83 day: `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`,
84 hm: `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`,
85 weekday: d.getUTCDay(),
86 }
87}
88
89export function budapest(ms: number): LocalTime {
90 return at(ms, budapestOffsetMs(ms))
91}
92
93/** The user's local time: Budapest in Hungarian, the machine's offset in English. */
94export function localTime(ms: number): LocalTime {
95 return lang() === 'en' ? at(ms, utcOffset() ?? 0) : budapest(ms)
96}
97
98const ON_DAY = ['vasárnap', 'hétfőn', 'kedden', 'szerdán', 'csütörtökön', 'pénteken', 'szombaton']
99const ON_DAY_EN = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
100
101/** A moment as a phrase: "14:20-kor", "holnap 9:05-kor", "hétfőn 14:20-kor"; "at 14:20", "tomorrow at 9:05", "on Monday at 14:20". */
102export function atPhrase(ms: number, nowMs: number): string {
103 const then = localTime(ms)
104 const today = localTime(nowMs)
105 const tomorrow = localTime(nowMs + 86400000)
106 if (lang() === 'en') {
107 if (then.day === today.day) return `at ${then.hm}`
108 if (then.day === tomorrow.day) return `tomorrow at ${then.hm}`
109 return `on ${ON_DAY_EN[then.weekday] ?? ''} at ${then.hm}`
110 }
111 if (then.day === today.day) return `${then.hm}-kor`
112 if (then.day === tomorrow.day) return `holnap ${then.hm}-kor`
113 return `${ON_DAY[then.weekday] ?? ''} ${then.hm}-kor`
114}
115
116/** When a window resets (atPhrase), or null without a valid time. */
117export function resetPhrase(resetsAt: string | undefined, nowMs: number): string | null {
118 if (resetsAt === undefined) return null
119 const ms = Date.parse(resetsAt)
120 if (Number.isNaN(ms)) return null
121 return atPhrase(ms, nowMs)
122}
123
124// ---- Limits -----------------------------------------------------------------
125
126export type Level = 'none' | 'ok' | 'warn' | 'high' | 'critical' | 'stop'
127
128export type Verdict = {
129 level: Level
130 worst: Limit | null
131 message: string
132 status: string
133}
134
135/**
136 * The readings as they stand now. A reading only changes with the next API
137 * response, so a session that sent nothing since a window reset still holds the
138 * old percentage; a window whose reset time has passed has started over.
139 */
140export function current(limits: readonly Limit[], nowMs: number): Limit[] {
141 return limits.map(l => {
142 const ms = l.resetsAt === undefined ? Number.NaN : Date.parse(l.resetsAt)
143 return !Number.isNaN(ms) && ms <= nowMs ? { kind: l.kind, percentUsed: 0, isRestarted: true } : l
144 })
145}
146
147/** The windows whose reset time has passed by now. */
148export function dueResets(limits: readonly Limit[], nowMs: number): Limit[] {
149 return limits.filter(l => {
150 const ms = l.resetsAt === undefined ? Number.NaN : Date.parse(l.resetsAt)
151 return !Number.isNaN(ms) && ms <= nowMs
152 })
153}
154
155/** A reading older than this gets its age in the status line. */
156export const STALE_AFTER_MS = 15 * 60 * 1000
157
158/** How old a reading is: "25 perce", "3 órája", "2 napja"; "25 min ago", "3 h ago", "2 d ago". */
159export function ageText(ms: number): string {
160 const min = Math.max(0, Math.floor(ms / 60000))
161 if (min < 60) return tr(`${min} perce`, `${min} min ago`)
162 const h = Math.floor(min / 60)
163 if (h < 48) return tr(`${h} órája`, `${h} h ago`)
164 const d = Math.floor(h / 24)
165 return tr(`${d} napja`, `${d} d ago`)
166}
167
168/** Told when a window's reset time passes before any fresh reading arrives. */
169export function refillNote(kind: string): string {
170 const name = theName(kind)
171 return tr(
172 `Visszaállt ${name} limit, tele a tank! A pontos százalék a következő válasszal jön.`,
173 `${capital(name)} limit has reset, full tank! The exact percentage comes with the next response.`,
174 )
175}
176
177export function statusLine(limits: readonly Limit[]): string {
178 return limits
179 .map(l => `${pick(SHORT[l.kind], l.kind)} ${Math.round(l.percentUsed)}%${l.isRestarted === true ? tr(' (újraindult)', ' (reset)') : ''}`)
180 .join(' · ')
181}
182
183/** readAtMs: when the readings arrived; an old one adds its age to the status. */
184export function judge(readings: readonly Limit[], nowMs = Date.now(), readAtMs?: number): Verdict {
185 const limits = current(readings, nowMs)
186 if (limits.length === 0) {
187 return { level: 'none', worst: null, message: tr('Még nincs limitadat.', 'No limit reading yet.'), status: '' }
188 }
189 const worst = limits.reduce((a, b) => (b.percentUsed > a.percentUsed ? b : a))
190 const name = theName(worst.kind)
191 const pct = Math.round(worst.percentUsed)
192 const age = readAtMs === undefined || readAtMs <= 0 || nowMs - readAtMs < STALE_AFTER_MS ? '' : ` · ${tr('adat', 'data')} ${ageText(nowMs - readAtMs)}`
193 const status = statusLine(limits) + age
194 const reset = resetPhrase(worst.resetsAt, nowMs)
195 const resetText = reset === null ? '' : tr(` Visszaáll ${reset}.`, ` It resets ${reset}.`)
196
197 if (worst.percentUsed > 100) {
198 return {
199 level: 'stop',
200 worst,
201 message: tr(
202 `Túllépted ${name} limitet (${pct}%). Leállítottam, alszom.${resetText}`,
203 `You went past ${name} limit (${pct}%). I stopped the work and went to sleep.${resetText}`,
204 ),
205 status,
206 }
207 }
208 if (worst.percentUsed >= STOP_AT) {
209 return {
210 level: 'stop',
211 worst,
212 message: tr(
213 `Elérted ${name} limit ${STOP_AT}%-át (${pct}%). Leállítottam, alszom.${resetText}`,
214 `You reached ${STOP_AT}% of ${name} limit (${pct}%). I stopped the work and went to sleep.${resetText}`,
215 ),
216 status,
217 }
218 }
219 if (worst.percentUsed >= CRITICAL_AT) {
220 return {
221 level: 'critical',
222 worst,
223 message: tr(
224 `Majdnem elfogyott ${name} limit: ${pct}%. ${STOP_AT}%-nál leállítom a munkát, zárd le, ami félkész.${resetText}`,
225 `${capital(name)} limit is almost gone: ${pct}%. At ${STOP_AT}% I stop the work, so wrap up what is half done.${resetText}`,
226 ),
227 status,
228 }
229 }
230 if (worst.percentUsed >= HIGH_AT) {
231 return {
232 level: 'high',
233 worst,
234 message: tr(
235 `Már ${name} limit ${HIGH_AT}%-ánál tartunk (${pct}%). A fontosra koncentrálj.${resetText}`,
236 `We are past ${HIGH_AT}% of ${name} limit (${pct}%). Focus on what matters.${resetText}`,
237 ),
238 status,
239 }
240 }
241 if (worst.percentUsed >= WARN_AT) {
242 return {
243 level: 'warn',
244 worst,
245 message: tr(
246 `Figyelem: elérted ${name} limit ${WARN_AT}%-át (${pct}%).${resetText}`,
247 `Heads up: you reached ${WARN_AT}% of ${name} limit (${pct}%).${resetText}`,
248 ),
249 status,
250 }
251 }
252 return { level: 'ok', worst, message: tr('Minden rendben, figyelek.', 'All is well, I am watching.'), status }
253}
254
255/** A window that rose past one of the thresholds between two readings, the highest it passed. */
256export type Crossing = { limit: Limit; threshold: number }
257
258export function crossings(before: readonly Limit[], after: readonly Limit[]): Crossing[] {
259 const out: Crossing[] = []
260 for (const l of after) {
261 const prev = before.find(b => b.kind === l.kind)?.percentUsed ?? 0
262 const passed = THRESHOLDS.filter(t => prev < t && l.percentUsed >= t)
263 const top = passed[passed.length - 1]
264 if (top !== undefined) out.push({ limit: l, threshold: top })
265 }
266 return out
267}
268
269export function crossingNote(c: Crossing, nowMs: number): string {
270 const name = theName(c.limit.kind)
271 const reset = resetPhrase(c.limit.resetsAt, nowMs)
272 const pct = Math.round(c.limit.percentUsed)
273 return tr(
274 `${capital(name)} limit ${c.threshold}%-a elfogyott (${pct}%${reset === null ? '' : `, visszaáll ${reset}`}).`,
275 `${capital(name)} limit passed ${c.threshold}% (${pct}%${reset === null ? '' : `, resets ${reset}`}).`,
276 )
277}
278
279/** Windows reset since the last reading: the use dropped by 10 points or more, or a later reset time came with less use. */
280export function resets(before: readonly Limit[], after: readonly Limit[]): Limit[] {
281 return after.filter(l => {
282 const prev = before.find(b => b.kind === l.kind)
283 if (prev === undefined) return false
284 const dropped = prev.percentUsed - l.percentUsed >= 10
285 const later =
286 prev.resetsAt !== undefined &&
287 l.resetsAt !== undefined &&
288 Date.parse(l.resetsAt) - Date.parse(prev.resetsAt) > 30 * 60 * 1000
289 return dropped || (later && l.percentUsed < prev.percentUsed)
290 })
291}
292
293export function resetNote(l: Limit): string {
294 const name = theName(l.kind)
295 const pct = Math.round(l.percentUsed)
296 return tr(`Visszaállt ${name} limit (most ${pct}%). Tele a tank!`, `${capital(name)} limit has reset (now ${pct}%). Full tank!`)
297}
298
299// ---- Pace: will a window run out before it resets? ---------------------------
300
301const WINDOW_MS: Record<string, number> = {
302 five_hour: 5 * 3600 * 1000,
303 seven_day: 7 * 86400 * 1000,
304}
305
306export const PACE_FROM = 25
307const PACE_MARGIN_MS = 15 * 60 * 1000
308
309export type Forecast = { limit: Limit; exhaustAt: number; resetAt: number }
310
311/** When the window runs out at the pace so far, if that is before its reset; null when it lasts. */
312export function forecast(l: Limit, nowMs: number): Forecast | null {
313 const span = WINDOW_MS[l.kind]
314 if (span === undefined || l.resetsAt === undefined) return null
315 const resetAt = Date.parse(l.resetsAt)
316 if (Number.isNaN(resetAt) || resetAt <= nowMs) return null
317 const elapsed = span - (resetAt - nowMs)
318 if (elapsed < span * 0.1 || l.percentUsed < PACE_FROM || l.percentUsed >= STOP_AT) return null
319 const exhaustAt = nowMs + ((100 - l.percentUsed) * elapsed) / l.percentUsed
320 return exhaustAt < resetAt - PACE_MARGIN_MS ? { limit: l, exhaustAt, resetAt } : null
321}
322
323/** The window that runs out soonest at the current pace, if one does before its reset. */
324export function worstForecast(limits: readonly Limit[], nowMs: number): Forecast | null {
325 let best: Forecast | null = null
326 for (const l of limits) {
327 const f = forecast(l, nowMs)
328 if (f !== null && (best === null || f.exhaustAt < best.exhaustAt)) best = f
329 }
330 return best
331}
332
333export function paceNote(f: Forecast, nowMs: number): string {
334 const name = theName(f.limit.kind)
335 const pct = Math.round(f.limit.percentUsed)
336 return tr(
337 `Ezzel a tempóval ${name} limit kb. ${atPhrase(f.exhaustAt, nowMs)} elfogy ` +
338 `(most ${pct}%), pedig csak ${atPhrase(f.resetAt, nowMs)} áll vissza. ` +
339 'Lassíts, vagy válts kisebb modellre.',
340 `At this pace ${name} limit runs out ${atPhrase(f.exhaustAt, nowMs)} or so ` +
341 `(now ${pct}%), but it only resets ${atPhrase(f.resetAt, nowMs)}. ` +
342 'Slow down, or switch to a smaller model.',
343 )
344}
345
346// ---- API errors, model fallback, compaction, cost -----------------------------
347
348export type Alert = { message: string; isSevere: boolean }
349
350/** What an API error that ended a turn means (StopFailure's `error`), in plain words. */
351export function apiErrorNote(error: string): Alert {
352 switch (error) {
353 case 'rate_limit':
354 return {
355 message: tr(
356 'Az API elutasította a kérést: elfogyott a használati keret (rate limit). Várni kell a visszaállásig.',
357 'The API turned the request down: the usage allowance ran out (rate limit). We have to wait for the reset.',
358 ),
359 isSevere: true,
360 }
361 case 'overloaded':
362 return {
363 message: tr(
364 'Az Anthropic szerverei most túlterheltek. Pár perc múlva próbáld újra.',
365 "Anthropic's servers are overloaded right now. Try again in a few minutes.",
366 ),
367 isSevere: false,
368 }
369 case 'billing_error':
370 return {
371 message: tr('Számlázási hiba: nézd meg az előfizetést vagy a kreditegyenleget.', 'Billing error: check the subscription or the credit balance.'),
372 isSevere: true,
373 }
374 case 'max_output_tokens':
375 return {
376 message: tr(
377 'A válasz elérte a kimeneti token-limitet, a vége hiányozhat. Kérd, hogy folytassa.',
378 'The answer hit the output token limit, its end may be missing. Ask it to continue.',
379 ),
380 isSevere: false,
381 }
382 case 'authentication_failed':
383 return {
384 message: tr('Lejárt vagy hibás a bejelentkezés: futtasd a /login parancsot.', 'The login expired or failed: run /login.'),
385 isSevere: true,
386 }
387 case 'oauth_org_not_allowed':
388 case 'account_on_hold':
389 case 'verification_required':
390 return {
391 message: tr(`Gond van a fiókkal (${error}): nézd meg a fiókbeállításokat.`, `Something is wrong with the account (${error}): check the account settings.`),
392 isSevere: true,
393 }
394 case 'invalid_request':
395 return {
396 message: tr(
397 'Érvénytelen kérés, gyakran a túl hosszú kontextus miatt: a /compact segíthet.',
398 'Invalid request, often from a context that is too long: /compact may help.',
399 ),
400 isSevere: false,
401 }
402 case 'server_error':
403 return {
404 message: tr('Szerverhiba az API oldalán. Kicsit később próbáld újra.', 'Server error on the API side. Try again a little later.'),
405 isSevere: false,
406 }
407 case 'model_not_found':
408 return {
409 message: tr('A választott modell nem érhető el: válts a /model paranccsal.', 'The chosen model is not available: switch with /model.'),
410 isSevere: true,
411 }
412 case 'cloud_credential_error':
413 return {
414 message: tr(
415 'Felhős hitelesítési hiba (Bedrock / Vertex): nézd meg a hozzáférést.',
416 'Cloud credential error (Bedrock / Vertex): check the access.',
417 ),
418 isSevere: true,
419 }
420 default:
421 return { message: tr(`API-hiba (${error}): a kör elbukott.`, `API error (${error}): the turn failed.`), isSevere: false }
422 }
423}
424
425/** A model id as people say it: "claude-opus-5-5-20260101" is "opus-5-5". */
426export function shortModel(id: string): string {
427 return id
428 .replace(/^claude-/, '')
429 .replace(/\[1m\]$/, '')
430 .replace(/-\d{8}$/, '')
431}
432
433export function modelSwitchNote(from: string, to: string): string {
434 return tr(
435 `Automatikus modellváltás: ${shortModel(from)} helyett most ${shortModel(to)} dolgozik (tartalék vagy limit miatt).`,
436 `Automatic model switch: ${shortModel(to)} now works in place of ${shortModel(from)} (a fallback or a limit).`,
437 )
438}
439
440export function autoCompactNote(): string {
441 return tr(
442 'Megtelt a kontextus, automatikusan tömörítettem (auto-compact): a korábbi részletek már csak összefoglalóként vannak meg.',
443 'The context filled up and was compacted automatically: the earlier details are now only a summary.',
444 )
445}
446
447export function contextCriticalNote(percent: number): string {
448 const pct = Math.round(percent)
449 return tr(
450 `A kontextus ${pct}%-on áll: hamarosan automatikus tömörítés jön, a részletek elveszhetnek.`,
451 `The context is at ${pct}%: an automatic compaction is coming soon, and details may be lost.`,
452 )
453}
454
455/** Session cost milestones in US dollars, spoken on API-key use (no rate-limit windows). */
456export const COST_STEPS = [1, 5, 10, 20, 50, 100, 200] as const
457
458export function costCrossed(before: number, after: number): number | null {
459 const passed = COST_STEPS.filter(s => before < s && after >= s)
460 return passed[passed.length - 1] ?? null
461}
462
463export function costNote(step: number, usd: number): string {
464 return tr(
465 `A munkamenet költsége átlépte a ${step} dollárt (most ${usd.toFixed(2)} $).`,
466 `The session cost went past ${step} dollars (now $${usd.toFixed(2)}).`,
467 )
468}
469
470export function contextNote(percent: number | null): string | null {
471 if (percent === null || percent < CONTEXT_COMPACT_AT) return null
472 const pct = Math.round(percent)
473 return tr(`Fáradok: a kontextus ${pct}%-on áll. Ideje a /compact parancsnak.`, `I am getting tired: the context is at ${pct}%. Time for /compact.`)
474}
475
476export function formatDuration(ms: number): string {
477 const total = Math.max(0, Math.round(ms / 1000))
478 const min = Math.floor(total / 60)
479 const sec = total % 60
480 if (lang() === 'en') return min > 0 ? `${min}m ${sec}s` : `${sec}s`
481 return min > 0 ? `${min}p ${sec}mp` : `${sec}mp`
482}
483
484export function shouldBark(elapsedMs: number, reason: string): boolean {
485 return reason === 'answer' && elapsedMs >= BARK_AFTER_MS
486}
487
488// ---- Content guard ----------------------------------------------------------
489// The characters the guard looks for are built from code points: a tool's
490// input reaches the hooks with escapes already turned into characters, so a
491// `\u` escape of them in this source would trip the guard on its own file.
492
493const chars = (...codes: number[]) => String.fromCodePoint(...codes)
494
495const DASH = new RegExp(
496 [chars(0x2014), chars(0x2013), '&' + 'mdash;', '&' + 'ndash;', '&#' + '8212;', '&#' + '8211;', '&#x' + '2014;', '&#x' + '2013;'].join('|'),
497 'gi',
498)
499const CODE_FILE = /\.(m?[jt]sx?|cjs|cts|py|css|scss|html?|vue|svelte|astro|json|ya?ml|toml|sql|sh|ps1|go|rs|java|kt|swift|c|cpp|h)$/i
500const JS_FILE = /\.(m?[jt]sx?|cjs|cts|vue|svelte|astro)$/i
501// A Hungarian or curly quote right where JS expects a string delimiter.
502const QUOTE_DELIMITER = new RegExp(String.raw`(^|[=(,:[{?]|\breturn)\s*[` + chars(0x201e, 0x201c, 0x201d) + ']', 'm')
503// Emoji: the pictographic planes, or anything forced to emoji presentation.
504const EMOJI = new RegExp('[' + chars(0x1f000) + '-' + chars(0x1faff) + ']|' + chars(0xfe0f), 'u')
505const SECRET_FILE = /(^|[\\/])(\.env[^\\/]*|\.dev\.vars)$/i
506const SECRETS: readonly { name: [string, string]; re: RegExp }[] = [
507 { name: ['Anthropic API-kulcs', 'An Anthropic API key'], re: /sk-ant-[A-Za-z0-9_-]{20,}/ },
508 { name: ['Stripe éles kulcs', 'A live Stripe key'], re: /\b[sr]k_live_[0-9A-Za-z]{20,}/ },
509 { name: ['AWS hozzáférési kulcs', 'An AWS access key'], re: /\bAKIA[0-9A-Z]{16}\b/ },
510 { name: ['GitHub token', 'A GitHub token'], re: /\b(ghp|gho|ghs)_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,}/ },
511 { name: ['Slack token', 'A Slack token'], re: /\bxox[baprs]-[A-Za-z0-9-]{10,}/ },
512 { name: ['Google API-kulcs', 'A Google API key'], re: /\bAIza[0-9A-Za-z_-]{35}\b/ },
513 { name: ['privát kulcs', 'A private key'], re: /-----BEGIN (RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/ },
514]
515
516const count = (text: string, re: RegExp) => (text.match(re) ?? []).length
517
518export type GuardOptions = { dashes?: boolean }
519
520/** Why Zsemle growls at this write, or null when it may go ahead. The dash rule is on unless `dashes` is false. */
521export function guardReason(path: string, added: string, removed = '', options: GuardOptions = {}): string | null {
522 if (options.dashes !== false && count(added, DASH) > count(removed, DASH)) {
523 return tr(
524 'hosszú gondolatjel (em vagy en dash) kerülne a fájlba. Használj vesszőt, kettőspontot vagy sima kötőjelet.',
525 'a long dash (em or en dash) would go into the file. Use a comma, a colon or a plain hyphen.',
526 )
527 }
528 if (JS_FILE.test(path) && QUOTE_DELIMITER.test(added) && !QUOTE_DELIMITER.test(removed)) {
529 return tr(
530 'magyar vagy görbe idézőjel állna string-határolóként a JS/TS kódban (SyntaxError). Csak ASCII \' vagy " jó.',
531 'a curly or Hungarian quote would delimit a string in JS/TS code (SyntaxError). Only ASCII \' or " works.',
532 )
533 }
534 if (CODE_FILE.test(path) && EMOJI.test(added) && !EMOJI.test(removed)) {
535 return tr('emoji kerülne kódba vagy kommentbe.', 'an emoji would go into code or a comment.')
536 }
537 if (!SECRET_FILE.test(path)) {
538 for (const s of SECRETS) {
539 if (s.re.test(added) && !s.re.test(removed)) {
540 return tr(
541 `${s.name[0]} kerülne a fájlba. Titok csak .env-be vagy secret store-ba mehet.`,
542 `${s.name[1]} would go into the file. Secrets belong in .env or a secret store only.`,
543 )
544 }
545 }
546 }
547 return null
548}
549
550export type WriteParts = { path: string; added: string; removed: string }
551
552/** What a file-writing tool call would add, or null for other tools. */
553export function writeParts(tool: string, input: Record<string, unknown>): WriteParts | null {
554 const str = (v: unknown) => (typeof v === 'string' ? v : '')
555 if (tool === 'Write') {
556 return { path: str(input.file_path), added: str(input.content), removed: '' }
557 }
558 if (tool === 'Edit') {
559 return { path: str(input.file_path), added: str(input.new_string), removed: str(input.old_string) }
560 }
561 if (tool === 'MultiEdit' && Array.isArray(input.edits)) {
562 const edits = input.edits as { new_string?: unknown; old_string?: unknown }[]
563 return {
564 path: str(input.file_path),
565 added: edits.map(e => str(e.new_string)).join('\n'),
566 removed: edits.map(e => str(e.old_string)).join('\n'),
567 }
568 }
569 if (tool === 'NotebookEdit') {
570 return { path: str(input.notebook_path), added: str(input.new_source), removed: '' }
571 }
572 return null
573}
574
575// ---- Commands ---------------------------------------------------------------
576
577export function isTestCommand(cmd: string): boolean {
578 return /\b(npm|pnpm|yarn|bun)\s+(run\s+)?test\b|\b(vitest|jest|pytest|playwright\s+test|mocha)\b|\bclaude\s+plugin\s+test\b|\bnode\s+--test\b|\bgo\s+test\b|\bcargo\s+test\b/.test(cmd)
579}
580
581// A command word at the start of the command or after a shell separator, so a
582// script that merely mentions "vercel" in its text is not a deploy.
583const AT_COMMAND = String.raw`(?:^|[;&|(]\s*|\bnpx\s+|\bbunx\s+|\bpnpm\s+dlx\s+)`
584
585const DEPLOY = new RegExp(
586 AT_COMMAND +
587 String.raw`(?:wrangler\s+(?:pages\s+)?deploy\b|vercel(?:\s+(?:--prod\b|deploy\b|--yes\b)|\s*$|\s*[;&|])|netlify\s+deploy\b|firebase\s+deploy\b|fly\s+deploy\b)`,
588 'm',
589)
590
591export function isDeployCommand(cmd: string): boolean {
592 return DEPLOY.test(cmd.trim())
593}
594
595export function deployNote(): string {
596 return tr(
597 'Zsemle szimatol: deploy futott. A sikeres parancs nem bizonyít, kérdezd vissza az élő állapotot ' +
598 '(curl az élő URL-re, a verzió vagy időbélyeg, a fő API státusza), mielőtt késznek mondod.',
599 'Zsemle sniffs: a deploy ran. A successful command proves nothing; check the live state ' +
600 '(curl the live URL, the version or timestamp, the main API status) before you call it done.',
601 )
602}
603
604export function chaseNote(): string {
605 return tr(
606 'Zsemle: ez a parancs egymás után többször ugyanúgy elbukott. Ne futtasd újra változatlanul; ' +
607 'állj meg, olvasd el a hibát, és keresd a gyökérokot, mielőtt újra próbálod.',
608 'Zsemle: this command failed the same way several times in a row. Do not run it again unchanged; ' +
609 'stop, read the error and find the root cause before you try again.',
610 )
611}
612
613// ---- Sprite -----------------------------------------------------------------
614// The figures live in skins.ts: 24 x 12 pixels per pose. A one-pixel outline
615// and a pixel of padding make 28 x 14, drawn in quadrant blocks as 14 columns
616// and 7 rows on a terminal, and as an Svg on the desktop.
617
618function outline(rows: readonly string[]): string[] {
619 const w = (rows[0]?.length ?? 0) + 2
620 const h = rows.length + 2
621 const grid = Array.from({ length: h }, (_, y) =>
622 Array.from({ length: w }, (_, x) => rows[y - 1]?.[x - 1] ?? '.'),
623 )
624 const filled = (y: number, x: number) => {
625 const c = grid[y]?.[x]
626 return c !== undefined && c !== '.' && c !== 'O'
627 }
628 return grid.map(
629 (row, y) =>
630 '.' +
631 row
632 .map((c, x) =>
633 c === '.' && (filled(y + 1, x) || filled(y - 1, x) || filled(y, x + 1) || filled(y, x - 1)) ? 'O' : c,
634 )
635 .join('') +
636 '.',
637 )
638}
639
640/**
641 * A frame the figure can show: one of the 9 poses, or a skin's extra frame:
642 * `work0`.. (while the model works), `long` (a turn past 3 minutes), `level0`
643 * to `level3` (the resting face by fatigue).
644 */
645export type Frame = Pose | `work${number}` | 'long' | `level${number}`
646
647const frameCache = new Map<SkinId, Record<string, readonly string[]>>()
648
649/** A skin's frames, outlined and padded: the grids every drawing is made from. */
650export function framesOf(skin: SkinId): Record<string, readonly string[]> {
651 const cached = frameCache.get(skin)
652 if (cached !== undefined) return cached
653 const s = skinOf(skin)
654 const out: Record<string, readonly string[]> = {}
655 for (const p of POSE_NAMES) out[p] = outline(s.poses[p])
656 ;(s.workFrames ?? []).forEach((g, i) => (out[`work${i}`] = outline(g)))
657 if (s.longTurn !== undefined) out.long = outline(s.longTurn)
658 ;(s.levels ?? []).forEach((g, i) => (out[`level${i}`] = outline(g)))
659 frameCache.set(skin, out)
660 return out
661}
662
663/** A skin's 9 poses, outlined and padded. */
664export function posesOf(skin: SkinId): Record<Pose, readonly string[]> {
665 return framesOf(skin) as Record<Pose, readonly string[]>
666}
667
668const rowsOf = (frame: Frame, skin: SkinId) => framesOf(skin)[frame] ?? framesOf(skin).awake ?? []
669
670/** Zsemle's own poses (the default skin). */
671export const POSES: Record<Pose, readonly string[]> = posesOf(DEFAULT_SKIN)
672
673export const SPRITE_COLUMNS = (POSES.awake[0]?.length ?? 0) / 2
674export const SPRITE_ROWS = POSES.awake.length / 2
675
676const rgbRaw = (skin: SkinId, c: string): readonly [number, number, number] => skinOf(skin).palette[c] ?? [0, 0, 0]
677
678// A faded figure (a ghost asleep) for the terminal, which has no opacity: every
679// color mixed 60% toward a mid grey, which reads as see-through on dark and light alike.
680const FADE = 0.6
681let fading = false
682
683const rgbOf = (skin: SkinId, c: string): readonly [number, number, number] => {
684 const rgb = rgbRaw(skin, c)
685 return fading ? (rgb.map(v => Math.round(v * (1 - FADE) + 128 * FADE)) as unknown as readonly [number, number, number]) : rgb
686}
687
688const hexRaw = (skin: SkinId, c: string) =>
689 '#' +
690 rgbRaw(skin, c)
691 .map(v => v.toString(16).padStart(2, '0'))
692 .join('')
693
694const hex = (skin: SkinId, c: string) =>
695 '#' +
696 rgbOf(skin, c)
697 .map(v => v.toString(16).padStart(2, '0'))
698 .join('')
699
700// Quadrant blocks by mask: top-left 8, top-right 4, bottom-left 2, bottom-right 1.
701const QUADRANTS = [' ', '▗', '▖', '▄', '▝', '▐', '▞', '▟', '▘', '▚', '▌', '▙', '▀', '▜', '▛', '█']
702
703/** One run of identical cells: its glyphs and colors, ready to draw as one Text. */
704export type SpriteRun = { text: string; fg?: string; bg?: string }
705
706/** A cell holds two colors (foreground, background); a third maps to the nearer of the two. */
707function quadrantCell(skin: SkinId, px: string[]): { ch: string; fg?: string; bg?: string } {
708 const distance = (a: string, b: string) => {
709 const x = rgbOf(skin, a)
710 const y = rgbOf(skin, b)
711 return (x[0] - y[0]) ** 2 + (x[1] - y[1]) ** 2 + (x[2] - y[2]) ** 2
712 }
713 const counts = new Map<string, number>()
714 for (const c of px) counts.set(c, (counts.get(c) ?? 0) + 1)
715 const keep = [...counts.keys()]
716 .sort((a, b) => (counts.get(b) ?? 0) - (counts.get(a) ?? 0) || Number(a !== 'O') - Number(b !== 'O') || a.localeCompare(b))
717 .slice(0, 2)
718 const opaque = keep.filter(k => k !== '.')
719 const cells = px.map(c =>
720 keep.includes(c) ? c : (opaque.length > 0 ? opaque : keep).reduce((best, k) => (distance(c, k) < distance(c, best) ? k : best)),
721 )
722 const fg = cells.find(c => c !== '.')
723 if (fg === undefined) return { ch: ' ' }
724 const bg = cells.find(c => c !== fg && c !== '.')
725 const mask = cells.reduce((m, c, i) => (c === fg ? m | [8, 4, 2, 1][i]! : m), 0)
726 return { ch: QUADRANTS[mask] ?? ' ', fg: hex(skin, fg), bg: bg === undefined ? undefined : hex(skin, bg) }
727}
728
729const runCache = new Map<string, SpriteRun[][]>()
730
731/** Grid rows as lines of runs: quadrant glyphs, at most two colors per cell; `faded` mixes every color toward grey. */
732function runsFrom(rows: readonly string[], skin: SkinId, faded: boolean): SpriteRun[][] {
733 fading = faded
734 const lines: SpriteRun[][] = []
735 const width = (rows[0]?.length ?? 0) / 2
736 for (let cy = 0; cy < rows.length / 2; cy++) {
737 const runs: SpriteRun[] = []
738 for (let cx = 0; cx < width; cx++) {
739 const px = (dy: number, dx: number) => rows[cy * 2 + dy]?.[cx * 2 + dx] ?? '.'
740 const cell = quadrantCell(skin, [px(0, 0), px(0, 1), px(1, 0), px(1, 1)])
741 const last = runs[runs.length - 1]
742 if (last !== undefined && last.fg === cell.fg && last.bg === cell.bg) last.text += cell.ch
743 else {
744 // Client props are plain data: an absent color is left out, never undefined.
745 const run: SpriteRun = { text: cell.ch }
746 if (cell.fg !== undefined) run.fg = cell.fg
747 if (cell.bg !== undefined) run.bg = cell.bg
748 runs.push(run)
749 }
750 }
751 lines.push(runs)
752 }
753 fading = false
754 return lines
755}
756
757/** The frame as lines of runs: quadrant glyphs, at most two colors per cell; `faded` mixes every color toward grey. */
758export function spriteRuns(pose: Frame, skin: SkinId = DEFAULT_SKIN, faded = false): SpriteRun[][] {
759 const cacheKey = `${skin}:${pose}:${faded}`
760 const cached = runCache.get(cacheKey)
761 if (cached !== undefined) return cached
762 const lines = runsFrom(rowsOf(pose, skin), skin, faded)
763 runCache.set(cacheKey, lines)
764 return lines
765}
766
767/**
768 * The frame at half size: each 2 x 2 block of pixels becomes one pixel of its
769 * weightiest opaque color (rare colors weigh more, the outline half), padded to an even height.
770 */
771export function halve(rows: readonly string[]): string[] {
772 // Colors rare in the whole figure (eyes, nose, beak) outweigh the common ones,
773 // so the face survives the halving.
774 const total = new Map<string, number>()
775 for (const r of rows) for (const c of r) if (c !== '.') total.set(c, (total.get(c) ?? 0) + 1)
776 const out: string[] = []
777 const width = rows[0]?.length ?? 0
778 for (let y = 0; y < rows.length; y += 2) {
779 let line = ''
780 for (let x = 0; x < width; x += 2) {
781 const px = [rows[y]?.[x], rows[y]?.[x + 1], rows[y + 1]?.[x], rows[y + 1]?.[x + 1]].filter(
782 (c): c is string => c !== undefined && c !== '.',
783 )
784 if (px.length < 2) {
785 line += '.'
786 continue
787 }
788 const counts = new Map<string, number>()
789 for (const c of px) counts.set(c, (counts.get(c) ?? 0) + (c === 'O' ? 0.5 : 1) / Math.sqrt(total.get(c) ?? 1))
790 line += [...counts.entries()].sort((p, q) => q[1] - p[1] || p[0].localeCompare(q[0]))[0]?.[0] ?? '.'
791 }
792 out.push(line)
793 }
794 if (out.length % 2 === 1) out.push('.'.repeat(out[0]?.length ?? 0))
795 return out
796}
797
798/** The mini figure's size in terminal cells: half the figure. */
799export const MINI_COLUMNS = SPRITE_COLUMNS / 2
800export const MINI_ROWS = Math.ceil(SPRITE_ROWS / 2)
801
802/** The frame at half size as runs: the small helpers that stand for running subagents. */
803export function miniRuns(pose: Frame, skin: SkinId = DEFAULT_SKIN): SpriteRun[][] {
804 const cacheKey = `mini:${skin}:${pose}`
805 const cached = runCache.get(cacheKey)
806 if (cached !== undefined) return cached
807 const lines = runsFrom(halve(rowsOf(pose, skin)), skin, false)
808 runCache.set(cacheKey, lines)
809 return lines
810}
811
812const svgCache = new Map<string, string>()
813
814function svgFrom(rows: readonly string[], skin: SkinId, faded: boolean): string {
815 const width = rows[0]?.length ?? 0
816 const rects: string[] = []
817 rows.forEach((row, y) => {
818 let x = 0
819 while (x < row.length) {
820 const c = row[x] ?? '.'
821 let end = x + 1
822 while (end < row.length && row[end] === c) end++
823 if (c !== '.') rects.push(`<rect x="${x}" y="${y * 2}" width="${end - x}" height="2" fill="${hexRaw(skin, c)}"/>`)
824 x = end
825 }
826 })
827 return (
828 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${rows.length * 2}" shape-rendering="crispEdges"${faded ? ' opacity="0.35"' : ''}>` +
829 rects.join('') +
830 '</svg>'
831 )
832}
833
834/**
835 * The frame as an SVG document for surfaces that draw `Svg` (the desktop),
836 * where quadrant glyphs do not tile. One sprite pixel is 1 x 2 user units, as
837 * tall as a quadrant is in a terminal cell, so both surfaces show the same
838 * figure. Each row's runs of one color are merged into a single rect.
839 */
840export function spriteSvg(pose: Frame, skin: SkinId = DEFAULT_SKIN, faded = false): string {
841 const cacheKey = `${skin}:${pose}:${faded}`
842 const cached = svgCache.get(cacheKey)
843 if (cached !== undefined) return cached
844 const svg = svgFrom(rowsOf(pose, skin), skin, faded)
845 svgCache.set(cacheKey, svg)
846 return svg
847}
848
849/** The half-size frame as an SVG document, for the desktop's mini figures. */
850export function miniSvg(pose: Frame, skin: SkinId = DEFAULT_SKIN): string {
851 const cacheKey = `mini:${skin}:${pose}`
852 const cached = svgCache.get(cacheKey)
853 if (cached !== undefined) return cached
854 const svg = svgFrom(halve(rowsOf(pose, skin)), skin, false)
855 svgCache.set(cacheKey, svg)
856 return svg
857}
858
859// ---- Housekeeping: context save, reflect queue, commits, ports ---------------
860
861export const SAVE_RESET_BELOW = 50
862export const COMMIT_SNIFF_AT = 10
863export const SCAN_EVERY_MS = 2 * 60 * 1000
864
865export function contextSaveNote(percent: number, isSaved: boolean): string {
866 const pct = Math.round(percent)
867 if (isSaved) {
868 return tr(`Kontextus ${pct}%: a "Folytatás innen" mentve, mehet a /compact.`, `Context ${pct}%: the status note is saved, /compact can go.`)
869 }
870 return tr(
871 `Kontextus ${pct}%: előbb frissítsd a projekt HOME "Folytatás innen" blokkját, aztán /compact. Ha másik munkamenetben folytatnád: /atadas <cél>.`,
872 `Context ${pct}%: first update the project's status note (HOME.md or INDEX.md), then /compact. To continue in another session: /atadas <goal>.`,
873 )
874}
875
876export function contextSavePromptNote(): string {
877 return tr(
878 'Zsemle: a kontextus 80% fölött jár. Mielőtt tovább dolgozol, frissítsd a projekt HOME.md (vagy INDEX.md) ' +
879 '"Folytatás innen" blokkját: hol tartunk, mi a következő lépés, mi vár másra. Utána javasold a felhasználónak a /compact parancsot.',
880 'Zsemle: the context is above 80%. Before you go on, update the project status note (HOME.md or INDEX.md): ' +
881 'where we are, what the next step is, what waits on others. Then suggest /compact to the user.',
882 )
883}
884
885/** A project status card: the note the "Folytatás innen" block lives in. */
886export function isProjectCard(path: string): boolean {
887 return /(^|[\/])(HOME|INDEX|00-INDEX)\.md$/i.test(path)
888}
889
890/** Lines in the SIL queue: each one is a session that has not been reflected on yet. */
891export function queueCount(text: string): number {
892 return text.split(/\r?\n/).filter(l => l.trim() !== '').length
893}
894
895export function reflectNote(count: number): string {
896 return tr(
897 `A SIL-sorban ${count} feldolgozatlan reflexió vár. A munkamenet végén futtasd: /reflect`,
898 `${count} unprocessed reflections wait in the queue. At the end of the session run: /reflect`,
899 )
900}
901
902export function reflectPromptNote(count: number): string {
903 return tr(
904 `Zsemle: a SIL-sorban ${count} feldolgozatlan reflexió vár. A válaszod végén javasold a /reflect futtatását.`,
905 `Zsemle: ${count} unprocessed reflections wait in the queue. At the end of your answer, suggest running /reflect.`,
906 )
907}
908
909/** A prompt that sounds like the end of the session. */
910export function isClosingPrompt(text: string): boolean {
911 const t = text.trim().toLowerCase()
912 // Unicode letter boundaries: "készíts" is not "kész".
913 return (
914 t.length < 80 &&
915 /(?<!\p{L})(köszi|köszönöm|kösz|kész|ennyi|vége|mára|jó éjt|jóéjt|szia|viszlát|bye|thanks|thank you|good night|that's all|done for today)(?!\p{L})/u.test(t)
916 )
917}
918
919/** Changed files in `git status --porcelain` output. */
920export function porcelainCount(text: string): number {
921 return text.split(/\r?\n/).filter(l => l.trim() !== '').length
922}
923
924export function commitNote(count: number): string {
925 return tr(`Szimat: ${count} módosított fájl vár commitra. Ideje egy commitnak?`, `Sniff: ${count} changed files wait for a commit. Time for one?`)
926}
927
928export function commitBucket(count: number): number {
929 return Math.floor(count / COMMIT_SNIFF_AT) * COMMIT_SNIFF_AT
930}
931
932export function isDevServerCommand(cmd: string): boolean {
933 return /\b(wrangler|vite|next|astro|nuxt|remix|vercel)\s+dev\b|\b(npm|pnpm|yarn|bun)\s+(run\s+)?(dev|start|preview)\b|\bpython\s+-m\s+http\.server\b|\bserve\b/.test(cmd)
934}
935
936const DEV_PORT = (port: number) =>
937 (port >= 3000 && port <= 3010) || (port >= 4000 && port <= 4010) || port === 4173 || port === 4321 ||
938 (port >= 5000 && port <= 5010) || (port >= 5173 && port <= 5180) || (port >= 8000 && port <= 8010) ||
939 port === 8080 || port === 8081 || (port >= 8787 && port <= 8790) || port === 8888
940
941export type PortUse = Map<number, Set<number>>
942
943/** Listening TCP ports and their PIDs from Windows `netstat -ano`. */
944export function parseNetstat(text: string): PortUse {
945 const use: PortUse = new Map()
946 for (const line of text.split(/\r?\n/)) {
947 // A listening socket has no remote end (0.0.0.0:0 or [::]:0); the state word
948 // itself is localized on some Windows installs, so it is not matched.
949 const m = /^\s*TCP\s+\S*:(\d+)\s+(?:0\.0\.0\.0|\[::\]):0\s+\S+\s+(\d+)\s*$/i.exec(line)
950 if (m === null) continue
951 const port = Number(m[1])
952 const pid = Number(m[2])
953 if (!use.has(port)) use.set(port, new Set())
954 use.get(port)?.add(pid)
955 }
956 return use
957}
958
959/** Listening TCP ports and their PIDs from `lsof -nP -iTCP -sTCP:LISTEN`. */
960export function parseLsof(text: string): PortUse {
961 const use: PortUse = new Map()
962 for (const line of text.split(/\r?\n/).slice(1)) {
963 const cols = line.trim().split(/\s+/)
964 const pid = Number(cols[1])
965 const name = cols.find(c => /:\d+$/.test(c))
966 if (!Number.isFinite(pid) || name === undefined) continue
967 const port = Number(name.slice(name.lastIndexOf(':') + 1))
968 if (!use.has(port)) use.set(port, new Set())
969 use.get(port)?.add(pid)
970 }
971 return use
972}
973
974export type PortClash = { port: number; pids: number[] }
975
976/** Dev ports with more than one process listening: one of them serves stale code. */
977export function portClashes(use: PortUse): PortClash[] {
978 return [...use.entries()]
979 .filter(([port, pids]) => DEV_PORT(port) && pids.size > 1)
980 .map(([port, pids]) => ({ port, pids: [...pids].sort((a, b) => a - b) }))
981 .sort((a, b) => a.port - b.port)
982}
983
984export function portNote(clash: PortClash): string {
985 const pids = clash.pids.join(', ')
986 return tr(
987 `Portőr: a ${clash.port}-es porton ${clash.pids.length} folyamat figyel (PID ${pids}). Az egyik elavult kódot szolgálhat ki.`,
988 `Port guard: ${clash.pids.length} processes listen on port ${clash.port} (PID ${pids}). One of them may serve stale code.`,
989 )
990}
991
992export function portModelNote(clash: PortClash): string {
993 const pids = clash.pids.join(', ')
994 const ps =
995 "Get-CimInstance Win32_Process | Where-Object { $_.ProcessId -in @(" + clash.pids.join(',') + ') } | Select ProcessId, CommandLine'
996 return tr(
997 `Zsemle portőr: a ${clash.port}-es porton ${clash.pids.length} folyamat figyel (PID ${pids}), ` +
998 'az egyik elavult kódot szolgálhat ki. Állítsd le a régieket, mielőtt a dev-szerver válaszára építesz. ' +
999 `Windowson: ${ps}, aztán Stop-Process -Id <régi PID> -Force (a saját shelledet ne).`,
1000 `Zsemle port guard: ${clash.pids.length} processes listen on port ${clash.port} (PID ${pids}), ` +
1001 'one of them may serve stale code. Stop the old ones before you rely on the dev server. ' +
1002 `On Windows: ${ps}, then Stop-Process -Id <old PID> -Force (not your own shell).`,
1003 )
1004}
1005
1006// ---- Daily stats ------------------------------------------------------------
1007
1008export type DayStats = {
1009 turns: number
1010 tools: number
1011 longestMs: number
1012 tests: number
1013 deploys: number
1014 growls: number
1015 pets: number
1016 barks: number
1017}
1018
1019export const EMPTY_STATS: DayStats = { turns: 0, tools: 0, longestMs: 0, tests: 0, deploys: 0, growls: 0, pets: 0, barks: 0 }
1020
1021export function statsText(day: string, s: DayStats, name = 'Zsemle'): string {
1022 const longest = s.longestMs > 0 ? formatDuration(s.longestMs) : '-'
1023 if (lang() === 'en') {
1024 return [
1025 `${name}'s stats for the day (${day}):`,
1026 ` turns: ${s.turns}`,
1027 ` tool calls: ${s.tools}`,
1028 ` longest turn: ${longest}`,
1029 ` green test runs: ${s.tests}`,
1030 ` deploys: ${s.deploys}`,
1031 ` growls (guard): ${s.growls}`,
1032 ` barks: ${s.barks}`,
1033 ` pets: ${s.pets}`,
1034 ].join('\n')
1035 }
1036 return [
1037 `${name} napi statisztikája (${day}):`,
1038 ` körök: ${s.turns}`,
1039 ` tool-hívások: ${s.tools}`,
1040 ` leghosszabb kör: ${longest}`,
1041 ` zöld tesztfutás: ${s.tests}`,
1042 ` deploy: ${s.deploys}`,
1043 ` morgás (őr): ${s.growls}`,
1044 ` ugatás: ${s.barks}`,
1045 ` simogatás: ${s.pets}`,
1046 ].join('\n')
1047}
1048
1049// --- Marveen-atvetel 40 (2026-10-07): kvota-pillanatkep az utemezett futasoknak, hook-jelzesek, megszakitas ---
1050
1051
1052export const INTERRUPT_WINDOW_MS = 10 * 60 * 1000
1053export const INTERRUPTS_TO_NOTE = 2
1054
1055export function recentInterrupts(times: readonly number[], nowMs: number): number[] {
1056 return times.filter(t => nowMs - t < INTERRUPT_WINDOW_MS)
1057}
1058
1059export function frustrationNote(count: number): string {
1060 return tr(
1061 `${count}. megszakítás 10 percen belül. Ha rossz irányba megy, mondd meg egy mondatban, mit szeretnél helyette.`,
1062 `Interrupt number ${count} within 10 minutes. If it's going the wrong way, say in one sentence what you want instead.`,
1063 )
1064}
1065hooks/features.ts 338 lines1// Pure helpers for Zsemle's optional features: fatigue, the weekly budget,
2// model advice, the context saver, the commit guard, the loop watch, the
3// morning brief, the lesson sniff, the weekly activity chart and the day
4// summary. Each feature can be switched off (FEATURES). No `$` here.
5
6import type { Limit } from '../types'
7import { lang, tr } from './i18n'
8
9// ---- Switches ---------------------------------------------------------------
10
11export type Feature =
12 | 'fatigueLook'
13 | 'budgetPlanner'
14 | 'modelAdvice'
15 | 'contextSaver'
16 | 'commitGuard'
17 | 'loopWatch'
18 | 'morningBrief'
19 | 'lessonSniff'
20 | 'daySummary'
21
22export const FEATURES: readonly Feature[] = [
23 'fatigueLook',
24 'budgetPlanner',
25 'modelAdvice',
26 'contextSaver',
27 'commitGuard',
28 'loopWatch',
29 'morningBrief',
30 'lessonSniff',
31 'daySummary',
32]
33
34const FEATURE_TEXT: Record<Feature, [string, string, string]> = {
35 fatigueLook: ['faradtsag', 'a figura fárad, ahogy a limit fogy', 'the figure tires as the limit runs low'],
36 budgetPlanner: ['koltsegvetes', 'heti keret napi adagra osztva, esti figyelmeztetés', 'weekly allowance split into daily shares, evening warning'],
37 modelAdvice: ['modelltanacs', 'egyszerű feladatnál kisebb modellt javasol', 'suggests a smaller model for simple tasks'],
38 contextSaver: ['kontextusfek', '70% kontextus fölött tömörebb munkát kér', 'asks for leaner work above 70% context'],
39 commitGuard: ['commitor', 'git commit előtt szól, ha a módosítás óta nem futott teszt', 'warns before git commit when no test ran since the last edit'],
40 loopWatch: ['hurokfigyelo', 'szól, ha egy fájlt egy körön belül ötödször szerkeszt', 'warns when a file is edited a fifth time in one turn'],
41 morningBrief: ['reggeli', 'a nap első promptjánál rövid áttekintés', 'a short brief with the first prompt of the day'],
42 lessonSniff: ['tanulsag', 'harmadszor visszatérő hibánál tanulság rögzítését javasolja', 'suggests recording a lesson when an error comes back a third time'],
43 daySummary: ['napzaro', 'búcsúzáskor napzáró összefoglaló', 'a day summary when you sign off'],
44}
45
46/** The Hungarian command word of a feature (`/zsemle kapcsolo <word> ki`). */
47export function featureWord(f: Feature): string {
48 return FEATURE_TEXT[f][0]
49}
50
51export function featureLabel(f: Feature): string {
52 return tr(FEATURE_TEXT[f][1], FEATURE_TEXT[f][2])
53}
54
55const fold = (s: string) => s.trim().toLowerCase().normalize('NFD').replace(/\p{M}/gu, '')
56
57/** The feature a word names: its option key or its Hungarian word, accents and case aside. */
58export function findFeature(word: string): Feature | null {
59 const w = fold(word)
60 return FEATURES.find(f => fold(f) === w || fold(FEATURE_TEXT[f][0]) === w) ?? null
61}
62
63// ---- Fatigue ----------------------------------------------------------------
64
65/** 0 under 50% of the worst window, 1 from 50%, 2 from 75%, 3 from 90%. */
66export function fatigueLevel(limits: readonly Limit[]): number {
67 const worst = limits.reduce((m, l) => Math.max(m, l.percentUsed), 0)
68 return worst >= 90 ? 3 : worst >= 75 ? 2 : worst >= 50 ? 1 : 0
69}
70
71// ---- Weekly budget ----------------------------------------------------------
72
73const DAY_MS = 86400000
74
75/** What a day of the weekly window may use: the rest of the window over the days left, today included. */
76export type Budget = { allowance: number; usedToday: number; startPct: number; daysLeft: number }
77
78/**
79 * The day's share of the weekly window. `startPct` is the weekly use at the
80 * first reading of the day, `dayStartMs` that local day's midnight in ms.
81 */
82export function dailyBudget(week: Limit, startPct: number, dayStartMs: number): Budget | null {
83 if (week.resetsAt === undefined) return null
84 const resetAt = Date.parse(week.resetsAt)
85 if (Number.isNaN(resetAt)) return null
86 const daysLeft = Math.max(1, Math.ceil((resetAt - dayStartMs) / DAY_MS))
87 const allowance = Math.max(0, (100 - startPct) / daysLeft)
88 return { allowance, usedToday: Math.max(0, week.percentUsed - startPct), startPct, daysLeft }
89}
90
91export function budgetLine(b: Budget): string {
92 const a = b.allowance.toFixed(1)
93 const u = b.usedToday.toFixed(1)
94 return tr(
95 `Napi adag a heti keretből: ${a}% (még ${b.daysLeft} nap), ma eddig ${u}%.`,
96 `Daily share of the weekly allowance: ${a}% (${b.daysLeft} days left), used today ${u}%.`,
97 )
98}
99
100export function budgetOverNote(b: Budget): string {
101 const a = b.allowance.toFixed(1)
102 const u = b.usedToday.toFixed(1)
103 return tr(
104 `Ma ${u}%-ot használtál a heti keretből, a napi adag ${a}% lett volna. Holnap lassabban, hogy kitartson a hét végéig.`,
105 `Today you used ${u}% of the weekly allowance against a daily share of ${a}%. Go easier tomorrow so it lasts the week.`,
106 )
107}
108
109// ---- Model advice -----------------------------------------------------------
110
111const SIMPLE =
112 /(?<!\p{L})(átnevez\p{L}*|nevezd át|rename|formáz\p{L}*|format\p{L}*|elírás\p{L}*|typo\p{L}*|fordítsd|translate|komment\p{L}*|comment\p{L}*|behúz\p{L}*|indent\p{L}*|rendezd|sort|lint\p{L}*|helyesírás|spelling|import\p{L}*|szóköz\p{L}*|whitespace)(?!\p{L})/iu
113
114/** A prompt that asks for a small mechanical change. */
115export function isSimpleTask(text: string): boolean {
116 const t = text.trim()
117 return t.length > 0 && t.length < 240 && !t.startsWith('/') && SIMPLE.test(t)
118}
119
120export const isBigModel = (model: string) => /opus/i.test(model)
121
122export function modelAdviceNote(model: string): string {
123 return tr(
124 `Ez egyszerű feladatnak tűnik, és most ${model} dolgozik. Egy kisebb modell (/model sonnet vagy haiku) kímélné a keretet.`,
125 `This looks like a simple task, and ${model} is working now. A smaller model (/model sonnet or haiku) would spare the allowance.`,
126 )
127}
128
129// ---- Context saver ----------------------------------------------------------
130
131export const CONTEXT_SAVER_AT = 70
132
133export function contextSaverNote(): string {
134 return tr(
135 'Zsemle: a kontextus 70% fölött jár. Dolgozz takarékosan: tömör válaszok, fájlokból csak a szükséges részt olvasd be (offset/limit), kerüld a hosszú parancskimeneteket, és ne ismételd meg, amit már láttál.',
136 'Zsemle: the context is above 70%. Work lean: short answers, read only the needed part of files (offset/limit), avoid long command output, and do not repeat what you have already seen.',
137 )
138}
139
140// ---- Commit guard -----------------------------------------------------------
141
142// Global options may come first, with a value for -C and -c: git -C repo commit, git -c a=b commit.
143const COMMIT_AT = /(?:^|[;&|(]\s*)git\s+(?:(?:-[Cc]\s+\S+|--?[\w-]+(?:=\S+)?)\s+)*commit\b/m
144
145export function isCommitCommand(cmd: string): boolean {
146 return COMMIT_AT.test(cmd.trim())
147}
148
149const TEMP_DIR = /^(?:\/tmp\/|\$\{?TMPDIR\b|%TE?MP%|\$env:TE?MP\b|\$\(mktemp\b|[A-Za-z]:[\\/](?:[^\\/]+[\\/])*AppData[\\/]Local[\\/]Temp[\\/]|\/[a-z]\/Users\/[^/]+\/AppData\/Local\/Temp\/)/i
150const unquote = (s: string) => s.replace(/^["']|["']$/g, '')
151
152/**
153 * A commit into a throwaway repository: one the same command makes (`git init` before the commit), or one in a
154 * temp folder (`cd`, `Set-Location`, `Push-Location` or `git -C` to mktemp, /tmp, %TEMP%, $env:TEMP,
155 * AppData/Local/Temp). Such a commit is a test harness, not the project's history, so the commit guard lets it
156 * through. A move into a real folder keeps it guarded.
157 */
158export function isThrowawayCommit(cmd: string): boolean {
159 const m = COMMIT_AT.exec(cmd)
160 if (m === null) return false
161 const before = cmd.slice(0, m.index)
162 if (/\bgit\s+init\b/.test(before)) return true
163 const tempVars = [...cmd.matchAll(/\b(\w+)=["']?\$\(mktemp\b/g)].map(v => v[1])
164 const isTemp = (raw: string) => {
165 const p = unquote(raw)
166 const v = /^\$\{?(\w+)\}?/.exec(p)
167 return TEMP_DIR.test(p) || (v !== null && tempVars.includes(v[1]))
168 }
169 const target =
170 /\s-C\s+("[^"]*"|'[^']*'|\S+)/.exec(m[0])?.[1] ??
171 [...before.matchAll(/(?:^|[\s;&|(])(?:cd|pushd|Set-Location|Push-Location|sl)\s+(?:-(?:Literal)?Path\s+)?("[^"]*"|'[^']*'|[^\s;&|)]+)/gi)].pop()?.[1]
172 return target !== undefined && isTemp(target)
173}
174
175export function commitGuardNote(): string {
176 return tr(
177 'Zsemle commit-őr: a legutóbbi módosítás óta nem futott teszt. Futtasd a teszteket, vagy ha szándékos, add ki újra ugyanezt a commitot (2 percen belül átengedem).',
178 'Zsemle commit guard: no test ran since the last edit. Run the tests, or if this is on purpose, run the same commit again (I let it through within 2 minutes).',
179 )
180}
181
182// ---- Loop watch -------------------------------------------------------------
183
184export const LOOP_EDITS = 5
185
186export function loopNote(path: string, edits: number): string {
187 const name = path.split(/[\\/]/).pop() ?? path
188 return tr(
189 `Zsemle hurokfigyelő: a(z) ${name} fájlt ebben a körben már ${edits}. alkalommal szerkeszted. Állj meg, nézd meg egészben, mi a cél, és egy átgondolt lépésben javítsd.`,
190 `Zsemle loop watch: this is edit number ${edits} of ${name} in this turn. Stop, look at the whole file and the goal, and fix it in one considered step.`,
191 )
192}
193
194export function loopBubble(path: string, edits: number): string {
195 const name = path.split(/[\\/]/).pop() ?? path
196 return tr(`Körbe-körbe javítunk: ${name}, ${edits}. szerkesztés ebben a körben.`, `Going in circles: ${name}, edit number ${edits} in this turn.`)
197}
198
199// ---- Lesson sniff -----------------------------------------------------------
200
201/** An error's signature: its first meaningful line without numbers, paths and quotes, so the same failure matches across runs. */
202export function errorSignature(text: string): string | null {
203 const line = text
204 .split(/\r?\n/)
205 .map(l => l.trim())
206 .find(l => l.length > 8 && /[A-Za-z]/.test(l))
207 if (line === undefined) return null
208 return line
209 .replace(/[A-Za-z]:[\\/][^\s'"]*/g, '<path>')
210 .replace(/(^|\s)\/[^\s'"]+/g, '$1<path>')
211 .replace(/'[^']*'|"[^"]*"|`[^`]*`/g, '<q>')
212 .replace(/\d+/g, '<n>')
213 .replace(/\s+/g, ' ')
214 .slice(0, 160)
215}
216
217export const LESSON_AFTER = 3
218
219export function lessonNote(signature: string, count: number): string {
220 return tr(
221 `Zsemle tanulság-szimat: ez a hiba már ${count}. alkalommal jön elő ("${signature}"). Ha megvan a javítás, a válaszod végén javasold a felhasználónak, hogy rögzítse tanulságként (/reflect vagy a projekt LESSONS.md-je).`,
222 `Zsemle lesson sniff: this error came back for the ${count}th time ("${signature}"). Once it is fixed, suggest at the end of your answer that the user records it as a lesson (/reflect or the project's LESSONS.md).`,
223 )
224}
225
226export function lessonBubble(count: number): string {
227 return tr(`Ez a hiba már ${count}. alkalommal jön elő: érdemes tanulságként rögzíteni.`, `This error is back for the ${count}th time: worth recording as a lesson.`)
228}
229
230// ---- Morning brief ----------------------------------------------------------
231
232export type ProjectRow = { name: string; status: string; note: string }
233
234/** Rows of a markdown project table: `| [[path\|Name]] | status | ... | blocker | ... |`. */
235export function parseProjectTable(text: string): ProjectRow[] {
236 const rows: ProjectRow[] = []
237 for (const line of text.split(/\r?\n/)) {
238 if (!line.startsWith('|') || /^\|\s*-/.test(line)) continue
239 const cells = line
240 .replace(/\\\|/g, '\u0000')
241 .split('|')
242 .slice(1, -1)
243 .map(c => c.replace(/\u0000/g, '|').trim())
244 if (cells.length < 2) continue
245 const nameCell = cells[0] ?? ''
246 const m = /\[\[[^\]|]*\|([^\]]+)\]\]/.exec(nameCell) ?? /\[\[([^\]]+)\]\]/.exec(nameCell)
247 const name = (m?.[1] ?? nameCell).trim()
248 const status = (cells[1] ?? '').trim()
249 if (name === '' || /^(projekt|project|name|név)$/i.test(name)) continue
250 rows.push({ name, status, note: (cells[4] ?? '').trim() })
251 }
252 return rows
253}
254
255const WAITING = /^(deploy-var|blokkolt|blocked|ready|review)$/i
256
257export function briefNote(opts: { yesterday: string | null; projects: ProjectRow[]; budget: string | null; reflect: number }): string {
258 const parts: string[] = []
259 if (opts.yesterday !== null && opts.yesterday.trim() !== '') {
260 parts.push(tr(`Tegnapi napzáró: ${opts.yesterday.trim()}`, `Yesterday's summary: ${opts.yesterday.trim()}`))
261 }
262 const waiting = opts.projects.filter(p => WAITING.test(p.status))
263 if (waiting.length > 0) {
264 const list = waiting.map(p => `${p.name} (${p.status}${p.note !== '' && p.note !== '-' ? `: ${p.note}` : ''})`).join('; ')
265 parts.push(tr(`Kiadásra vagy másra váró projektek: ${list}.`, `Projects waiting to ship or on others: ${list}.`))
266 }
267 if (opts.budget !== null) parts.push(opts.budget)
268 if (opts.reflect > 0) parts.push(tr(`${opts.reflect} feldolgozatlan reflexió vár (/reflect).`, `${opts.reflect} unprocessed reflections wait (/reflect).`))
269 if (parts.length === 0) return ''
270 return tr(
271 `Zsemle reggeli brief (ez a nap első promptja; a válaszod elején 3-5 sorban mondd el a felhasználónak, aztán folytasd a kérését): ${parts.join(' ')}`,
272 `Zsemle morning brief (this is the first prompt of the day; tell the user in 3-5 lines at the start of your answer, then carry on with the request): ${parts.join(' ')}`,
273 )
274}
275
276// ---- Day summary ------------------------------------------------------------
277
278export function daySummaryNote(): string {
279 return tr(
280 'Zsemle napzáró: a válaszod végén foglald össze 3-5 pontban a mai munkát (mi készült el, mi maradt nyitva, mi a következő lépés). Ha van projekt HOME.md vagy INDEX.md kártya, frissítsd a "Folytatás innen" blokkját is.',
281 'Zsemle day summary: at the end of your answer, sum up today\'s work in 3-5 points (what got done, what is still open, what comes next). If the project has a HOME.md or INDEX.md status note, update it too.',
282 )
283}
284
285/** The tail of an answer worth keeping as the day summary: its last 600 characters, on whole lines. */
286export function summaryOf(answer: string): string {
287 const t = answer.trim()
288 if (t.length <= 600) return t
289 const tail = t.slice(-600)
290 const nl = tail.indexOf('\n')
291 return (nl >= 0 ? tail.slice(nl + 1) : tail).trim()
292}
293
294// ---- Weekly activity chart ----------------------------------------------------
295
296/** Tool calls per hour of one day: 24 counts. */
297export type DayActivity = number[]
298
299const SHADES = [' ', '.', ':', '+', '#']
300
301/** A 24-column chart, one row per day (oldest first), a header of hours and the busiest hour. */
302export function weekChart(days: { day: string; hours: DayActivity }[]): string {
303 const max = Math.max(1, ...days.flatMap(d => d.hours))
304 const shade = (n: number) => (n <= 0 ? SHADES[0] : SHADES[Math.min(SHADES.length - 1, 1 + Math.floor(((n - 1) / max) * (SHADES.length - 1)))])
305 const header = ' ' + Array.from({ length: 24 }, (_, h) => (h % 3 === 0 ? String(h).padEnd(3) : '')).join('')
306 const lines = days.map(d => {
307 const total = d.hours.reduce((a, b) => a + b, 0)
308 return `${d.day.slice(5)} ${weekday(d.day)} |${d.hours.map(shade).join('')}| ${total}`
309 })
310 const byHour = Array.from({ length: 24 }, (_, h) => days.reduce((a, d) => a + (d.hours[h] ?? 0), 0))
311 const best = byHour.indexOf(Math.max(...byHour))
312 const total = byHour.reduce((a, b) => a + b, 0)
313 const bestLine =
314 total === 0
315 ? tr('Még nincs adat erről a hétről.', 'No data for this week yet.')
316 : tr(`A legaktívabb órád: ${best}:00 és ${best + 1}:00 között. Összesen ${total} eszközhívás.`, `Your busiest hour: ${best}:00 to ${best + 1}:00. ${total} tool calls in all.`)
317 return [header, ...lines, '', bestLine, tr('(jelmagyarázat: . kevés, : közepes, + sok, # a legtöbb)', '(legend: . a little, : some, + a lot, # the most)')].join('\n')
318}
319
320const WEEKDAYS_HU = ['V', 'H', 'K', 'Sze', 'Cs', 'P', 'Szo']
321const WEEKDAYS_EN = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
322
323function weekday(day: string): string {
324 const d = new Date(`${day}T12:00:00Z`).getUTCDay()
325 return ((lang() === 'en' ? WEEKDAYS_EN : WEEKDAYS_HU)[d] ?? '').padEnd(3)
326}
327
328// ---- Ask ----------------------------------------------------------------------
329
330export function askSystem(facts: string): string {
331 return tr(
332 'Te Zsemle vagy, egy Claude Code társfigura. Röviden (legfeljebb 5 mondat), barátságosan, magyarul válaszolj a felhasználó kérdésére a lenti tények alapján. Ha a válasz nincs a tényekben, mondd meg őszintén. Ne használj hosszú gondolatjelet.\n\nTények:\n' +
333 facts,
334 'You are Zsemle, a Claude Code companion. Answer the user\'s question briefly (at most 5 sentences), in a friendly way, in English, from the facts below. If the answer is not in the facts, say so honestly. Do not use em or en dashes.\n\nFacts:\n' +
335 facts,
336 )
337}
338hooks/skins.ts 286 lines1import { lang } from './i18n'
2import { EXTRA_SKINS } from './extra-skins'
3import { CIRMI } from './figures/cirmi'
4import { KAPOCS } from './figures/kapocs'
5import { PINGVIN } from './figures/pingvin'
6import { TEKNOS } from './figures/teknos'
7import { HORCSOG } from './figures/horcsog'
8import { BAGOLY } from './figures/bagoly'
9import { RUBIK } from './figures/rubik'
10import { GUMIKACSA } from './figures/gumikacsa'
11import { BOGRE } from './figures/bogre'
12import { KAKTUSZ } from './figures/kaktusz'
13import { SARKANY } from './figures/sarkany'
14import { SZELLEM } from './figures/szellem'
15import { ROBOT } from './figures/robot'
16import { edits, lower } from './pixels'
17import type { Skin, SkinId, Voice } from './pixels'
18
19export { edits, HEIGHT, POSE_NAMES, WIDTH } from './pixels'
20export type { Pose, Skin, SkinId, Voice } from './pixels'
21
22// The figures Zsemle can wear. Each skin is pixel art on a 24 x 12 grid (a
23// pixel is a quarter of a terminal cell, half as wide as it is tall), one
24// grid per pose, its own palette and its own voice. Letters are colors, '.'
25// is transparent, 'O' is the outline color every skin defines. No `$` here.
26
27// ---- Zsemle: a cream golden retriever puppy, sitting, facing left ------------
28
29const DOG = [
30 '......CCCCCCDD..........',
31 '...CCCCCCCCCODDD........',
32 '..CCCWKKCCCCODDDDD......',
33 'LLLLCCCCCCCCODDDDD......',
34 'KKLLLLCCCCCCCODDD.......',
35 '..LLTTLLCCCCCCCC........',
36 '....LLLLCCCCCCCCCC......',
37 '....LLLLCCCCCCCCCCCC..DD',
38 '....LLOLLCCCCCCCCCCCDDDD',
39 '....LLOLLCCCCCCCCCCDDD..',
40 '....LLOLLOCCCCCCCC......',
41 '..LLLLLLLL.LLLLLL.......',
42]
43
44const ZSEMLE: Skin = {
45 id: 'zsemle',
46 label: 'Zsemle, a golden retriever kölyök',
47 aliases: ['kutya', 'dog', 'golden', 'retriever'],
48 palette: {
49 C: [0xe9, 0xc5, 0x8f], // cream coat ("zsemle")
50 D: [0xc8, 0x98, 0x5c], // darker ear and tail
51 L: [0xf8, 0xe7, 0xc6], // light muzzle, chest, paws
52 K: [0x28, 0x1a, 0x12], // eye, nose
53 T: [0xe8, 0x78, 0x87], // tongue
54 W: [0xff, 0xff, 0xff], // eye highlight, teeth
55 O: [0x78, 0x50, 0x28], // outline
56 },
57 poses: {
58 awake: DOG,
59 // Eye closed: also the sleeping face.
60 blink: edits(DOG, [2, 5, 'COO']),
61 bark: edits(DOG, [4, 3, 'OOO'], [5, 2, 'TTTT']),
62 yawn: edits(DOG, [4, 2, 'OOOO'], [5, 2, 'TTTTT']),
63 // Ear hangs lower: sad after a failure.
64 droop: edits(DOG, [0, 12, 'CC'], [1, 12, 'CCCC'], [2, 12, 'ODDDDD'], [3, 12, 'ODDDDD'], [4, 13, 'ODDD'], [5, 12, 'ODDD'], [6, 12, 'ODDD']),
65 // Tail raised: alternates with awake while wagging.
66 wag: edits(DOG, [7, 22, '..'], [8, 20, 'CC..'], [6, 20, 'DD'], [5, 21, 'DD']),
67 // Nose twitch: alternates with awake while sniffing.
68 sniff: edits(DOG, [4, 0, 'DD']),
69 // Teeth shown, ear pinned back.
70 growl: edits(DOG, [5, 2, 'OOWWWW'], [1, 12, 'CCODDD']),
71 // Heavy lid, ear hanging low: the limit is running out.
72 tired: edits(DOG, [2, 5, 'COK'], [0, 12, 'CC'], [1, 12, 'CCCC'], [2, 12, 'ODDDDD'], [3, 12, 'ODDDDD'], [4, 13, 'ODDD'], [5, 12, 'ODDD'], [6, 12, 'ODDD']),
73 },
74 voice: {
75 name: 'Zsemle',
76 bark: 'Vau!',
77 growl: 'Grrr!',
78 happy: 'Zöld a teszt, csóválok!',
79 pet: 'Köszi a simit!',
80 sniff: 'Szimat!',
81 snore: 'Zzz',
82 sound: 'sounds/bark.wav',
83 alt: 'Zsemle, a golden retriever kölyök',
84 },
85 labelEn: 'Zsemle, the golden retriever puppy',
86 voiceEn: {
87 name: 'Zsemle',
88 bark: 'Woof!',
89 growl: 'Grrr!',
90 happy: 'Tests are green, wagging!',
91 pet: 'Thanks for the pat!',
92 sniff: 'Sniff!',
93 snore: 'Zzz',
94 sound: 'sounds/bark.wav',
95 alt: 'Zsemle, the golden retriever puppy',
96 },
97}
98
99// ---- Trutyi: a jelly slime with big eyes -------------------------------------
100
101const SLIME = [
102 '........SSSSSSS.........',
103 '......SSSSSSSSSSS.......',
104 '....SSSHHSSSSSSSSSS.....',
105 '...SSSHHSSSSSSSSSSSS....',
106 '..SSSSSSSSSSSSSSSSSSS...',
107 '..SSWKSSSSWKSSSSSSSSSS..',
108 '.SSSKKSSSSKKSSSSSSSSSSS.',
109 '.SSSKKSSSSKKSSSSSSSSSSS.',
110 'SSSSSSSMMSSSSSSSSSSSSSSS',
111 'SSSSSSSSSSSSSSSSSSSSSSSS',
112 'DSSSSSSSSSSSSSSSSSSSSSSD',
113 '.DDDDDDDDDDDDDDDDDDDDDD.',
114]
115
116// Squashed one row lower and wider at the top: the wobble.
117const SLIME_SQUASH = lower(
118 [
119 '......SSSSSSSSSSS.......',
120 '...SSSHHSSSSSSSSSSSS....',
121 '..SSSHHSSSSSSSSSSSSSSS..',
122 '.SSSWKSSSSWKSSSSSSSSSSS.',
123 '.SSSKKSSSSKKSSSSSSSSSSS.',
124 'SSSSKKSSSSKKSSSSSSSSSSSS',
125 'SSSSSSSMMSSSSSSSSSSSSSSS',
126 'SSSSSSSSSSSSSSSSSSSSSSSS',
127 'SSSSSSSSSSSSSSSSSSSSSSSS',
128 'DSSSSSSSSSSSSSSSSSSSSSSD',
129 '.DDDDDDDDDDDDDDDDDDDDDD.',
130 '........................',
131 ],
132 1,
133)
134
135const TRUTYI: Skin = {
136 id: 'trutyi',
137 label: 'Trutyi, a zselés slime',
138 aliases: ['slime', 'zselé', 'zsele', 'nyalka', 'nyálka'],
139 palette: {
140 S: [0x6f, 0xd6, 0xa0], // jelly
141 D: [0x3f, 0xa4, 0x74], // darker base
142 H: [0xd8, 0xfb, 0xe8], // gloss
143 K: [0x14, 0x2a, 0x22], // eyes
144 W: [0xff, 0xff, 0xff], // eye highlight
145 M: [0x2a, 0x5c, 0x46], // mouth
146 T: [0xe8, 0x78, 0x87], // tongue
147 O: [0x24, 0x6c, 0x4c], // outline
148 },
149 poses: {
150 awake: SLIME,
151 blink: edits(SLIME, [5, 4, 'SS'], [5, 10, 'SS'], [6, 4, 'SS'], [6, 10, 'SS']),
152 bark: edits(SLIME, [8, 6, 'MTTM']),
153 yawn: edits(SLIME, [5, 4, 'SS'], [5, 10, 'SS'], [6, 4, 'SS'], [6, 10, 'SS'], [8, 6, 'MTTM'], [9, 6, 'MMMM']),
154 // Melted a little: lower and flat.
155 droop: lower(edits(SLIME, [5, 4, 'SS'], [5, 10, 'SS'], [8, 7, 'SS'], [9, 6, 'MMMM']), 1),
156 wag: SLIME_SQUASH,
157 // Looks to the left.
158 sniff: edits(SLIME, [5, 3, 'WKS'], [6, 3, 'KKS'], [7, 3, 'KKS'], [5, 9, 'WKS'], [6, 9, 'KKS'], [7, 9, 'KKS']),
159 // Eyes narrowed, a frown.
160 growl: edits(SLIME, [5, 4, 'KK'], [5, 10, 'KK'], [6, 4, 'SK'], [6, 10, 'KS'], [8, 6, 'MMMM']),
161 // Heavy lids over the eyes, a flat mouth.
162 tired: edits(SLIME, [5, 4, 'OO'], [5, 10, 'OO'], [8, 7, 'MM']),
163 },
164 voice: {
165 name: 'Trutyi',
166 bark: 'Blub!',
167 growl: 'Bugyborr!',
168 happy: 'Zöld a teszt, rezgek!',
169 pet: 'Ragacsos köszi!',
170 sniff: 'Bugyog valami!',
171 snore: 'Blub... blub',
172 sound: 'sounds/chime.wav',
173 alt: 'Trutyi, a zselés slime',
174 },
175 labelEn: 'Trutyi, the jelly slime',
176 voiceEn: {
177 name: 'Trutyi',
178 bark: 'Blub!',
179 growl: 'Bubble-grr!',
180 happy: 'Tests are green, wobbling!',
181 pet: 'Sticky thanks!',
182 sniff: 'Something is bubbling!',
183 snore: 'Blub... blub',
184 sound: 'sounds/chime.wav',
185 alt: 'Trutyi, the jelly slime',
186 },
187}
188
189// Each figure's own sound, relative to the mod root (sounds/CREDITS.md).
190const SOUNDS: Record<string, string> = {
191 zsemle: 'sounds/bark.wav',
192 cirmi: 'sounds/meow.wav',
193 trutyi: 'sounds/blub.wav',
194 kapocs: 'sounds/ding.wav',
195 pingvin: 'sounds/penguin.wav',
196 teknos: 'sounds/hum.wav',
197 horcsog: 'sounds/squeak.wav',
198 bagoly: 'sounds/hoot.wav',
199 rubik: 'sounds/click.wav',
200 gumikacsa: 'sounds/quack.wav',
201 bogre: 'sounds/clink.wav',
202 kaktusz: 'sounds/boing.wav',
203 sarkany: 'sounds/roar.wav',
204 szellem: 'sounds/ooo.wav',
205 robot: 'sounds/beep.wav',
206}
207
208const withSound = (skin: Skin): Skin => {
209 const sound = SOUNDS[skin.id]
210 return sound === undefined ? skin : { ...skin, voice: { ...skin.voice, sound }, voiceEn: { ...skin.voiceEn, sound } }
211}
212
213export const SKINS: Readonly<Record<SkinId, Skin>> = Object.fromEntries(
214 [ZSEMLE, CIRMI, TRUTYI, KAPOCS, PINGVIN, TEKNOS, HORCSOG, BAGOLY, RUBIK, GUMIKACSA, BOGRE, KAKTUSZ, SARKANY, SZELLEM, ROBOT, ...EXTRA_SKINS]
215 .map(withSound)
216 .map(skin => [skin.id, skin]),
217)
218
219export const SKIN_IDS: readonly SkinId[] = Object.keys(SKINS)
220
221export const DEFAULT_SKIN: SkinId = 'zsemle'
222
223/** The skin by id, the default one for an unknown id. */
224export function skinOf(id: SkinId): Skin {
225 return SKINS[id] ?? ZSEMLE
226}
227
228/** Each figure's English word: what an English user types and sees (`/zsemle skin penguin`). */
229const EN_WORDS: Record<string, string> = {
230 zsemle: 'dog',
231 cirmi: 'cat',
232 trutyi: 'slime',
233 kapocs: 'paperclip',
234 pingvin: 'penguin',
235 teknos: 'turtle',
236 horcsog: 'hamster',
237 bagoly: 'owl',
238 rubik: 'cube',
239 gumikacsa: 'duck',
240 bogre: 'mug',
241 kaktusz: 'cactus',
242 sarkany: 'dragon',
243 szellem: 'ghost',
244 robot: 'robot',
245}
246
247/** The word a figure goes by in the current language: its id in Hungarian, its English word in English. */
248export function skinWord(id: SkinId): string {
249 return lang() === 'en' ? (EN_WORDS[id] ?? id) : id
250}
251
252/** The skin's voice in the current language. */
253export function voiceOf(id: SkinId): Voice {
254 return lang() === 'en' ? skinOf(id).voiceEn : skinOf(id).voice
255}
256
257/** The skin's label in the current language. */
258export function labelOf(id: SkinId): string {
259 return lang() === 'en' ? skinOf(id).labelEn : skinOf(id).label
260}
261
262const fold = (s: string) => s.trim().toLowerCase().normalize('NFD').replace(/\p{M}/gu, '')
263
264/** The skin a word names (id, alias or label start), accents and case ignored; null when none. */
265export function findSkin(word: string): SkinId | null {
266 const w = fold(word)
267 if (w === '') return null
268 for (const id of SKIN_IDS) {
269 const skin = skinOf(id)
270 if (fold(id) === w || fold(EN_WORDS[id] ?? '') === w || skin.aliases.some(a => fold(a) === w) || fold(skin.voice.name) === w) return id
271 if (fold(skin.labelEn.split(',')[0] ?? '') === w) return id
272 }
273 return null
274}
275
276/** A stored value as a skin id, falling back to the default. */
277export function asSkin(value: unknown): SkinId {
278 return typeof value === 'string' && (SKIN_IDS as string[]).includes(value) ? (value as SkinId) : DEFAULT_SKIN
279}
280
281/** The skin after `id` in the list, wrapping around. */
282export function nextSkin(id: SkinId): SkinId {
283 const i = SKIN_IDS.indexOf(id)
284 return SKIN_IDS[(i + 1) % SKIN_IDS.length] ?? DEFAULT_SKIN
285}
286hooks/extra-skins.ts 6 lines1// Extra skins: none in this build.
2
3import type { Skin } from './pixels'
4
5export const EXTRA_SKINS: readonly Skin[] = []
6hooks/figures/cirmi.ts 80 lines1import { edits } from '../pixels'
2import type { Skin } from '../pixels'
3
4// ---- Cirmi: a grey tabby kitten, sitting, seen from the front ---------------
5
6const CAT = [
7 '..GG..........GG........',
8 '..GPPGG....GGPPG........',
9 '..GGGGGGSSGGGGGG........',
10 '.GGEWKEGSSGEWKEGG.......',
11 '.GGEKKEGGGGEKKEGG.......',
12 '.SGGGGLLPPLLGGGGS...SS..',
13 '.SGGGLLLLLLLLGGGS...GG..',
14 '..GGGGGGLLGGGGGG....SS..',
15 '....GGLLLLLLGG......GG..',
16 '...SGGLLLLLLGGS.....SS..',
17 '..GSGLLLLLLLLGSG...GG...',
18 '..GGGLLLOOLLLGGGGGGG....',
19]
20
21// Eyes shut in a soft curve: blink, sleep and yawn.
22const SHUT: [number, number, string][] = [[3, 3, 'GGGG'], [4, 3, 'OOOO'], [3, 11, 'GGGG'], [4, 11, 'OOOO']]
23
24export const CIRMI: Skin = {
25 id: 'cirmi',
26 label: 'Cirmi, a szürke cirmos cica',
27 aliases: ['macska', 'cica', 'cat', 'cirmos', 'kitty', 'tabby'],
28 palette: {
29 G: [0xa7, 0xa9, 0xb0], // grey coat
30 S: [0x6e, 0x70, 0x78], // tabby stripes
31 L: [0xf2, 0xf0, 0xea], // muzzle, chest, paws
32 E: [0x86, 0xcc, 0x58], // green eyes
33 K: [0x1c, 0x1c, 0x22], // pupils
34 P: [0xf0, 0x9a, 0xa8], // nose, inner ear
35 W: [0xff, 0xff, 0xff], // eye highlight, fangs
36 T: [0xe8, 0x78, 0x87], // tongue
37 O: [0x3c, 0x3e, 0x46], // outline, lids, mouth
38 },
39 poses: {
40 awake: CAT,
41 blink: edits(CAT, ...SHUT),
42 // Mouth open: "Miau!"
43 bark: edits(CAT, [6, 7, 'OTTO'], [7, 8, 'TT']),
44 yawn: edits(CAT, ...SHUT, [6, 6, 'OTTTTO'], [7, 7, 'OTTO']),
45 // Ears flat to the sides, looking down: sad after a failure.
46 droop: edits(CAT, [0, 0, '........................'], [1, 0, '.GGGG........GGGG.......'], [2, 1, 'P'], [2, 16, 'P'], [3, 3, 'EEEE'], [4, 3, 'EKKE'], [3, 11, 'EEEE'], [4, 11, 'EKKE']),
47 // Tail flicks up.
48 wag: edits(CAT, [3, 20, 'SS'], [4, 20, 'GG']),
49 // A glance to the left.
50 sniff: edits(CAT, [3, 3, 'WKEE'], [4, 3, 'KKEE'], [3, 11, 'WKEE'], [4, 11, 'KKEE']),
51 // Hiss: ears back, angry brows, fangs.
52 growl: edits(CAT, [0, 0, '........................'], [1, 0, '.GPGG........GGPG.......'], [3, 3, 'EEOO'], [3, 11, 'OOEE'], [6, 6, 'OWTTWO'], [7, 8, 'OO']),
53 // Heavy lids, ears lowered: the limit is running out.
54 tired: edits(CAT, [0, 0, '........................'], [3, 3, 'SSSS'], [4, 3, 'EKKE'], [3, 11, 'SSSS'], [4, 11, 'EKKE']),
55 },
56 voice: {
57 name: 'Cirmi',
58 bark: 'Miau!',
59 growl: 'Fffff!',
60 happy: 'Zöld a teszt, dorombolok!',
61 pet: 'Dorombolok, köszi!',
62 sniff: 'Hmm, gyanús!',
63 snore: 'Zzz',
64 sound: 'sounds/chime.wav',
65 alt: 'Cirmi, a szürke cirmos cica',
66 },
67 labelEn: 'Cirmi, the grey tabby cat',
68 voiceEn: {
69 name: 'Cirmi',
70 bark: 'Meow!',
71 growl: 'Hiss!',
72 happy: 'Tests are green, purring!',
73 pet: 'Purr, thanks!',
74 sniff: 'Hmm, suspicious!',
75 snore: 'Zzz',
76 sound: 'sounds/chime.wav',
77 alt: 'Cirmi, the grey tabby cat',
78 },
79}
80hooks/figures/kapocs.ts 98 lines1import { edits } from '../pixels'
2import type { Skin } from '../pixels'
3
4// ---- Kapocs: a silver paperclip with a little face in its top loop ----------
5
6// The wire: the outer run on the left, the inner run that ends halfway down,
7// the long run on the right whose top end is free. A round cream face peeks
8// out of the upper loop.
9const CLIP = [
10 '...HHHHHHHHHH...........',
11 '..HM........HM..........',
12 '..HM.FFFFFF.HM...HM.....',
13 '..HMFFKFFKFFHM...HM.....',
14 '..HMFFKFFKFFHM...HM.....',
15 '..HMFPFOOFPFHM...HM.....',
16 '..HM.FFFFFF.HM...HM.....',
17 '..HM........HM...HM.....',
18 '..HM.............HM.....',
19 '..HM.............HM.....',
20 '..HM.............HM.....',
21 '...MMMMMMMMMMMMMMM......',
22]
23
24/**
25 * The upper loop and the face (columns 0 to 15, rows 0 to 6) moved `left`
26 * columns to the left and `down` rows lower: a lean or a slump. The long
27 * right-hand run stays where it is.
28 */
29function bend(rows: readonly string[], left: number, down: number): string[] {
30 const out = [...rows]
31 for (let y = 0; y <= 6 + down; y++) {
32 const src = rows[y - down]
33 const head = src === undefined ? '.'.repeat(16) : src.slice(left, 16) + '.'.repeat(left)
34 out[y] = head + (rows[y] ?? '').slice(16)
35 }
36 return out
37}
38
39const SHUT: [number, number, string][] = [[3, 6, 'F'], [3, 9, 'F'], [4, 5, 'OO'], [4, 9, 'OO']]
40const SMALL_MOUTH: [number, number, string][] = [[6, 7, 'TT']]
41const BIG_MOUTH: [number, number, string][] = [[5, 6, 'OTTO'], [6, 7, 'TT']]
42
43export const KAPOCS: Skin = {
44 id: 'kapocs',
45 label: 'Kapocs, a segítőkész gemkapocs',
46 aliases: ['gemkapocs', 'gémkapocs', 'paperclip', 'clip', 'paper clip'],
47 palette: {
48 H: [0xe6, 0xea, 0xf0], // wire, lit side
49 M: [0xa8, 0xb0, 0xbe], // wire, shaded side
50 F: [0xff, 0xf6, 0xe8], // face
51 K: [0x1c, 0x1e, 0x26], // eyes
52 W: [0xff, 0xff, 0xff], // teeth
53 P: [0xf4, 0xa6, 0xb4], // blush
54 T: [0xe8, 0x78, 0x87], // tongue
55 O: [0x48, 0x50, 0x5e], // outline, mouth, lids
56 },
57 poses: {
58 awake: CLIP,
59 blink: edits(CLIP, ...SHUT),
60 // Mouth open: "Kling!"
61 bark: edits(CLIP, ...SMALL_MOUTH),
62 yawn: edits(CLIP, ...SHUT, ...BIG_MOUTH),
63 // The top bends over and sags, eyes cast down.
64 droop: bend(edits(CLIP, [3, 6, 'F'], [3, 9, 'F']), 1, 1),
65 // A happy sway to the left, mouth open.
66 wag: bend(edits(CLIP, ...SMALL_MOUTH), 1, 0),
67 // Eyes dart to the left.
68 sniff: edits(CLIP, [3, 5, 'KF'], [4, 5, 'KF'], [3, 8, 'KF'], [4, 8, 'KF']),
69 // Slanted cross eyes, a tight mouth.
70 growl: edits(CLIP, [3, 5, 'KF'], [4, 5, 'FK'], [3, 9, 'FK'], [4, 9, 'KF'], [5, 6, 'OOOO']),
71 // Heavy lids, a flat mouth, slumped a row lower.
72 tired: bend(edits(CLIP, [3, 5, 'OO'], [3, 9, 'OO']), 0, 1),
73 },
74 voice: {
75 name: 'Kapocs',
76 bark: 'Kling!',
77 growl: 'Hé, ezt ne!',
78 happy: 'Zöld a teszt, ugrálok!',
79 pet: 'Hihi, csiklandoz!',
80 sniff: 'Úgy látom, deployoltál!',
81 snore: 'Zzz',
82 sound: 'sounds/chime.wav',
83 alt: 'Kapocs, a gemkapocs',
84 },
85 labelEn: 'Kapocs, the helpful paperclip',
86 voiceEn: {
87 name: 'Kapocs',
88 bark: 'Ding!',
89 growl: 'Hey, not that!',
90 happy: 'Tests are green, hopping!',
91 pet: 'Hehe, that tickles!',
92 sniff: 'Looks like you deployed!',
93 snore: 'Zzz',
94 sound: 'sounds/chime.wav',
95 alt: 'Kapocs, the helpful paperclip',
96 },
97}
98hooks/figures/pingvin.ts 97 lines1import { edits } from '../pixels'
2import type { Skin } from '../pixels'
3
4// ---- Pingu: a cute penguin, standing, facing 3/4 left -----------------------
5
6const PENGUIN = [
7 '.......KKKKKKK..........',
8 '.....KKKKKKKKKKK........',
9 '....KWWWKKWWWKKKK.......',
10 '...KWWEEWWEEWWKKKK......',
11 '...KWWEEWWEEWWKKKKK.....',
12 '..YYYYYYWWPPWKKKKKK.....',
13 '...YYYWWWWWWWWKKKKKK....',
14 '...KWWWWWWWWWWKKKOKKK...',
15 '...KWWWWWWWWWWKKKKOKKK..',
16 '...KWWWWWWWWWWKKKKKOKK..',
17 '....KWWWWWWWWKKKKK......',
18 '...YYYYY...YYYYY........',
19]
20
21/** Sunk by n rows: the head drops into the shoulders, belly rows go, feet stay. */
22function squash(rows: readonly string[], n: number): string[] {
23 return [...Array.from({ length: n }, () => '.'.repeat(24)), ...rows.slice(0, 10 - n), rows[10] ?? '', rows[11] ?? '']
24}
25
26/** Each row moved right by the given number of columns (a lean). */
27function lean(rows: readonly string[], shifts: readonly number[]): string[] {
28 return rows.map((r, i) => {
29 const n = shifts[i] ?? 0
30 return n > 0 ? ('.'.repeat(n) + r).slice(0, 24) : r
31 })
32}
33
34// Totter: the body leans right, the left foot lifts off the ground.
35const WADDLE = edits(
36 lean(PENGUIN, [2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 0]),
37 [11, 0, '............YYYYY.......'],
38 [10, 2, 'YYY'],
39)
40
41export const PINGVIN: Skin = {
42 id: 'pingvin',
43 label: 'Pingu, a totyogó pingvin',
44 labelEn: 'Pingu, the waddling penguin',
45 aliases: ['pingvin', 'penguin', 'pingu', 'tux'],
46 palette: {
47 K: [0x34, 0x40, 0x5c], // back and head
48 W: [0xf6, 0xf8, 0xfc], // face and belly
49 Y: [0xf8, 0x9a, 0x1c], // beak and feet
50 E: [0x10, 0x12, 0x18], // eyes
51 P: [0xf2, 0x9a, 0xa8], // blush
52 T: [0xd9, 0x4f, 0x5c], // open beak
53 G: [0x9a, 0xa4, 0xb8], // heavy lids
54 B: [0x8c, 0xc8, 0xf0], // tear
55 O: [0x1a, 0x1f, 0x2c], // outline
56 },
57 poses: {
58 awake: PENGUIN,
59 blink: edits(PENGUIN, [3, 6, 'WW'], [3, 10, 'WW']),
60 bark: edits(PENGUIN, [6, 3, 'TTT'], [7, 3, 'YYY']),
61 yawn: edits(PENGUIN, [3, 6, 'WW'], [3, 10, 'WW'], [6, 3, 'TTTT'], [7, 3, 'TTTT'], [8, 4, 'YYY']),
62 // Beak and eyes down, a tear, the flipper hangs limp.
63 droop: edits(PENGUIN, [3, 6, 'WW'], [3, 10, 'WW'], [5, 2, '.YYYYY'], [6, 3, 'YYYY'], [5, 10, 'BW'], [6, 10, 'B'],
64 [7, 14, 'KKKKOKK.'], [8, 14, 'KKKKOKK.'], [9, 14, 'KKKKOKK.'], [10, 18, 'OKK']),
65 wag: WADDLE,
66 // Eyes dart to the left.
67 sniff: edits(PENGUIN, [3, 6, 'EW'], [4, 6, 'EW'], [3, 10, 'EW'], [4, 10, 'EW']),
68 // Angry brows, beak open, flipper raised.
69 growl: edits(PENGUIN, [2, 5, 'OO'], [3, 7, 'W'], [2, 11, 'OO'], [3, 10, 'W'], [6, 3, 'TTT'], [7, 3, 'YYY'],
70 [5, 19, 'KK'], [6, 20, 'KK'], [7, 17, 'KK...'], [8, 18, 'K....'], [9, 19, 'K...']),
71 // Heavy lids, sunk into the shoulders.
72 tired: edits(squash(PENGUIN, 2), [5, 6, 'GG'], [5, 10, 'GG']),
73 },
74 voice: {
75 name: 'Pingu',
76 bark: 'Kvá-kvá!',
77 growl: 'Hé, ezt ne!',
78 happy: 'Zöld a teszt, totyogok!',
79 pet: 'Hűs köszi!',
80 sniff: 'Csúszik a jég!',
81 snore: 'Zzz',
82 sound: 'sounds/chime.wav',
83 alt: 'Pingu, a totyogó pingvin',
84 },
85 voiceEn: {
86 name: 'Pingu',
87 bark: 'Squawk!',
88 growl: 'Hey, not that!',
89 happy: 'Tests are green, waddling!',
90 pet: 'Cool, thanks!',
91 sniff: 'Slippery ice ahead!',
92 snore: 'Zzz',
93 sound: 'sounds/chime.wav',
94 alt: 'Pingu, the waddling penguin',
95 },
96}
97hooks/figures/teknos.ts 132 lines1import { edits, lower } from '../pixels'
2import type { Skin } from '../pixels'
3
4// ---- Teki: a friendly turtle, seen from the side, facing left ---------------
5
6const TURTLE = [
7 '........................',
8 '........................',
9 '..GGGGG.................',
10 '.GGGEWGG....HHLLHHH.....',
11 '.GGGEEGGG.HHDHLLHDHHH...',
12 '.GOOPPGG.HHHDHLLHDHHHH..',
13 '..GGGGG.DDDDDDDDDDDDDDD.',
14 '.....GGGHHDHHHDHHHDHHHH.',
15 '......GBBBBBBBBBBBBBBBB.',
16 '........GGGG....GGGG.GG.',
17 '........GGGG....GGGG....',
18 '.......GGGGG....GGGGG...',
19]
20
21const EMPTY = '........................'
22
23/** The sprite's letters written over the grid with its top-left corner at (top, left); '.' keeps what is below. */
24function stamp(rows: readonly string[], sprite: readonly string[], top: number, left: number): string[] {
25 return rows.map((r, y) => {
26 const line = sprite[y - top]
27 if (line === undefined) return r
28 return [...r].map((c, x) => (x >= left && line[x - left] !== undefined && line[x - left] !== '.' ? line[x - left] : c)).join('')
29 })
30}
31
32/** Only the given letters kept, everything else transparent. */
33function keep(rows: readonly string[], letters: string): string[] {
34 return rows.map(r => [...r].map(c => (letters.includes(c) ? c : '.')).join(''))
35}
36
37// The body with the head taken off (the neck stays), to put a head back at another height.
38const BODY = edits(TURTLE, [2, 0, '.........'], [3, 0, '.........'], [4, 0, '.........'], [5, 0, '.........'], [6, 0, '........'])
39
40// Heads, 5 rows by 9 columns; the base drawing has its head at row 2, column 0.
41const HEAD_SAD = [
42 '..GGGGG..',
43 '.GGGGGGG.',
44 '.GGGEEGGG',
45 '.GGGAGGG.',
46 '..GGGGG..',
47]
48
49// Head hung low, eye cast down, a tear.
50const DROOP = stamp(BODY, HEAD_SAD, 4, 0)
51
52const HEAD_TIRED = [
53 '..GGGGGG',
54 '.GGGKKGG',
55 '.GGGEEGG',
56 '..GGGGG.',
57]
58
59// Chin on the ground, heavy lids, the front leg pushed aside.
60const TIRED = stamp(edits(BODY, [9, 8, '.GGGG'], [10, 8, '.GGGG'], [11, 7, '..GGGGG']), HEAD_TIRED, 8, 0)
61
62const HEAD_LOOK = [
63 '..GGGGG..',
64 '.GGGEGGG.',
65 '.GGGEGGGG',
66 '.GOOPPGG.',
67 '..GGGGG..',
68]
69
70// Head raised a little, eye forward: curious.
71const SNIFF = edits(stamp(BODY, HEAD_LOOK, 1, 0), [6, 4, 'GGG'])
72
73// Pulled in: the shell alone rests on the ground, a dark opening in front.
74const HIDDEN = edits(lower(keep(TURTLE, 'HLDB'), 3), [9, 8, 'OO'], [10, 8, 'OW'])
75
76export const TEKNOS: Skin = {
77 id: 'teknos',
78 label: 'Teki, a barátságos teknős',
79 labelEn: 'Teki, the friendly turtle',
80 aliases: ['teknős', 'teknos', 'teki', 'turtle', 'tortoise'],
81 palette: {
82 G: [0x8c, 0xcc, 0x5a], // head and legs
83 H: [0xc9, 0x8a, 0x3c], // shell plates
84 L: [0xe6, 0xb4, 0x62], // plate shine
85 D: [0x6e, 0x46, 0x1e], // shell lines
86 B: [0xee, 0xd8, 0x96], // shell rim
87 E: [0x16, 0x18, 0x12], // eye
88 W: [0xff, 0xff, 0xff], // eye highlight
89 K: [0x5a, 0x96, 0x3a], // heavy lid
90 A: [0x8c, 0xc8, 0xf0], // tear
91 P: [0xf2, 0x9a, 0xa0], // cheek
92 T: [0xd9, 0x4f, 0x5c], // open mouth
93 O: [0x3a, 0x2a, 0x16], // outline
94 },
95 poses: {
96 awake: TURTLE,
97 blink: edits(TURTLE, [3, 4, 'GG']),
98 bark: edits(TURTLE, [5, 1, 'TTO'], [6, 1, 'G']),
99 yawn: edits(TURTLE, [3, 4, 'GG'], [5, 1, 'TTT'], [6, 1, 'TTO'], [7, 2, 'GGG']),
100 droop: DROOP,
101 // A happy hop.
102 wag: [...TURTLE.slice(1), EMPTY],
103 sniff: SNIFF,
104 // Brow down to the eye, mouth open.
105 growl: edits(TURTLE, [2, 5, 'OO'], [3, 4, 'GE'], [5, 1, 'TTO'], [6, 1, 'G']),
106 tired: TIRED,
107 },
108 longTurn: HIDDEN,
109 voice: {
110 name: 'Teki',
111 bark: 'Megjöttem!',
112 growl: 'Lassan a testtel!',
113 happy: 'Zöld a teszt: lassan, de biztosan!',
114 pet: 'Köszi, jólesett!',
115 sniff: 'Csak óvatosan!',
116 snore: 'Zzz',
117 sound: 'sounds/chime.wav',
118 alt: 'Teki, a barátságos teknős',
119 },
120 voiceEn: {
121 name: 'Teki',
122 bark: 'Made it!',
123 growl: 'Easy there!',
124 happy: 'Tests are green: slow and steady!',
125 pet: 'Thanks, that was nice!',
126 sniff: 'Careful now!',
127 snore: 'Zzz',
128 sound: 'sounds/chime.wav',
129 alt: 'Teki, the friendly turtle',
130 },
131}
132hooks/figures/horcsog.ts 168 lines1import { edits } from '../pixels'
2import type { Skin } from '../pixels'
3
4// ---- Pufi: a round golden hamster, facing the viewer ------------------------
5
6const HAMSTER = [
7 '........................',
8 '.....DPD..........DPD...',
9 '.....GGGGGGGGGGGGGGGG...',
10 '....GGGGEWGGGGGGEWGGGG..',
11 '...GGGGGEEGGGGGGEEGGGGG.',
12 '...GGWWWWWWWPPWWWWWWWGG.',
13 '...GWWPPWWWOWWOWWWPPWWG.',
14 '...GWWWWWWWWOOWWWWWWWWG.',
15 '...GGWWWWWWWWWWWWWWWWGG.',
16 '...GGWWWWWWWWWWWWWWWWGG.',
17 '....GGGWWWWWWWWWWWWGGG..',
18 '......PPPGGGGGGGGPPP....',
19]
20
21const EMPTY = '........................'
22
23/** Sunk by n rows: the middle rows go, the bottom two stay (a slump). */
24function squash(rows: readonly string[], n: number): string[] {
25 return [...Array.from({ length: n }, () => EMPTY), ...rows.slice(0, 10 - n), rows[10] ?? '', rows[11] ?? '']
26}
27
28/** The sprite's letters written over the grid at (top, left); '.' keeps what is below. */
29function stamp(rows: readonly string[], sprite: readonly string[], top: number, left: number): string[] {
30 return rows.map((r, y) => {
31 const line = sprite[y - top]
32 if (line === undefined) return r
33 return [...r].map((c, x) => (x >= left && line[x - left] !== undefined && line[x - left] !== '.' ? line[x - left] : c)).join('')
34 })
35}
36
37// ---- The exercise wheel: a rim, a disk, spokes that turn, a stand ----------
38
39const WHEEL = [
40 '.......MMMMMMMMMM.......',
41 '....MMMFFFFFFFFFFMMM....',
42 '...MMFFFFFFFFFFFFFFMM...',
43 '..MMFFFFFFFFFFFFFFFFMM..',
44 '.MMFFFFFFFFFFFFFFFFFFMM.',
45 '.MMFFFFFFFFMMFFFFFFFFMM.',
46 '.MMFFFFFFFFFFFFFFFFFFMM.',
47 '..MMFFFFFFFFFFFFFFFFMM..',
48 '...MMFFFFFFFFFFFFFFMM...',
49 '....MMMFFFFFFFFFFMMM....',
50 '.......MMMMMMMMMM.......',
51 '.....MMM........MMM.....',
52]
53
54// Spokes as a plus, then as a cross: alternating, the wheel turns.
55const PLUS = edits(
56 WHEEL,
57 [1, 11, 'SS'],
58 [2, 11, 'SS'],
59 [3, 11, 'SS'],
60 [4, 11, 'SS'],
61 [5, 3, 'SSSSSSSS'],
62 [5, 13, 'SSSSSSSS'],
63 [6, 11, 'SS'],
64 [7, 11, 'SS'],
65 [8, 11, 'SS'],
66 [9, 11, 'SS'],
67)
68
69const CROSS = edits(
70 WHEEL,
71 [1, 7, 'SS'],
72 [1, 15, 'SS'],
73 [2, 7, 'SS'],
74 [2, 15, 'SS'],
75 [3, 9, 'S'],
76 [3, 14, 'S'],
77 [4, 10, 'S'],
78 [4, 13, 'S'],
79 [6, 10, 'S'],
80 [6, 13, 'S'],
81 [7, 8, 'SS'],
82 [7, 14, 'SS'],
83 [8, 6, 'SS'],
84 [8, 16, 'SS'],
85)
86
87// The hamster from the side, running to the left: legs stretched, then tucked.
88const RUN_A = [
89 '.....DD.......',
90 '...GGGGGGGG...',
91 '..GEGGGGGGGGG.',
92 '.PGGGGGGGGGGGG',
93 '..WWWWWGGGGGG.',
94 '....WWWWWGG...',
95 '.PP........PP.',
96]
97
98const RUN_B = [
99 '.....DD.......',
100 '...GGGGGGGG...',
101 '..GEGGGGGGGGG.',
102 '.PGGGGGGGGGGGG',
103 '..WWWWWGGGGGG.',
104 '....WWWWWGG...',
105 '...PP..PP.....',
106]
107
108const WORK = [stamp(PLUS, RUN_A, 3, 5), stamp(CROSS, RUN_B, 3, 5)]
109
110export const HORCSOG: Skin = {
111 id: 'horcsog',
112 label: 'Pufi, a pufók hörcsög',
113 labelEn: 'Pufi, the chubby hamster',
114 aliases: ['hörcsög', 'horcsog', 'pufi', 'hamster'],
115 palette: {
116 G: [0xf0, 0xa8, 0x4c], // golden fur
117 D: [0xc8, 0x7a, 0x2c], // ear rim
118 W: [0xff, 0xf6, 0xe8], // cheeks and belly
119 P: [0xf4, 0x9a, 0xa8], // nose, inner ear, paws, blush
120 E: [0x22, 0x16, 0x10], // eyes
121 T: [0xd9, 0x4f, 0x5c], // open mouth
122 A: [0x8c, 0xc8, 0xf0], // tear
123 M: [0x70, 0x7c, 0x8c], // wheel rim and stand
124 S: [0xa8, 0xb4, 0xc2], // spokes
125 F: [0xe2, 0xe8, 0xef], // wheel disk
126 O: [0x7a, 0x48, 0x1e], // outline
127 },
128 poses: {
129 awake: HAMSTER,
130 blink: edits(HAMSTER, [3, 8, 'GG'], [3, 16, 'GG']),
131 bark: edits(HAMSTER, [7, 11, 'OTTO'], [8, 12, 'OO']),
132 yawn: edits(HAMSTER, [3, 8, 'GG'], [3, 16, 'GG'], [6, 11, 'OTTO'], [7, 11, 'OTTO'], [8, 11, 'OOOO']),
133 // Ears flat, a frown and a tear.
134 droop: edits(HAMSTER, [1, 0, EMPTY], [2, 3, 'DD'], [2, 21, 'DD'], [3, 9, 'E'], [3, 17, 'E'], [5, 8, 'A'], [6, 11, 'WOOW'], [7, 11, 'OWWO']),
135 // A happy hop, eyes squeezed shut.
136 wag: [...edits(HAMSTER, [3, 8, 'EE'], [4, 8, 'GG'], [3, 16, 'EE'], [4, 16, 'GG']).slice(1), EMPTY],
137 // Eyes to the left, whiskers twitch.
138 sniff: edits(HAMSTER, [3, 8, 'EG'], [4, 8, 'EG'], [3, 16, 'EG'], [4, 16, 'EG'], [5, 1, 'OO'], [7, 1, 'OO']),
139 // Slanted eyes, red cheeks, teeth bared.
140 growl: edits(HAMSTER, [3, 8, 'EG'], [3, 16, 'GE'], [6, 6, 'TT'], [6, 18, 'TT'], [6, 11, 'OOOO'], [7, 11, 'OWWO']),
141 // Slumped, heavy lids.
142 tired: edits(squash(HAMSTER, 2), [5, 8, 'DD'], [5, 16, 'DD']),
143 },
144 workFrames: WORK,
145 voice: {
146 name: 'Pufi',
147 bark: 'Cin-cin!',
148 growl: 'Grr, ezt ne!',
149 happy: 'Zöld a teszt, pörög a kerék!',
150 pet: 'Puha köszi!',
151 sniff: 'Mi ez az illat?',
152 snore: 'Zzz',
153 sound: 'sounds/chime.wav',
154 alt: 'Pufi, a pufók hörcsög',
155 },
156 voiceEn: {
157 name: 'Pufi',
158 bark: 'Squeak!',
159 growl: 'Grr, not that!',
160 happy: 'Tests are green, the wheel is spinning!',
161 pet: 'Fluffy thanks!',
162 sniff: 'What is that smell?',
163 snore: 'Zzz',
164 sound: 'sounds/chime.wav',
165 alt: 'Pufi, the chubby hamster',
166 },
167}
168hooks/figures/bagoly.ts 106 lines1import { edits } from '../pixels'
2import type { Skin } from '../pixels'
3
4// ---- Huhu: a brown owl facing the viewer, perched on a branch ---------------
5
6const OWL = [
7 '....DD............DD....',
8 '....DDBBBBBBBBBBBBDD....',
9 '...BBYYYYBBBBBBYYYYBB...',
10 '..BBYYWKYYBBBBYYWKYYBB..',
11 '..BBYYKKYYAAAAYYKKYYBB..',
12 '..BBBYYYYBBAABBYYYYBBB..',
13 '..DDDDLLLLLLLLLLLLDDDD..',
14 '.DDDDLLDLDLLLLDLDLLDDDD.',
15 '.DDDDLLLDLLLLLLDLLLDDDD.',
16 '..DDDLLLLLLLLLLLLLLDDD..',
17 '....DDLAALLLLLLAALDD....',
18 '.GGGGGGGGGGGGGGGGGGGGGG.',
19]
20
21// Eyes shut: brown lids with a dark lash line.
22const SHUT: [number, number, string][] = [
23 [2, 5, 'BBBB'], [2, 15, 'BBBB'],
24 [3, 4, 'BBBBBB'], [3, 14, 'BBBBBB'],
25 [4, 4, 'OOOOOO'], [4, 14, 'OOOOOO'],
26]
27
28export const BAGOLY: Skin = {
29 id: 'bagoly',
30 label: 'Huhu, a barna bagoly',
31 labelEn: 'Huhu, the brown owl',
32 aliases: ['bagoly', 'owl'],
33 palette: {
34 B: [0x9a, 0x66, 0x3c], // brown head
35 D: [0x6c, 0x44, 0x26], // darker wings, tufts, belly marks
36 L: [0xee, 0xdc, 0xb8], // light belly
37 Y: [0xf6, 0xc8, 0x32], // eye rings
38 K: [0x1a, 0x12, 0x0c], // pupils
39 W: [0xff, 0xff, 0xff], // eye highlight
40 A: [0xec, 0x8c, 0x2c], // beak, talons
41 G: [0x7a, 0x8a, 0x3a], // mossy branch
42 O: [0x3a, 0x24, 0x14], // outline
43 },
44 poses: {
45 awake: OWL,
46 blink: edits(OWL, ...SHUT),
47 // Beak open: "Huhú!"
48 bark: edits(OWL, [5, 10, 'KKKK'], [6, 11, 'AA']),
49 yawn: edits(OWL, ...SHUT, [5, 10, 'KKKK'], [6, 10, 'KKKK'], [7, 11, 'AA']),
50 // Tufts flat, lids sagging outward, looking down.
51 droop: edits(
52 OWL,
53 [0, 0, '........................'],
54 [1, 2, 'DDBBBBBBBBBBBBBBBBDD'],
55 [2, 5, 'BBBB'], [2, 15, 'BBBB'],
56 [3, 4, 'OOYYYY'], [3, 14, 'YYYYOO'],
57 [5, 5, 'YKKY'], [5, 15, 'YKKY'],
58 ),
59 // A little hop off the branch, talons dangling.
60 wag: [...OWL.slice(1, 11), '.......AA......AA.......', OWL[11] ?? ''],
61 // Looks to the left.
62 sniff: edits(OWL, [3, 4, 'WKYYYY'], [4, 4, 'KKYYYY'], [3, 14, 'WKYYYY'], [4, 14, 'KKYYYY']),
63 // Angry brows slanting to the beak, tufts up, beak snapping.
64 growl: edits(
65 OWL,
66 [0, 3, 'DD'], [0, 19, 'DD'],
67 [2, 4, 'OO'], [3, 6, 'OOOO'],
68 [2, 18, 'OO'], [3, 14, 'OOOO'],
69 [5, 10, 'KKKK'],
70 ),
71 // Heavy half-closed lids, tufts sagging.
72 tired: edits(
73 OWL,
74 [0, 4, '..'], [0, 18, '..'],
75 [1, 3, 'D'], [1, 20, 'D'],
76 [2, 5, 'BBBB'], [2, 15, 'BBBB'],
77 [3, 4, 'OOOOOO'], [3, 14, 'OOOOOO'],
78 ),
79 },
80 nightOwl: true,
81 voice: {
82 name: 'Huhu',
83 bark: 'Huhú!',
84 growl: 'Kssss!',
85 happy: 'Zöld a teszt, csapkodok!',
86 pet: 'Puha tollak, köszi!',
87 sniff: 'Huhú, látom ám!',
88 snore: 'Huhú... zzz',
89 sound: 'sounds/chime.wav',
90 alt: 'Huhu, a barna bagoly egy ágon',
91 night: 'Elmúlt 22 óra. Ideje lezárni a napot: mentsd a Folytatás innen blokkot, és pihenj.',
92 },
93 voiceEn: {
94 name: 'Huhu',
95 bark: 'Hoot!',
96 growl: 'Hsss!',
97 happy: 'Tests are green, flapping!',
98 pet: 'Soft feathers, thanks!',
99 sniff: 'Hoo, I see it!',
100 snore: 'Hoo... zzz',
101 sound: 'sounds/chime.wav',
102 alt: 'Huhu, the brown owl on a branch',
103 night: 'It is past 10 pm. Time to close the day: save the Continue from here block and get some rest.',
104 },
105}
106