SLOPSHOPPER

zsemle

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

newpanebandguardcommandtoast
v0.6.1no licenseupdated 2026-10-07Parais4/parais-mods/plugins/zsemle
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · zsemle
│ ┃ zsemle-skins ✕ › fix the failing auth test and add an audit log call │ ┃ Pick a companion: click the figure or its │ ┃ name. From a command: /zsemle skin <name>, ⏺ Read(src/auth.ts) │ ┃ in /config: zsemle.skin. ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ▗▄▀▀▀▀▄ ▗▀▄▖ ▗▄▀▖ ▄▀▀▀▙▖ ⎿ Added 2 lines, removed 1 line │ ┃ ▄▘▛▀██▌█▀▖ ▐▙▘▙▀▘▙▘▌ ▗▀▛▜███▙▙ ⏺ Bash(bun test) │ ┃ ▐▀█▀███▚█▛▘ ▌▌▘▌▀▌▘▌▌ ▄ ▗▘█▀██████▙ ⎿ 3 pass, 1 fail │ ┃ ▀▀▀█████▀▄ ▄ ▌█▛█▀█▜█▌▐▀▌ ▟█▘██▘█████ │ ┃ ▐█▜▜█████▀█▌ ▝▀█▀█▀█▀▘▐▀▌ ▗▘█▀▛▜▀█████ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ▟█▌▛████▀▀▀ ▗▘▛███▜▙▄▟▛▘ ▐▜██████████ │ ┃ ▝▀▀▀▀▜▀▀▛ ▝▀▀▀▀▀▀▀▀▀▘ ▜▀▀▀▀▀▀▀▀▀▀ ✻ Worked for 42s · done 4:20 PM │ ┃ [ [Zsemle] ] Cirmi Trutyi │ ┃ › /zsemle │ ⎿ zsemle: Oops, a command failed. │ ⎿ zsemle: 5h 31% · ctx 49% │ ⎿ zsemle: │ ⎿ zsemle: /zsemle ok got it (hides the bubble) │ ⎿ zsemle: /zsemle limit every limit, the pace, the context │ ⎿ zsemle: /zsemle skin figure picker pane (also in /confi │ │ ╭────────────────────────────────────╮ │Oops, a command failed. │ │5h 31% · ctx 49% │◀ │ok ♥ mute skin fold │ ╰────────────────────────────────────╯ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
╭────────────────────────────────────╮ │Oops, a command failed. │ │5h 31% · ctx 49% │◀ │ok ♥ mute skin fold │ ╰────────────────────────────────────╯
Pane · zsemle-skins
Pick a companion: click the figure or its name. From a command: /zsemle skin <name>, in /config: zsemle.skin. ▗▄▀▀▀▀▄ ▗▀▄▖ ▗▄▀▖ ▄▀▀▀▙▖ ▟▀▀▀▀▙ ▄▘▛▀██▌█▀▖ ▐▙▘▙▀▘▙▘▌ ▗▀▛▜███▙▙ ▐▌▛▀▀▜▌▌▟▙ ▐▀█▀███▚█▛▘ ▌▌▘▌▀▌▘▌▌ ▄ ▗▘█▀██████▙ ▐▌█▌▌█▌▌▌▌ ▀▀▀█████▀▄ ▄ ▌█▛█▀█▜█▌▐▀▌ ▟█▘██▘█████▙ ▐▌▜▙▘▛▌▌▌▌ ▐█▜▜█████▀█▌ ▝▀█▀█▀█▀▘▐▀▌ ▗▘█▀▛▜▀█████▙▖ ▐▌▛▀▀▀▀▘▌▌ ▟█▌▛████▀▀▀ ▗▘▛███▜▙▄▟▛▘ ▐▜██████████▛▌ ▐▌▙▄▄▄▄▄▌▌ ▝▀▀▀▀▜▀▀▛ ▝▀▀▀▀▀▀▀▀▀▘ ▜▀▀▀▀▀▀▀▀▀▀▛ ▜▀▀▀▀▀▀▀▘ [ [Zsemle] ] Cirmi Trutyi Kapocs
Pane · zsemle-week
0 3 6 9 12 15 18 21 10-03 Fr | | 0 10-04 Sa | | 0 10-05 Su | | 0 10-06 Mo | | 0 10-07 Tu | | 0 10-08 We | | 0 10-09 Th | # | 9 Your busiest hour: 8:00 to 9:00. 9 tool calls in all. (legend: . a little, : some, + a lot, # the most)
README

Zsemle

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.

Zsemle above the prompt

Contents

Figures

All figures

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

All figures in the terminal

namefigureits own trick
doggolden retriever puppy (default)wags its tail, barks when a long turn ends
catgrey tabby catmeows, hisses at a blocked write
slimejelly slimewobbles when happy
paperclippaperclip with a facehops
penguinpenguinwaddles when your tests pass
turtleturtlepulls its head in during turns longer than 3 minutes
hamsterhamsterruns in its wheel while the model works
owlowlafter 22:00 it tells you to close the day
cubeRubik's cube with eyesclicks
duckrubber duckquacks, listens to your bug explanation
mugcoffee mugsteams while the model works, empties as the limit runs low
cactuspotted cactusblooms on green tests, wilts on errors
dragonlittle dragonroars, breathes fire after a deploy
ghostghostfades when you are idle
robotrobotbeeps, 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):

All poses

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

Special frames

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

What it watches

signalwhat Zsemle does
5-hour, weekly and spend limitsheads-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.
paceif the window runs out before it resets at the current pace, it tells you roughly when
resetcheers when a window is full again: the moment its reset time passes, even in an idle session, and only once
fresh dataa 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 errorsexplains rate limit, overload, output token limit, billing, login, unavailable model and server errors in plain words
automatic model switchtells you when the engine fell back to another model
automatic compactiontells you when the context was compacted, warns at 90% before it happens
contexttired at 60%, asks for a status note before /compact at 80%
coston API-key use (no subscription windows), speaks up at 1, 5, 10, 20, 50, 100 and 200 dollars
status line5h 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.

Optional features

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.

featurewhat it does
fatigueLookthe 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
budgetPlannersplits 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
modelAdvicea simple task (rename, format, typo, translate...) on a big model at a high limit: suggests /model sonnet
contextSaverabove 70% context, asks the model for shorter answers and smaller file reads
commitGuardstops 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
loopWatchspeaks up when the model edits the same file a fifth time in one turn
morningBriefthe first prompt of the day carries a short brief: yesterday's day summary and today's budget
lessonSniffwhen the same error comes back in a third turn, suggests recording it as a lesson
daySummarywhen 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.

Install

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.

  1. Add the marketplace and install the plugin, in Claude Code:
   /plugin marketplace add Parais4/parais-mods
   /plugin install zsemle@parais-mods
  1. Start a new session (or run /plugin and reload). Zsemle appears above the prompt on the right.
  2. Optional: open /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.

Commands

EnglishHungarianwhat
/zsemlestatus and help
/zsemle limitevery window, the pace, today's budget, the context and the cost
/zsemle skinthe 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 hetiweekly activity chart
/zsemle summary/zsemle napzaroa summary of this session (small model)
/zsemle features/zsemle kapcsolokthe optional features and their state
/zsemle feature <name> off/on/zsemle kapcsolo <név> ki/beswitch one, e.g. commitGuard
/zsemle stats/zsemle stattoday's stats
/zsemle ok"got it": hides the bubble until there is news (or the bubble's ok button)
/zsemle mute / soundnemit / hangsound off/on
/zsemle petsimia pat (or the ♥ button next to the figure or in the bubble)
/zsemle hide / showelrejt / mutathide 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/onsor ki/bethe limit line under the prompt
/zsemle guard off/onor ki/bethe content guard for this session
/zsemle wakeebresztlet work go on past 95% in this session
/zsemle barkugasstest the sound

Settings

In /config, or in ~/.claude/settings.json under pluginConfigs.zsemle.options:

optiondefaultwhat
languageenen or hu
skinzsemlethe 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)
guardDashesfalsethe content guard also blocks em and en dashes
feature switchestruesee Optional features

Example:

"pluginConfigs": {
  "zsemle": {
    "options": { "language": "hu", "skin": "bogre", "commitGuard": false }
  }
}

Troubleshooting

  • No figure above the prompt. Start a new session after installing or updating; a running session keeps the version it started with. Check /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.
  • Commands are missing (no skin, no limit). The session runs an older version: start a new one.
  • The figure looks broken in the terminal. It uses the quadrant block characters (U+2596 to U+259F) and truecolor; use a font and terminal that support them (Windows Terminal, iTerm2, most modern terminals).
  • No sound on Windows. Zsemle plays its sound through PowerShell's SoundPlayer; check that PowerShell runs and the system sound is on. /zsemle mute turns it off.
  • The limit line shows an old number. The windows only change with an API response, so an idle session keeps its last reading; the line then says how old it is (data 3 h ago), and a window past its reset reads 0% (reset). The next answer brings the fresh numbers.
  • No limit numbers. The windows come with the API responses on a subscription; they show after the first answer. On an API key there are no windows, and Zsemle watches the cost instead.
  • The commit guard is in the way. Repeat the commit within 2 minutes, or /zsemle feature commitGuard off.
  • Times are off. In English the time zone is read from the machine at session start (date +%z or PowerShell); in Hungarian Zsemle uses Budapest time.

Privacy

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.

Under the hood

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

hookwhat it does with what it seesdoes it change anything?
session.startregisters /zsemle, reads the usage figures, loads the user's choices from the plugin store, starts a 250 ms timer that redraws the figureno
session.measurereads the rate-limit windows, context fill and cost; raises toasts at thresholds, pace forecasts and resetsno
classic.StopFailurereads the error kind of a turn that ended on an API error and explains it in the bubbleno
classic.PostModelSwitchnotices an automatic model fallback and tells the userno
classic.SubagentStart, classic.SubagentStopreads the subagent id to add or remove a mini figureno
session.compactpasses the compaction on (next) and tells the user when it was automaticno
tool.callsees 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.submitreads 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.completetimes turns; keeps the answer of a day-summary turn as the day summary; plays the figure's sound after a turn over 3 minutesno
command.run (/zsemle)answers the commands listed aboveno
ui.render, ui.messagedraws the figure, its bubble, the skin picker and the weekly chart; takes clicks on themno

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.
  • On Windows only, 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.

Credits

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.


Magyarul

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.

Source 21 files
hooks/register.tsx 1597 lines
1import { 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 lines
1// 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}
50
hooks/logic.ts 1065 lines
1// 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}
1065
hooks/features.ts 338 lines
1// 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}
338
hooks/skins.ts 286 lines
1import { 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}
286
hooks/extra-skins.ts 6 lines
1// Extra skins: none in this build.
2
3import type { Skin } from './pixels'
4
5export const EXTRA_SKINS: readonly Skin[] = []
6
hooks/figures/cirmi.ts 80 lines
1import { 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}
80
hooks/figures/kapocs.ts 98 lines
1import { 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}
98
hooks/figures/pingvin.ts 97 lines
1import { 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}
97
hooks/figures/teknos.ts 132 lines
1import { 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}
132
hooks/figures/horcsog.ts 168 lines
1import { 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}
168
hooks/figures/bagoly.ts 106 lines
1import { 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