SLOPSHOPPER

chiikawa

Chiikawa mode: Hachiware conducts, and the friends from Chiikawa appear as text drawings to take on tasks and talk them over (Claude subagents + Orca workers)…

newpanebandspinnerguardcommand
v0.2.2MITupdated 2026-10-07techkwon/chiikawa
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · chiikawa
│ ┃ Chiikawa ✕ › fix the failing auth test and add an audit log call │ ┃ Chiikawa The three, together │ ┃ ╭────────────────╮╭────────────────╮╭────── ⏺ Read(src/auth.ts) │ ┃ │ ▄▀▄ ▄▀▄ ││ ▄▀▀▄▄▄▄▀▀▄ ││ ▀▀ ⎿ Read 6 lines │ ┃ │ ▀▀▀▀▀▀▀▀▀▀ ││ ▀▀▀▀▀▀▀▀▀▀ ││ ▀▀ ⏺ Update(src/auth.ts) │ ┃ │ ▀▀▀▀▀▀▀▀▀▀ ││ ▀▀▀▀▀▀▀▀▀▀ ││ ▀▀▀ ⎿ Added 2 lines, removed 1 line │ ┃ │ ▀▀▀▀▀▀▀▀▄ ││ ▀▀▀▀▀▀▀▀ ││ ▀▀ ⏺ Bash(bun test) │ ┃ │ Hachiware ││ Chiikawa ││ U ⎿ 3 pass, 1 fail │ ┃ │ ⠋ on it toget… ││ ⠋ lending a h… ││ ⠋ len │ ┃ │ Bash ││ fixing · cach… ││ looki ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ╰────────────────╯╰────────────────╯╰────── │ ┃ ─────────────── Talk ─────────────── ▲ Olde Hachiware “The best!!” 42s │ ┃ ╭──────────────────────── Hachiware │ ┃ │ So that means "I'll look for it"?! › /chiikawa │ ┃ ╰─ reading Usagi ─────────────────── ⎿ chiikawa: Opened the friends' status pane. │ ┃ o o ╭─ Chiikawa ─╮ ⎿ chiikawa: 🐱 Hachiware (Lead) ● on it together · Bash · cat .env │ ┃ ( `'w'` ) < Fu!! │ fixing · auth.ts ⎿ chiikawa: 🐹 Chiikawa (Build) ● lending a hand · fixing · cache.t │ ┃ (")_(") ╰────────────╯ ⎿ chiikawa: 🐰 Usagi (Explore) ● lending a hand · looking · src │ ┃ ╭─────────────────── Hachiware ⎿ chiikawa: 🦦 Rakko (Build) ○ idle │ ┃ reading Chi… │ So that means "I'll fix it"?! ⎿ chiikawa: 🦁 Shisa (Build) ○ idle │ ┃ ╰────────────────────────────── │ ┃ Resting Rakko · Shisa · Kurimanju · Furuhony │ ┃ ╭─────────────────────────────────────────── │ ┃ │ Usage Claude session $0.42 │ ┃ │ Battery ▰▰▰▰▰▱▱▱▱▱ 51% left · context 97.4 │ ┃ │ 5h ▰▰▰▱▱▱▱▱▱▱ 31% used ✻ 🐱 Hachiware is thinking it over… Chiikawa ⠋ Hachiware ⠋ Chiikawa ⠋ Usagi 6 resting To: auto Battery 51% · 5h 31% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Chiikawa ⠋ Hachiware ⠋ Chiikawa ⠋ Usagi 6 resting To: auto Battery 51% · 5h 31%
Pane · Chiikawa
Chiikawa The three, together on ╭────────────────╮╭────────────────╮╭────────────────╮ │ ▄▀▄ ▄▀▄ ││ ▄▀▀▄▄▄▄▀▀▄ ││ ▀▀ ▀▀ │ │ ▀▀▀▀▀▀▀▀▀▀ ││ ▀▀▀▀▀▀▀▀▀▀ ││ ▀▀▄▄▄▄▀▀ │ │ ▀▀▀▀▀▀▀▀▀▀ ││ ▀▀▀▀▀▀▀▀▀▀ ││ ▀▀▀▀▀▀▀▀▀▀ │ │ ▀▀▀▀▀▀▀▀▄ ││ ▀▀▀▀▀▀▀▀ ││ ▀▀▀▀▀▀▀▀ │ │ Hachiware ││ Chiikawa ││ Usagi │ │ ⠋ on it toget… ││ ⠋ lending a h… ││ ⠋ lending a h… │ │ Bash ││ fixing · cach… ││ looking · src │ ╰────────────────╯╰────────────────╯╰────────────────╯ ─────────────── Talk ─────────────── ▲ Older Talk only ╭──────────────────────── Hachiware ─╮ /\___/\ │ So that means "I'll look for it"?! > (##'v'##) ╰─ reading Usagi ────────────────────╯ (")_(")~ o o ╭─ Chiikawa ─╮ ( `'w'` ) < Fu!! │ fixing · auth.ts (")_(") ╰────────────╯ ╭─────────────────── Hachiware ─╮ /\___/\ reading Chi… │ So that means "I'll fix it"?! > (##'v'##) ╰───────────────────────────────╯ (")_(")~ Resting Rakko · Shisa · Kurimanju · Furuhonya +3 ╭──────────────────────────────────────────────────────╮ │ Usage Claude session $0.42 Hide │ │ Battery ▰▰▰▰▰▱▱▱▱▱ 51% left · context 97.4k / 200k │ │ 5h ▰▰▰▱▱▱▱▱▱▱ 31% used │ │ Pay per friend (tokens, this session so far) │ │ ● Hachiware in 6.4k · cache 91k · out 1.5k │ ╰──────────────────────────────────────────────────────╯ Press a name to see what that friend is doing
Turn line
Hachiware “The best!!” 42s
README

chiikawa — Chiikawa mode for Claude Code

English · 한국어 · 日本語

A mod (plugin) for Claude Code in which Hachiware conducts, and Chiikawa, Usagi and their friends take the work and talk it over like a comic. A cut and a speech bubble for each character show who is doing what right now. It is an unofficial fan mod made by a fan of the comic Chiikawa (ちいかわ). It has nothing to do with the author Nagano (ナガノ), the publisher, the animation producers or any other rights holder, and none of them has approved it. It uses no official art or logo: the text drawings and pixel icons on the screen were drawn for this mod. It runs in English, Korean or Japanese (English by default, Korean for a Korean reader, Japanese for a Japanese reader). Version 0.2.0.

Three friends at work at the same time

Tell Hachiware something like "have Rakko find the cause of the login bug, Kurimanju review the fix, and Furuhonya look into the docs", and the pane on the right shows the three of them at work. When they are done, Hachiware gathers the results and tells you. The friends who cannot talk (Chiikawa, Usagi, Kurimanju, Furuhonya) only make sounds and gestures, and Hachiware puts what they mean into words.

The screenshots are the terminal output of a real Claude Code session (in an example folder, hello-app), redrawn as pictures character for character and color for color, and the usage figures in them are example values. Fonts and colors vary a little by terminal.

Contents

Install (3 minutes)

What you need

  • Claude Code: the program you run as claude in a terminal. If you do not have it yet, install it first from the Claude Code page.
  • The mod was built and tested on Claude Code 2.1.291. Check yours with claude --version, and bring an old one up to date with claude update.
  • Nothing else. There is no Node.js or other program to install.

Installing

  1. Open a terminal and run claude.
  2. Paste this one line into Claude Code's prompt and press Enter.
   /plugin install chiikawa --marketplace techkwon/chiikawa
  1. Answer what the screen asks.
  2. If it asks whether to add the marketplace, accept. A marketplace is a place plugins are fetched from (here, this GitHub repository) that Claude Code keeps on record.
  3. If it asks for a scope (where the mod is to be used), choose user. The mod is then there in every project.
  4. If it asks for settings, go on as it is unless you want something changed. You can change it later.
  5. When a line says the install is finished, you are done. If /chiikawa is not there right away, start Claude Code again.

The same install can be done in two lines from a terminal, outside Claude Code.

claude plugin marketplace add techkwon/chiikawa
claude plugin install chiikawa@chiikawa

To give settings as you install, add --config (it can be repeated). For example, to fix the screen to Korean:

claude plugin install chiikawa@chiikawa --config language=ko

Checking that it is installed

In a terminal, run claude plugin list and see that chiikawa@chiikawa is on the list. Then type /chiikawa in Claude Code's prompt. The friends' status pane opens and the reply says Opened the friends' status pane. (on a Korean screen, 먼작귀 친구들 현황 패널을 열었어요.).

Another way (from a copy of the repository)

git clone https://github.com/techkwon/chiikawa.git
claude --plugin-dir ./chiikawa

This loads the mod for that session only. Use it to work on the mod, or to try it without installing it.

Updating and removing

Run these in a terminal, outside Claude Code.

claude plugin update chiikawa@chiikawa
claude plugin uninstall chiikawa@chiikawa

An update takes effect after Claude Code is started again. To turn the mod off for a while, do not remove it: type /chiikawa off, and /chiikawa on to turn it back on. Whether it is on or off is kept in that session's state alone and is not saved to disk, and the default is on.

claude plugin uninstall removes the mod alone. Three things may be left for you to clear. More about each is under Privacy.

  • The saved roles and language. They are in Claude Code's plugin store, not in the mod's folder. By its help, claude plugin uninstall removes the plugin's persistent data directory unless you add --keep-data, but this guide does not promise that these two values go with it. To be sure they are cleared, type /chiikawa role reset and /chiikawa lang auto in Claude Code before you uninstall. Do it last: a prompt typed afterwards in Hangul or kana is remembered again.
  • The marketplace entry (the record of where the mod was fetched from). It has a command of its own: claude plugin marketplace remove chiikawa.
  • Orca voice copies, only if you used Orca with orcaVoice on. They are the files named <name>.chiikawa-<character>.md beside your spec files. The mod never deletes them. Delete the ones you do not need by hand.

First steps

You can type these as they are.

  1. Watch the demo: no real work is done. Four made-up tasks (Usagi scouting, Rakko hunting, Chiikawa fixing a typo, Kurimanju reviewing) run for about 15 seconds and show how the screen moves. Chiikawa's task ends in failure on purpose, so you also see what a failure looks like.
   /chiikawa demo
  1. Light work, the three together: ask as you usually do. Work the size of reading a few files and changing a little is done by Hachiware without calling another friend. The screen then shows Usagi finding and Chiikawa fixing.
   Find where README.md explains the install, and fix any typo you see.
  1. Hand work to friends by name: say a name, and that friend gets the work. Work handed to a friend is done by a subagent (a helper AI the main session starts), which reports in that friend's voice.
   Have Rakko find the cause of the bug in src/index.js, and have Kurimanju review the fix.

To hand your whole next prompt to one friend, type /chiikawa rakko first and then what you want done.

  1. Change a role: to have Rakko take reviews, type the line below. Pressing a friend's name in the pane and then a role button does the same.
   /chiikawa role rakko review
  1. See the talk alone: fill the pane with what the friends have said to one another. Type it once more to go back.
   /chiikawa talk
  1. See your usage: the usage card in the pane and the band above the prompt show the context left (how much this conversation can hold at once) and your plan's limits. For the same in text, /chiikawa usage.
  1. Change the language: if the screen is not in the language you want, choose one as below. ko is Korean and ja is Japanese.
   /chiikawa lang en

Work handed to friends costs tokens (the unit that counts the text an AI reads and writes) for each friend. So the mod tells the model to do light work itself and hand out only work that takes effort. Whether work is really handed out is decided by the model, from your request.

Friends and roles

FriendMarkIn the comicDefault roleWork it takesHow it talks
Hachiware🐱cat, maybe · Weeding Certification, Grade 5LeadSharing out the work, putting the friends into words. This is the main session (the Claude you are talking to)Casual
Chiikawa🐹hamster · Weeding Certification, Grade 5BuildSmall fixes, tidying (weeding)Sounds and gestures only
Usagi🐰rabbit, maybe · Weeding Certification, Grade 2ExploreExploring code, finding where things areCries only
Rakko🦦sea otter · #1 in the monster-hunting rankingsBuildHard implementation, debuggingShort, flat statements
Shisa🦁shisa lion-dog · Super Part-TimerBuildEveryday implementation, fixesPolite
Kurimanju🌰chestnut bun · holds a drinking licenseReviewReview, verification (O/X)An O or an X with both hands
Furuhonya🦀crab · secondhand booksellerResearchResearch, docs, tidying upLooks and gestures only
Pochette no Yoroi-san👛armor · nimble-fingeredBuildScreens, UI, designGentle
Momonga🍑flying squirrel · makes unreasonable demandsReviewAdversarial review, security checksCheeky
Roudou no Yoroi-san🔔armor · hands out the work assignments(takes no tasks)Announcing new work, warning of anything suspiciousMutters alone

"Work it takes" is this mod's casting, not something the comic says. The names and the words under "In the comic" differ by language (see the tables under Language).

Who takes a task is decided like this.

  1. The friend you named. You said a name in your request, or picked one with /chiikawa rakko.
  2. The specialist, where the work has one. Security and adversarial review go to Momonga first, screens, UI and design to Pochette no Yoroi-san, hard debugging and finding a cause to Rakko, and small touches such as typos, formatting and renaming to Chiikawa. Where two fit, the earlier one wins (a security check of a screen is Momonga's). Build work asked for on a small model (haiku) also goes to Chiikawa first.
  3. Otherwise, the role's own friends in order. The kind of work (build, review, research, explore) is told from the subagent's type and the words of its description; with no such word and no specialty, it is taken as build. Build goes Shisa → Rakko → Pochette no Yoroi-san → Chiikawa, review Kurimanju → Momonga → Rakko, research Furuhonya → Shisa → Usagi, explore Usagi → Furuhonya → Chiikawa, and when the first is busy the next one with free hands takes it.

Good to know:

  • The friends who cannot talk (Chiikawa, Usagi, Kurimanju, Furuhonya) do not speak in sentences, as in the comic. The first line of a report holds one sound or gesture alone, such as “Fu!!”, “Ura!” or (makes an O with both hands), and the plain report starts on the second line. Hachiware reads it for you, as in So that means "all done"?!.
  • The voice goes only on the report. The mod tells the model to keep it out of code, commands, commit messages and file contents.
  • The screen uses the comic's names for work. Building and bug fixing are a Hunt, a review is an Exam, research is Forage, tidying is weeding, and token usage is pay.
  • Claude Code's forks, the teammates of Agent Teams, and subagents whose type starts with codex: are not cast and are left as they are.

Roles can be changed. Press a friend's name in the pane or the band, and on the sheet that opens press one of Build, Review, Research and Explore in the Role row; or type something like /chiikawa role rakko review.

A friend's sheet after a role change

  • A friend given a role takes that role's work before the friends whose own role it is, and leaves the order of its original role. It is not a swap, so several friends can have the same role.
  • A specialty comes before a role. Even with roles changed, work that has a specialist under 2 above (screens and design to Pochette no Yoroi-san, hard debugging to Rakko, security and adversarial review to Momonga, typos and formatting to Chiikawa) goes to that specialist first. But if you changed the specialist's own role, that friend leaves the specialty too. The command's reply to a role change says this as well.
  • A changed role applies from the next task on and stays for later sessions (if it could not be saved, the reply says it holds for this session only). /chiikawa role rakko default puts one friend back, and /chiikawa role reset everyone.
  • Hachiware's lead cannot be changed, and Roudou no Yoroi-san takes no tasks and so has no role.

What you see

The pane on the right

The pane on the right

From the top:

  • The title row: beside Chiikawa, the state right now (2 working, The three, together, 1 waiting, Resting).
  • The three: the cuts of Hachiware, Chiikawa and Usagi are always at the top. Each cut holds a pixel icon, the name, the state, and one line of what the character is doing or last said.
  • Friends at work: any other friend gets a cut of the same shape only while it has work, and goes back to the bench about 30 seconds after the work ends.
  • Talk: the friends' lines pile up as cuts with speech bubbles. A friend is drawn on the left and Hachiware, answering, on the right. A line is in a round bubble and a gesture in a square box. There are three cuts to begin with, and up to eight where there is room. Press ▲ Older and ▼ Newer to page through the earlier talk (up to 120 lines are kept).
  • Friends resting (the bench): friends with no work sit there as an icon and a name. One that finished work this time has ✓ or ✗ beside its name.
  • The usage card: Battery is how much of this conversation's context window is left; 5h and 7d are how much of your plan's limits is used, and the time until each resets. Under them, Pay per friend is the tokens (in, cache, out) each friend has used this session so far. It is counted apart from the task records, so it stays when /chiikawa clear clears those, and /chiikawa clear all clears it. Press Hide to fold the card to one line, and press that line to open it again.

A state reads ● working 0:12 (the mark turns while the work goes on), ◐ waiting, ✓ done, ✗ failed or ○ idle. While the three do light work together, Hachiware is on it together and Chiikawa and Usagi are lending a hand.

Where the pane has room (50 columns wide and 17 rows tall, or more) it is drawn as cuts, as above; smaller than that, as a list with two rows a friend. However wide the pane is, no more than 84 columns are used.

A friend's sheet

The sheet that opens when a friend is pressed

Press a friend's name in the pane or the band and that friend's sheet opens. Press the same name again, or Close, to put it away.

  • Now: the name of the task in hand, which subagent (or Orca profile) it is, how long it has run, and the tool in use (as in ▸ Read index.js · 3 tool calls).
  • Done and Report: the task most recently finished, and up to the first six lines of the report handed back.
  • Pay and Said: the tokens that friend has used, and the last two things it said.
  • Role: press one to give that role. The role it has now is marked ▶.
  • ▷ Hand this friend the next prompt: press it, and the next prompt you type goes to that friend. Press again to cancel. On Hachiware's sheet it reads ▷ Have Hachiware do the next prompt.
  • Where the pane is short or narrow, the sheet gives things up in this order: the rows above (from the last one up), then the role buttons (their names are cut short before they are left out), then the hand-over button. The name and Close stay to the end.

The talk alone

The talk alone

Press Talk only at the end of the talk row, or type /chiikawa talk, and the whole pane is filled with the talk. Press Show all, or type /chiikawa talk once more, to go back. Where the pane is narrower than 50 columns, each line of talk is shown on one row instead of as a cut.

The band above the prompt

The band above the prompt

  • The friends' names: the three first, then the others. Press a name and that friend's sheet opens in the pane. A friend with work has a state mark before its name and the time the work has run after it.
  • To: auto: who takes your next prompt. The default is auto (Hachiware shares the work out); once you pick a friend it reads To: Rakko, and that friend's name is marked ▶. Press it to undo the pick.
  • Usage: it reads like Battery 82% · 5h 12%. Press it and the pane opens with the usage shown in full.
  • The last line, as one cut: while the pane is closed and there is talk going on (work in hand, or about 45 seconds after the last line), the last line is drawn under the band as one cut. With the pane open, the band is one row alone.
  • Where the width runs short, things are dropped in this order: the times, the 5h figure of the usage, the names of resting friends (they shrink to a count such as 4 resting; press it and the pane opens), the names of friends at work, To:, and the usage.

And more

  • Spinner text: while you wait it reads like 🐱 Hachiware is thinking it over or 🐱 Hachiware is leading.
  • The line after a turn: a word from Hachiware (“The best!!” or “It's the best, isn't it!”) and how long the turn took. After a turn of ten minutes or more it is “No matter how many times… I'll keep cheering for you!!”.
  • Toasts: when a friend's work ends, a toast shows that friend's line and the result (as in Hunt done · 12s).
  • Lines for the moment: they appear in the talk when the moment comes. When new work is posted, Roudou no Yoroi-san says “First come, first served!”; when two friends come to work at once, “It's a friendly type…!!”; when a task passes three minutes, its friend says a line and Momonga, if free, says “I'm so hungry I feel kinda sick—!!!”; when a friend's tool call is denied, Roudou no Yoroi-san says “A mimic type, huh~?”; when Chiikawa's task fails, Hachiware says “They cried!!!”; and when several tasks end well together, “Easy!! Easy!!!”.
  • Movement: the text drawing of a friend at work walks and its icon bounces. While nobody is at work, the drawings do not move.
  • Light and dark screens: if the name of the Claude Code theme has light in it, the mod draws in deeper colors that read on a light background. A change of theme is followed when you next send a prompt or run /chiikawa. If the colors do not suit, fix "Color theme" in the settings to light or dark.

Commands

TypeWhat it does
/chiikawaOpens the friends' status pane, and shows the status and the recent talk as text too
/chiikawa demoRuns four made-up tasks for about 15 seconds to show the screen. No real work is done. Only while the mode is on
/chiikawa on · /chiikawa offTurns the mode on and off. Off, the voice instructions stop, and the band, the spinner text and the line after a turn are no longer drawn. A pane that is already open is not closed: it stays, with off at the right of its title. Work already handed out is still closed and its usage counted when it ends, even while the mode is off (an Orca worker's result is read once the mode is back on), with no toast and no line in the talk
/chiikawa usageShows the usage (context, limits, cost, tokens per friend) as text
/chiikawa talkTurns the pane to the talk alone. Typed once more, it goes back to showing everything
/chiikawa rakko (a friend's name)Hands the next prompt you type to that friend. The same name again cancels it. /chiikawa hachiware has Hachiware do it without handing it out
/chiikawa autoUndoes the pick: prompts go by auto (the default) again
/chiikawa roleShows the friends' roles as text
/chiikawa role rakko reviewGives Rakko the review role. Roles: build, review, research, explore (hunt, exam and forage work too)
/chiikawa role rakko default · /chiikawa role resetPuts one friend, or everyone, back to the original role
/chiikawa langShows the language in use and what settled it
/chiikawa lang en (ko · ja)Chooses the language. It stays for later sessions (if it could not be saved, the reply says it holds for this session only)
/chiikawa lang autoClears both the language you chose and the one remembered from the letters you typed, and lets the language be settled automatically
/chiikawa clearClears the finished tasks and the talk. Tasks still running and each friend's pay (the usage so far) are kept
/chiikawa clear allClears every task, even those still listed as running, along with the talk and each friend's pay (the usage so far)
  • Names of friends and of roles are understood in any of the three languages, in capitals or not (rakko · 랏코 · ラッコ, review · 검토 · レビュー). Furuhonya also answers to kani, 카니, 헌책방 and 古本屋, and Pochette no Yoroi-san to pochette, 포쉐트, 포셰트 and ポシェット. The Korean edition's names (토끼, 해달, 밤만쥬, 하늘다람쥐) work too. Hachiware is known only as hachiware, 하치와레 or ハチワレ (not by the Korean edition's name).
  • A picked friend is used for your next prompt alone; once that prompt has gone in, prompts go by auto again. A slash command (one that starts with /) does not use up the pick: it waits for the next ordinary prompt.

Settings

You set these on the screen that asks as you install, and change them later by typing /plugin configure chiikawa@chiikawa in the prompt. If a changed setting does not show right away, start Claude Code again.

KeyTitle on the screenDefaultWhat it does
languageLanguageautoA value you type: auto · en · ko · ja. auto follows the order under Language. Anything else is read as auto
leaderVoiceHachiware's voiceonThe main session talks in the voice of Hachiware, the conductor. Off, the briefing is given neither in the system prompt nor beside a prompt, and no [Chiikawa roles] line is added. The rest stays: the screens, the casting (a subagent's description, the [Chiikawa cast] line after a result), the friends' voices for subagents (memberVoice), Orca voice copies (orcaVoice), and the [Chiikawa pick] line when you pick a friend
politeLeaderPolite HachiwareoffHachiware speaks politely instead of in the casual speech of the comic
memberVoiceThe friends' voicesonA subagent reports in the voice of the character cast for it (a friend who cannot talk reports with a sound)
orcaVoiceOrca workers' voicesonA fleet-run launch is handed a copy of its spec, made beside it, with the character's voice added (only if you use Orca)
bandBand of friends above the promptonShows one row above the prompt with the friends' names, who takes the next prompt and the usage; only while the pane is closed, the last line said is drawn under it as one cut
trioThe three togetheronShows light work the main session does itself as Hachiware, Chiikawa and Usagi at it together (Usagi for tools that search, Chiikawa for tools that edit)
animateMovement while workingonThe drawing and the icon of a character at work move every half second (the state mark turns with them). Off, only that half-second movement stops. The screen is still redrawn when something changes (a task starts or ends, a tool is called, you press something) and by the check that runs every 2 seconds
themeColor themeautoA value you type: auto (follows the Claude Code theme) · dark · light. Anything else is read as auto
autoOpenOpen the pane by itselfonThe pane opens by itself when a friend is handed work (where the terminal is 144 columns wide or more). Once you close it by hand, it is not opened again until /chiikawa

Language

The

Source 14 files
hooks/register.tsx 1692 lines
1import { atom, read, update } from 'claude-code'
2import type { AgentInfo, Elements, EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Aids, Lang, MemberId, Phrase, Role, Roles, Said, Task, Tokens, Usage, WorkRole } from '../types'
5
6import type { Situation } from './cast'
7import { CAST, isActive, jobOf, LEAD, MEMBERS, MOODS, pickMember, PROFILES, readingOf, reported, roleOf, roleOfAgent, rolesFrom, say, shown, specialistOf, titleOf, toneOf, withRole, WORK_ROLES, WORKERS } from './cast'
8import type { FleetRun } from './orca'
9import { findFleetRuns, readMeta, voicedSpecPath } from './orca'
10import type { Kit, Scene } from './view'
11import { AID_MS, drawBand, drawPane, lastSaid, plus, rosterLines, talkLines, usageLines, ZERO } from './view'
12import { castIn, firstLines, fleetNote, isMarked, leaderSection, memberBlock, memberNamed, namedMember, openerOf, orcaBlock, pickNote, roleNamed, roleNote, standDown, tookNote, unvoiced } from './voice'
13import type { LangFrom, Words } from './words'
14import { asIs, inAll, isSaid, LANG_NAMES, langKeptFrom, langNamed, LANGS, configLang, localeLang, typedLang, WORDS } from './words'
15
16const PANE = 'chiikawa'
17const TICK_MS = 2000
18/** How long a frame of the drawings stands while a character is at work. */
19const FRAME_MS = 500
20const FRAMES = 600
21/** Lines of a report kept for the one who asks to see the task. */
22const SUMMARY_LINES = 6
23/** How long one of the three waits before it says again that it is lending a hand. */
24const AID_SAY_MS = 20_000
25/** The main loop's own tools that look for something: 우사기 lends a hand. */
26const SEEK = new Set(['Read', 'Grep', 'Glob', 'LS', 'WebSearch', 'WebFetch'])
27/** The ones that change a file: 치이카와 does. */
28const MEND = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
29const SLOW_MS = 180_000
30const LINGER_MS = 45_000
31/** Finished tasks kept; one still at work is never let go. */
32const KEPT = 40
33const FEED_KEPT = 120
34const PANE_SIZE = { rows: 40, columns: 72 } as const
35const LOST_MS = 3_600_000
36/** Ends kept for one agent whose task has yet to be told its id, and agents they are kept for: past these the oldest goes. */
37const EARLY_KEPT = 16
38/** Agents whose records were let go on a guess of how they ended, and are still listened for. */
39const OWED_KEPT = 40
40/** Beats in a row the engine's list must be without an agent before it is taken to have ended. */
41const UNLISTED_BEATS = 3
42/** Copies of one spec a character may have beside it before a launch goes without one. */
43const COPIES = 9
44const TYPED = new Set(['composer', 'bridge', 'sdk'])
45
46const isOn = atom({ plugin: 'chiikawa', key: 'isOn' } as const, true)
47const tasks = atom({ plugin: 'chiikawa', key: 'tasks' } as const, [])
48const clockNow = atom({ plugin: 'chiikawa', key: 'now' } as const, 0)
49const waveAt = atom({ plugin: 'chiikawa', key: 'waveAt' } as const, 0)
50const isPaneOpen = atom({ plugin: 'chiikawa', key: 'isPaneOpen' } as const, false)
51const usage = atom({ plugin: 'chiikawa', key: 'usage' } as const, null)
52const leaderTokens = atom({ plugin: 'chiikawa', key: 'leaderTokens' } as const, { fresh: 0, cached: 0, out: 0 })
53const brief = atom({ plugin: 'chiikawa', key: 'brief' } as const, { isAlive: false, isStale: false, doubts: 0 })
54const isPaneDismissed = atom({ plugin: 'chiikawa', key: 'isPaneDismissed' } as const, false)
55const feed = atom({ plugin: 'chiikawa', key: 'feed' } as const, [])
56const picked = atom({ plugin: 'chiikawa', key: 'picked' } as const, null)
57const watched = atom({ plugin: 'chiikawa', key: 'watched' } as const, null)
58const isLight = atom({ plugin: 'chiikawa', key: 'isLight' } as const, false)
59const frame = atom({ plugin: 'chiikawa', key: 'frame' } as const, 0)
60const aids = atom({ plugin: 'chiikawa', key: 'aids' } as const, {})
61const isUsageOpen = atom({ plugin: 'chiikawa', key: 'isUsageOpen' } as const, true)
62const talkBack = atom({ plugin: 'chiikawa', key: 'talkBack' } as const, 0)
63const isTalkOnly = atom({ plugin: 'chiikawa', key: 'isTalkOnly' } as const, false)
64const roles = atom({ plugin: 'chiikawa', key: 'roles' } as const, {})
65const roleVersion = atom({ plugin: 'chiikawa', key: 'roleVersion' } as const, 0)
66const toldRoleVersion = atom({ plugin: 'chiikawa', key: 'toldRoleVersion' } as const, 0)
67const paid = atom({ plugin: 'chiikawa', key: 'paid' } as const, {})
68const owed = atom({ plugin: 'chiikawa', key: 'owed' } as const, [])
69const lang = atom({ plugin: 'chiikawa', key: 'lang' } as const, 'en')
70const langKept = atom({ plugin: 'chiikawa', key: 'langKept' } as const, {})
71const isLoaded = atom({ plugin: 'chiikawa', key: 'isLoaded' } as const, false)
72const isRolesRead = atom({ plugin: 'chiikawa', key: 'isRolesRead' } as const, false)
73const isLangRead = atom({ plugin: 'chiikawa', key: 'isLangRead' } as const, false)
74const isLangUnkept = atom({ plugin: 'chiikawa', key: 'isLangUnkept' } as const, false)
75/** Where the roles the person gave are kept from one session to the next. */
76const ROLES_KEPT = 'roles'
77/** Where the language the person chose, and the one they type in, are kept from one session to the next. */
78const LANG_KEPT = 'lang'
79
80type Engine = EngineInterface
81type Draft = Pick<Task, 'id' | 'kind' | 'role' | 'engine' | 'title'> & Pick<Task, 'titles' | 'out' | 'call' | 'due' | 'staleAt'>
82/** A line of the conversation before it has its moment: who, on what occasion, and in each language the words and what they are about. */
83type Line = readonly [member: MemberId, situation: Situation, quote: Phrase, note: Phrase]
84/** How a task ended. */
85type Outcome = {
86  isOk: boolean
87  /** Why it ended so, as a language says it. */
88  why?: (words: Words) => string
89  tokens?: Tokens
90  /** What the mode itself has it hand back, where no worker wrote anything: a demonstration's. */
91  report?: Phrase
92  /** The first lines of what it handed back: the first is its report. */
93  summary?: string[]
94  /** The friend who cannot talk whose name opens what it handed back, where that is one line and no more. */
95  opener?: MemberId
96  /** Read off the engine's list, not told by the task's own turn. */
97  isGuessed?: boolean
98}
99
100/** The language the mode's own setting fixes; `auto` leaves it to the person and their machine. */
101let langWanted: Lang | 'auto' = 'auto'
102
103/** The language the screen and the voices are in just now. */
104const langOf = async ($: Engine): Promise<Lang> => (langWanted === 'auto' ? read($, lang) : langWanted)
105
106/**
107 * Words the session holds, in each language. A session under way when the
108 * mode was loaded again may still hold them as the one text they were said
109 * in: that text then stands in every language.
110 */
111const phraseOf = (held: Phrase | string): Phrase => (typeof held === 'string' ? asIs(held) : held)
112
113/** The conversation so far, each line with its words in each language. */
114const talkOf = async ($: Engine): Promise<Said[]> => (await read($, feed)).map(said => ({ ...said, quote: phraseOf(said.quote), note: phraseOf(said.note) }))
115
116/** The tasks, each with its words in each language. */
117const tasksOf = async ($: Engine): Promise<Task[]> =>
118  (await read($, tasks)).map(task => ({ ...task, quote: phraseOf(task.quote), note: phraseOf(task.note), ...(task.report === undefined ? {} : { report: phraseOf(task.report) }) }))
119
120/** The hands lent, each with its words in each language. */
121const aidsOf = async ($: Engine): Promise<Aids> =>
122  Object.fromEntries(Object.entries(await read($, aids)).map(([id, aid]) => [id, { ...aid, what: phraseOf(aid.what), ...(aid.brief === undefined ? {} : { brief: phraseOf(aid.brief) }) }]))
123
124const sceneOf = async ($: Engine): Promise<Scene> => ({
125  lang: await langOf($),
126  tasks: await tasksOf($),
127  now: await read($, clockNow),
128  waveAt: await read($, waveAt),
129  usage: await read($, usage),
130  leaderTokens: await read($, leaderTokens),
131  feed: await talkOf($),
132  picked: await read($, picked),
133  watched: await read($, watched),
134  aids: await aidsOf($),
135  isUsageOpen: await read($, isUsageOpen),
136  talkBack: await read($, talkBack),
137  isTalkOnly: await read($, isTalkOnly),
138  roles: await read($, roles),
139  paid: await read($, paid),
140})
141
142/** The surface's elements with how the drawings look just now: the theme's tones, the frame of what moves. */
143const kitOf = async ($: Engine, { Box, Text, Raster, Button }: Elements['terminal']): Promise<Kit> => ({
144  Box,
145  Text,
146  Raster,
147  Button,
148  isLight: await read($, isLight),
149  frame: await read($, frame),
150})
151
152/** Which tones the mode's own setting asks for; `auto` follows the person's theme. */
153let themeWanted = 'auto'
154
155type Settings = Awaited<ReturnType<Engine['config']['list']>>
156
157/**
158 * The language and what settled it, the first of these that holds: the
159 * mode's own setting, the one the person chose, the one their prompts are
160 * typed in, Claude Code's own language where that is one of the three, the
161 * locale of the environment, and English. `settings` is what `/config`
162 * holds, where it was already read.
163 */
164const settledLang = async ($: Engine, settings?: Settings): Promise<{ lang: Lang; from: LangFrom }> => {
165  if (langWanted !== 'auto') return { lang: langWanted, from: 'setting' }
166  const kept = await read($, langKept)
167
168  if (kept.chosen !== undefined) return { lang: kept.chosen, from: 'chosen' }
169  if (kept.typed !== undefined) return { lang: kept.typed, from: 'typed' }
170  const set = (settings ?? (await $.config.list().catch(() => []))).find(row => row.key === 'language')?.value
171  const configured = typeof set === 'string' ? configLang(set) : undefined
172
173  if (configured !== undefined) return { lang: configured, from: 'config' }
174  // The first of the three that is set is the one a program goes by.
175  const locale = [await $.env.get('LC_ALL').catch(() => undefined), await $.env.get('LC_MESSAGES').catch(() => undefined), await $.env.get('LANG').catch(() => undefined)].find(
176    value => value !== undefined && value !== '',
177  )
178  const named = locale === undefined ? undefined : localeLang(locale)
179
180  if (named !== undefined) return { lang: named, from: 'env' }
181
182  return { lang: 'en', from: locale !== undefined && /^en(?![a-z])/i.test(locale) ? 'env' : 'default' }
183}
184
185/**
186 * Follows the person's settings as they are now: whether the theme is a
187 * light one (the mode's own setting, else the theme `/config` holds), and
188 * the language. A language other than the one in use turns the screen to it,
189 * and what the main loop was told of how to talk is out of date from then on:
190 * it is told again beside the next prompt while it conducts, and told to stop
191 * where it no longer does. Answers whether the language changed.
192 */
193const refreshLook = async ($: Engine): Promise<boolean> => {
194  const settings = themeWanted !== 'auto' && langWanted !== 'auto' ? [] : await $.config.list().catch(() => [])
195  const theme = themeWanted !== 'auto' ? themeWanted : settings.find(row => row.key === 'theme')?.value
196  const isNowLight = typeof theme === 'string' && /light/i.test(theme)
197
198  if (isNowLight !== (await read($, isLight))) await update($, isLight, () => isNowLight)
199  const { lang: next } = await settledLang($, settings)
200
201  if (next === (await read($, lang))) return false
202  await update($, lang, () => next)
203  await update($, brief, told => ({ ...told, isStale: true, doubts: told.doubts + 1 }))
204  $.ui.invalidate('prompt.section')
205
206  return true
207}
208
209/** Names the mode's command, and says what it takes, in the language in use. */
210const enlist = async ($: Engine): Promise<void> => {
211  const words = WORDS[await langOf($)]
212
213  await $.command.register({ name: 'chiikawa', description: words.description, argumentHint: words.argumentHint, immediate: true })
214}
215
216/** Follows the person's settings, and where the language changed, has the command say what it takes in the new one. */
217const follow = async ($: Engine): Promise<void> => {
218  if (await refreshLook($)) await enlist($).catch(() => undefined)
219}
220
221/** Moves the clock the drawings read on to a moment; it does not run back. */
222const stamp = async ($: Engine, moment?: number): Promise<void> => {
223  const now = moment ?? (await $.clock.now())
224
225  await update($, clockNow, at => Math.max(at, now))
226}
227
228/** Whether there is something to show unasked: work in hand, or a line just said. */
229const isLive = (scene: Scene): boolean => {
230  const at = lastSaid(scene)?.at ?? 0
231
232  return scene.tasks.some(isActive) || (at > 0 && scene.now - at < LINGER_MS)
233}
234
235/** A line that work stands in: each piece starts once the one before it has ended, however that went. */
236const lineUp = (): (<T>(work: () => Promise<T>) => Promise<T>) => {
237  let last: Promise<unknown> = Promise.resolve()
238
239  return work => {
240    const mine = last.then(work, work)
241
242    last = mine.catch(() => undefined)
243
244    return mine
245  }
246}
247
248// One thing happens at a time: taking a task or ending one reads the list,
249// writes it and speaks, and two doing so at once would talk over each other.
250const inTurn = lineUp()
251/** A spec's copies are made one at a time, so that two launches do not take one place for two texts. */
252const inCopy = lineUp()
253/** The roles and the languages are saved one at a time, so that the last save is of the last change. */
254const inSave = lineUp()
255
256/**
257 * The characters say their lines, one after another: the conversation keeps
258 * them, newest last, each a moment after the one before it whatever the clock
259 * says, so that it reads in the order it was said. Answers the last moment.
260 */
261const tell = async ($: Engine, now: number, lines: readonly Line[]): Promise<number> => {
262  let last = now
263
264  await update($, feed, list => {
265    const first = Math.max(now, (list[list.length - 1]?.at ?? -1) + 1)
266    const said: Said[] = lines.map(([member, situation, quote, note], index) => ({ member, quote, note, at: first + index, mood: MOODS[situation] }))
267
268    last = first + Math.max(0, lines.length - 1)
269
270    return [...list, ...said].slice(-FEED_KEPT)
271  })
272  // A page turned back stays on the lines it was showing.
273  await update($, talkBack, back => (back > 0 ? Math.min(FEED_KEPT - 1, back + lines.length) : back))
274
275  return last
276}
277
278/** One of a character's own lines for the occasion, in each language: one turn is the same place of the same occasion in each. */
279const lineOf = (member: MemberId, situation: Situation, turn: number): Phrase => inAll(lang => say(lang, member, situation, turn))
280
281/** How 하치와레 reads what a friend just did, in each language; nothing where he has nothing to read. */
282const readOf = (member: MemberId, situation: 'start' | 'done' | 'fail', role: Role): Phrase | undefined =>
283  LANGS.some(lang => readingOf(lang, member, situation, role) === undefined) ? undefined : inAll(lang => readingOf(lang, member, situation, role) ?? '')
284
285/** What a tool call is on, in a few words: the file's name, the command's start. */
286const detailOf = (input: object): string => {
287  const { file_path: file, path, command, pattern, query, url, description } = input as Record<string, unknown>
288  const named = [file, path].find(one => typeof one === 'string' && one !== '')
289
290  if (typeof named === 'string') return named.slice(named.lastIndexOf('/') + 1)
291  const said = [command, pattern, query, url, description].find(one => typeof one === 'string' && one !== '')
292
293  return typeof said === 'string' ? said.replace(/\s+/g, ' ').slice(0, 48) : ''
294}
295
296/** The list with its oldest finished tasks let go. */
297const kept = (list: readonly Task[]): Task[] => {
298  const ended = list.filter(task => !isActive(task))
299  const old = new Set(ended.slice(0, Math.max(0, ended.length - KEPT)))
300
301  return list.filter(task => !old.has(task))
302}
303
304/**
305 * Notes the agents whose records are let go while how they ended is still a
306 * guess: the word of how each really went may yet come, and what it cost is
307 * then still its character's. Only so many are listened for.
308 */
309const owe = async ($: Engine, gone: readonly Task[]): Promise<void> => {
310  const late = gone.filter(task => task.kind === 'agent' && task.isGuessed === true).map(task => ({ id: task.id, member: task.member }))
311
312  if (late.length > 0) await update($, owed, list => [...list.filter(one => !late.some(now => now.id === one.id)), ...late].slice(-OWED_KEPT))
313}
314
315let isAutoOpening = true
316/** Whether a character is at work, as the last beat or the last task taken has it: the drawings move while it holds. */
317let isBusy = false
318
319/**
320 * Adds a task and hands it to a character. 노동 갑옷 씨 rings the bell for a
321 * new wave, the character answers, and 하치와레 reads the answer aloud where
322 * the character cannot talk. `hint` is whatever else tells the kind of work,
323 * such as the model asked for. A worker told to write where another is still
324 * waited for is taken without its result file.
325 */
326const reserve = ($: Engine, draft: Draft, wanted?: MemberId, hint = ''): Promise<Task> =>
327  inTurn(async () => {
328    const now = await $.clock.now()
329    const given = await read($, roles)
330    const { out, staleAt: _staleAt, ...unread } = draft
331    const box: { task: Task; isWaveStart: boolean; busy: number; gone: Task[] } = {
332      task: { ...draft, member: 'shisa', status: 'running', startedAt: now, toolCount: 0, quote: asIs(''), note: asIs(''), mood: 'calm' },
333      isWaveStart: false,
334      busy: 0,
335      gone: [],
336    }
337
338    await update($, tasks, list => {
339      const member = wanted ?? pickMember(draft.role, list, specialistOf(`${draft.engine} ${hint}`, draft.title, draft.role), given)
340      const turn = list.filter(task => task.member === member).length
341      // A result file another worker is still waited for at tells of that one alone. Whose it is is settled in the one write that
342      // takes the task, so that two launches taken at once do not both have it.
343      const isShared = out !== undefined && list.some(task => task.id !== draft.id && task.kind === 'orca' && task.out === out && (isActive(task) || isDoubted(task, now)))
344
345      box.isWaveStart = !list.some(isActive)
346      box.busy = new Set([...list.filter(isActive).map(task => task.member), member]).size
347      box.task = {
348        ...(isShared ? unread : draft),
349        member,
350        status: 'running',
351        startedAt: now,
352        toolCount: 0,
353        quote: lineOf(member, 'start', turn),
354        note: inAll(lang => WORDS[lang].started(CAST[lang].job[draft.role], titleOf(lang, draft))),
355        mood: 'calm',
356      }
357      const next = kept([...list.filter(task => task.id !== draft.id), box.task])
358
359      box.gone = list.filter(task => task.id !== draft.id && !next.includes(task))
360
361      return next
362    })
363    await owe($, box.gone)
364
365    const { task } = box
366    const reading = readOf(task.member, 'start', task.role)
367
368    isBusy = true
369    const lines: Line[] = []
370
371    if (box.isWaveStart) {
372      await update($, waveAt, () => now)
373      lines.push(['rodo', 'start', lineOf('rodo', 'start', 0), inAll(lang => WORDS[lang].bellNote)])
374    }
375    lines.push([task.member, 'start', task.quote, task.note])
376    if (reading !== undefined) lines.push([LEAD, 'start', reading, inAll(lang => WORDS[lang].reading(CAST[lang].names[task.member]))])
377    if (box.busy === 2) {
378      const since = await read($, waveAt)
379
380      // 노동 갑옷 씨 remarks on two friends at work at once, once a wave.
381      if (!(await talkOf($)).some(said => said.at >= since && isSaid(one => one.paired, said.note))) lines.push(['rodo', 'done', lineOf('rodo', 'done', 0), inAll(lang => WORDS[lang].paired)])
382    }
383    await stamp($, await tell($, now, lines))
384    if (box.isWaveStart && isAutoOpening && !(await read($, isPaneOpen)) && !(await read($, isPaneDismissed))) {
385      // Unasked, so the surface seats it only where there is width to spare;
386      // where it does not, the band above the prompt carries the conversation.
387      void $.ui.open({ id: PANE, title: WORDS[await langOf($)].brand, closeOnEscape: true, ...PANE_SIZE }).then(
388        opened => (opened.isPlaced ? update($, isPaneOpen, () => true) : undefined),
389        () => undefined,
390      )
391    }
392
393    return task
394  })
395
396/**
397 * The main loop is at a piece of work itself, no friend called in: 하치와레
398 * is seen doing it, and where the tool looks for something or changes a
399 * file, 우사기 or 치이카와 is seen lending a hand, saying so now and then
400 * for 하치와레 to read aloud. One with a task of its own is left to it.
401 */
402const lend = ($: Engine, tool: string, detail: string): Promise<void> =>
403  inTurn(async () => {
404    const now = await $.clock.now()
405    const wanted: MemberId | undefined = SEEK.has(tool) ? 'usagi' : MEND.has(tool) ? 'chiikawa' : undefined
406    const helper = wanted !== undefined && !(await read($, tasks)).some(task => task.member === wanted && isActive(task)) ? wanted : undefined
407    const target = detail === '' ? '' : ` · ${detail}`
408    const verb = (lang: Lang): string => (helper === 'usagi' ? WORDS[lang].seeking : WORDS[lang].mending)
409    const doing = inAll(lang => `${verb(lang)}${target}`)
410    const box = { isDue: false }
411
412    await update($, aids, was => {
413      const next: Aids = {
414        ...was,
415        [LEAD]:
416          helper === undefined
417            ? { what: asIs(`${tool}${target}`), at: now, saidAt: now }
418            : { what: inAll(lang => `${WORDS[lang].lending(CAST[lang].names[helper], verb(lang))}${target}`), brief: inAll(lang => WORDS[lang].withFriend(CAST[lang].names[helper])), at: now, saidAt: now },
419      }
420
421      if (helper !== undefined) {
422        const last = was[helper]?.saidAt
423
424        box.isDue = last === undefined || now - last >= AID_SAY_MS
425        next[helper] = { what: doing, at: now, saidAt: box.isDue || last === undefined ? now : last }
426      }
427
428      return next
429    })
430    isBusy = true
431    if (helper === undefined || !box.isDue) return stamp($, now)
432    const lines: Line[] = [
433      [helper, 'start', lineOf(helper, 'start', Math.floor(now / AID_SAY_MS)), doing],
434      [LEAD, 'start', inAll(lang => CAST[lang].means(helper === 'usagi' ? CAST[lang].read.seeking : CAST[lang].read.mending)), inAll(lang => WORDS[lang].reading(CAST[lang].names[helper]))],
435    ]
436
437    await stamp($, await tell($, now, lines))
438  })
439
440const drop = ($: Engine, id: string): Promise<unknown> => update($, tasks, list => list.filter(task => task.id !== id))
441
442/** What a task has cost with a new figure in: a subagent's turns add up, an Orca worker's file states its whole. */
443const costOf = (task: Task, tokens: Tokens | undefined): Tokens | undefined =>
444  tokens === undefined ? task.tokens : task.kind === 'orca' ? tokens : plus(task.tokens ?? ZERO, tokens)
445
446/** What a new figure adds to what a task has cost: a subagent's turn its own, an Orca worker's whole less what was counted before. */
447const gainOf = (task: Task, tokens: Tokens | undefined): Tokens | undefined => {
448  const was = task.tokens
449
450  if (tokens === undefined || was === undefined || task.kind !== 'orca') return tokens
451
452  return plus(tokens, { fresh: -was.fresh, cached: -was.cached, out: -was.out, usd: was.usd === undefined ? undefined : -was.usd })
453}
454
455/**
456 * What a task has handed back, with an end's word of it in: what the mode
457 * itself has it hand back, else the first line the worker wrote, else what
458 * it had. The worker's line stands as written in every language, but for the
459 * one line a character wrote after its own name as all its report: a sound
460 * or a gesture of its own there is as each language has it.
461 */
462const toldOf = (task: Task, { report, summary, opener }: Outcome): Phrase | undefined => {
463  const [first] = summary ?? []
464
465  if (report !== undefined || first === undefined) return report ?? task.report
466
467  return opener === task.member ? reported(task.member, first) : asIs(first)
468}
469
470/** The task as it stands once ended that way: the character's line for it, how long it took, what it handed back. */
471const endOf = (task: Task, outcome: Outcome, now: number): Task => {
472  const { tool: _tool, detail: _detail, isGuessed: _isGuessed, ...rest } = task
473  const { isOk, why, tokens, summary, isGuessed = false } = outcome
474  const situation = isOk ? 'done' : 'fail'
475  const endedAt = task.endedAt ?? now
476  const cost = costOf(task, tokens)
477  const told = toldOf(task, outcome)
478
479  return {
480    ...rest,
481    status: isOk ? 'done' : 'failed',
482    endedAt,
483    // A check that passed is an O of the hand, whatever the turn.
484    quote: lineOf(task.member, situation, task.role === '검토' ? 0 : task.toolCount + task.title.length),
485    note: inAll(lang => [WORDS[lang].ended(CAST[lang].job[task.role], isOk), WORDS[lang].spoken(endedAt - task.startedAt), why?.(WORDS[lang]) ?? ''].filter(part => part !== '').join(' · ')),
486    mood: MOODS[situation],
487    ...(cost === undefined ? {} : { tokens: cost }),
488    ...(told === undefined ? {} : { report: told }),
489    ...(summary === undefined || summary.length === 0 ? {} : { summary }),
490    ...(isGuessed ? { isGuessed } : {}),
491  }
492}
493
494/**
495 * Ends a task: the character says how it went, 하치와레 reads it, and sums up
496 * a finished wave. Word of a task that has already ended adds what it cost
497 * and what it handed back; where the end was a guess, it also says how the
498 * task really went. What it cost goes on its character's account, which
499 * outlasts the task. Turned off, the mode still keeps the end and the cost,
500 * and says nothing of them: no line, and no toast.
501 */
502const settle = ($: Engine, id: string, outcome: Outcome): Promise<void> =>
503  inTurn(async () => {
504    const now = await $.clock.now()
505    const box: { ended?: Task; all: Task[]; gain?: readonly [MemberId, Tokens] } = { all: [] }
506
507    await update($, tasks, list => {
508      box.ended = undefined
509      box.gain = undefined
510      box.all = list.map(task => {
511        if (task.id !== id) return task
512        const gain = gainOf(task, outcome.tokens)
513        const cost = costOf(task, outcome.tokens)
514        const told = toldOf(task, outcome)
515        const lines = outcome.summary === undefined || outcome.summary.length === 0 ? task.summary : outcome.summary
516        // A task at work is ended, and one ended on a guess is ended again as it really went; any other only gains what it cost and handed back.
517        const next =
518          isActive(task) || (task.isGuessed === true && outcome.isGuessed !== true)
519            ? endOf(task, outcome, now)
520            : { ...task, ...(cost === undefined ? {} : { tokens: cost }), ...(told === undefined ? {} : { report: told }), ...(lines === undefined ? {} : { summary: lines }) }
521
522        if (isActive(task) || next.status !== task.status) box.ended = next
523        if (gain !== undefined) box.gain = [task.member, gain]
524
525        return next
526      })
527
528      return box.all
529    })
530    if (box.gain !== undefined) {
531      const [member, gain] = box.gain
532
533      await update($, paid, was => ({ ...was, [member]: plus(was[member] ?? ZERO, gain) }))
534    }
535
536    const { ended } = box
537
538    if (ended === undefined || !(await read($, isOn))) return
539    // A toast is read once and gone: it is in the language of the moment.
540    const spoke = await langOf($)
541    const isOk = ended.status === 'done'
542    const situation = isOk ? 'done' : 'fail'
543    const reading = readOf(ended.member, situation, ended.role)
544    const lines: Line[] = [[ended.member, situation, ended.quote, ended.note]]
545    const toasts = [`${MEMBERS[ended.member].mark} ${CAST[spoke].names[ended.member]} ${shown(ended.quote[spoke])} ${ended.note[spoke]}`]
546
547    if (reading !== undefined) lines.push([LEAD, situation, reading, inAll(lang => (isOk ? WORDS[lang].reading : WORDS[lang].mourning)(CAST[lang].names[ended.member]))])
548    if (!box.all.some(isActive)) {
549      const since = await read($, waveAt)
550      const wave = box.all.filter(task => task.startedAt >= since)
551      const failed = wave.filter(task => task.status === 'failed').length
552      const quote = inAll(lang => (failed > 0 ? CAST[lang].leader.cheer : wave.length >= 2 ? CAST[lang].leader.sweep : CAST[lang].leader.allDone))
553      const note = inAll(lang => (failed > 0 ? WORDS[lang].waveFailed(wave.length, failed) : WORDS[lang].waveDone(wave.length)))
554
555      lines.push([LEAD, failed > 0 ? 'fail' : 'done', quote, note])
556      if (wave.length >= 2) toasts.push(`${MEMBERS[LEAD].mark} ${CAST[spoke].names[LEAD]} “${quote[spoke]}” ${note[spoke]}`)
557    }
558    await stamp($, await tell($, now, lines))
559    for (const toast of toasts) $.ui.toast(toast)
560  })
561
562const isUnnamed = (task: Task): boolean => task.kind === 'agent' && task.id.startsWith('spawn:') && isActive(task)
563
564/** What the spawn hook names a subagent's task: the engine's list has its row under it. */
565const described = (spoke: Lang, task: Pick<Task, 'member' | 'title'>): string => `${MEMBERS[task.member].mark} ${CAST[spoke].names[task.member]} · ${task.title}`
566
567/** Whether a row of the engine's list is under the name the spawn hook gave a task, in whichever language it was given. */
568const isDescribed = (description: string, task: Pick<Task, 'member' | 'title'>): boolean => LANGS.some(one => description === described(one, task))
569
570/** Whether a row of the engine's list may be a task's: under the task's own title, whole, or under the name the spawn hook gave it. A title that only ends as the task's does is another's. */
571const isRowOf = (description: string, task: Pick<Task, 'member' | 'title'>): boolean => description === task.title || isDescribed(description, task)
572
573/** The Agent calls whose spawn has yet to answer: the task of each is still to be told its id. */
574const asking = new Set<string>()
575/** Ends that came before their task had its id, oldest first, each with the turn it is of: they wait for the task the id is given to. */
576const early = new Map<string, { turn: string; outcome: Outcome }[]>()
577
578/** Ends the task an id was just given to, where word of its turns' ends came before the id did: each in the order it came. */
579const catchUp = async ($: Engine, agentId: string): Promise<void> => {
580  const waiting = early.get(agentId)
581
582  if (waiting === undefined || !(await read($, tasks)).some(task => task.id === agentId)) return
583  early.delete(agentId)
584  for (const { outcome } of waiting) await settle($, agentId, outcome)
585}
586
587/**
588 * Gives a subagent's task that waits for its id the one its loop's events
589 * carry: the task the agent's own row tells, by its type and title and,
590 * where several share those, by the name the spawn hook gave it. A row under
591 * another title is no waiting task's, however few wait. Tasks that still
592 * cannot be told apart are not guessed between while a spawn's answer is to
593 * come and tell them. One write decides it, so no task is given two ids and
594 * no id two tasks.
595 */
596const claim = async ($: Engine, agentId: string, info: Pick<AgentInfo, 'type' | 'description'>): Promise<void> => {
597  await update($, tasks, list => {
598    if (list.some(task => task.id === agentId)) return list
599    const fits = list.filter(task => isUnnamed(task) && info.type === task.engine && isRowOf(info.description, task))
600    const named = fits.filter(task => isDescribed(info.description, task))
601    const alike = named.length > 0 ? named : fits
602    // Alike in every way, and their spawns answered no id, so none is coming: either may have the row.
603    const match = alike.length === 1 || alike.every(task => !asking.has(task.call ?? '')) ? alike[0] : undefined
604
605    return match === undefined ? list : list.map(task => (task === match ? { ...task, id: agentId } : task))
606  })
607  await catchUp($, agentId)
608}
609
610/** Gives the task an Agent call made the id its spawn answered; a task that took that id by mistake goes back to waiting. */
611const bind = async ($: Engine, call: string, agentId: string): Promise<void> => {
612  await update($, tasks, list =>
613    list.map(task => {
614      if (task.kind !== 'agent') return task
615      if (task.call === call) return { ...task, id: agentId }
616
617      return task.id === agentId ? { ...task, id: `spawn:${task.call ?? agentId}` } : task
618    }),
619  )
620  await catchUp($, agentId)
621}
622
623/** Gives a subagent's task the id its loop's events carry, where the engine's list has the agent's row to tell it by. */
624const adopt = async ($: Engine, agentId: string): Promise<void> => {
625  const list = await read($, tasks)
626
627  if (list.some(task => task.id === agentId) || !list.some(isUnnamed)) return
628  const info = (await $.agent.list().catch(() => [])).find(agent => agent.id === agentId)
629
630  if (info !== undefined) await claim($, agentId, info)
631}
632
633/**
634 * Word that a subagent's turn ended. Where a task still waits for its id,
635 * the word is kept for the task the id is given to, so that it ends no
636 * other; the same turn told twice is kept once. Where the task's record was
637 * let go on a guess of how it ended, what the turn cost is still its
638 * character's.
639 */
640const finish = async ($: Engine, agentId: string, turn: string, outcome: Outcome): Promise<void> => {
641  await adopt($, agentId)
642  const list = await read($, tasks)
643
644  if (list.some(task => task.id === agentId)) return settle($, agentId, outcome)
645  const member = (await read($, owed)).find(one => one.id === agentId)?.member
646
647  if (member !== undefined) {
648    await update($, paid, was => ({ ...was, [member]: plus(was[member] ?? ZERO, outcome.tokens) }))
649
650    return
651  }
652  const waiting = early.get(agentId) ?? []
653
654  if (!list.some(isUnnamed) || waiting.some(one => one.turn === turn)) return
655  // The agent last heard of is the last to go when there are too many.
656  early.delete(agentId)
657  early.set(agentId, [...waiting, { turn, outcome }].slice(-EARLY_KEPT))
658  for (const id of [...early.keys()].slice(0, Math.max(0, early.size - EARLY_KEPT))) early.delete(id)
659}
660
661/** Notes the tool a character's loop is on; a finished one who works again is back at work. */
662const touch = async ($: Engine, agentId: string, tool: string, detail: string): Promise<void> => {
663  await adopt($, agentId)
664  if (!(await read($, tasks)).some(task => task.id === agentId)) return
665  await update($, tasks, list =>
666    list.map(task => {
667      if (task.id !== agentId) return task
668      const { endedAt: _endedAt, isGuessed: _isGuessed, ...rest } = task
669
670      return isActive(task)
671        ? { ...task, tool, detail, toolCount: task.toolCount + 1 }
672        : { ...rest, status: 'running' as const, tool, detail, toolCount: task.toolCount + 1 }
673    }),
674  )
675}
676
677/** Why a subagent's turn ended short of an answer, as a language says it. */
678const ENDS: Readonly<Record<string, (words: Words) => string>> = { aborted: words => words.stopped, error: words => words.apiError, refusal: words => words.refused }
679
680const tokensOf = (spent: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }): Tokens => ({
681  fresh: spent.input_tokens + spent.cache_creation_input_tokens,
682  cached: spent.cache_read_input_tokens,
683  out: spent.output_tokens,
684})
685
686/** Reads the session's own figures; writes them only when one moved. */
687const refreshUsage = async ($: Engine): Promise<void> => {
688  const figures = await $.session.usage()
689  const next: Usage = {
690    contextWindow: figures.context.window,
691    limits: figures.rateLimits.map(limit => {
692      const resetsAt = limit.resetsAt === undefined ? Number.NaN : Date.parse(limit.resetsAt)
693
694      return { kind: limit.kind, percentUsed: limit.percentUsed, ...(Number.isNaN(resetsAt) ? {} : { resetsAt }) }
695    }),
696    ...(figures.context.percent === undefined ? {} : { contextPercent: figures.context.percent }),
697    ...(figures.context.tokens === undefined ? {} : { contextTokens: figures.context.tokens }),
698    ...(figures.cost === undefined ? {} : { usd: figures.cost.usd }),
699  }
700
701  if (JSON.stringify(next) !== JSON.stringify(await read($, usage))) await update($, usage, () => next)
702}
703
704/** When a file was last written; nothing where there is none to be found. */
705const writtenAt = ($: Engine, path: string): Promise<number | undefined> =>
706  $.fs.stat(path).then(
707    stat => stat.mtimeMs,
708    () => undefined,
709  )
710
711/** Ends an Orca worker whose `<out>.meta.json` has landed since it started; one that was there before it started is an earlier run's. */
712const pollWorker = async ($: Engine, task: Task): Promise<void> => {
713  if (task.out === undefined) return
714  const path = `${task.out}.meta.json`
715  const at = await writtenAt($, path)
716
717  if (at === undefined || at < task.startedAt - 1000 || at === task.staleAt) return
718  const text = await $.fs.read(path).catch(() => '')
719  // How it went and what it cost are the same in any language; how long it took and how it exited are said again as each says them.
720  const meta = readMeta(text, WORDS.en)
721
722  if (meta === undefined) return
723  const handed = await $.fs.read(task.out).catch(() => '')
724
725  await settle($, task.id, { isOk: meta.isOk, why: words => readMeta(text, words)?.note ?? '', tokens: meta.tokens, summary: firstLines(handed, SUMMARY_LINES), opener: openerOf(handed) })
726}
727
728/**
729 * Writes a character's copy of a spec, whose text was read, beside it and
730 * answers where. A file found at the copy's place is not written over: one
731 * that holds this very text is used as it is, and past any other the copy
732 * takes the next numbered place with nothing at it. The engine has no write
733 * that fails where a file is: the place is looked at right before the write
734 * and read again after it, and a copy that does not read back as it was
735 * written is not used. Nothing then, as where every place is taken: the
736 * launch runs on the spec as it was written, without the voice.
737 */
738const voiceSpec = ($: Engine, spec: string, text: string, task: Task): Promise<string | undefined> =>
739  inCopy(async () => {
740    const copy = text + orcaBlock(await langOf($), task.member, task.role)
741
742    for (let turn = 1; turn <= COPIES; turn += 1) {
743      const path = voicedSpecPath(spec, turn === 1 ? task.member : `${task.member}-${turn}`)
744      // A link that leads nowhere is something there too: only a place where nothing can be found is written to.
745      if ((await writtenAt($, path)) === undefined) {
746        const landed = await $.fs
747          .write(path, copy)
748          .then(() => $.fs.read(path))
749          .catch(() => undefined)
750
751        return landed === copy ? path : undefined
752      }
753      if ((await $.fs.read(path).catch(() => undefined)) === copy) return path
754    }
755
756    return undefined
757  })
758
759/**
760 * Takes a task whose end was never learned off the page, said aloud by
761 * 노동 갑옷 씨 so it does not go quietly: it is not told as done or failed.
762 * One that has ended in the meantime stays. `why` is the line for it: a
763 * worker with no result, or a subagent never told apart from the others.
764 */
765const letGo = ($: Engine, id: string, now: number, why: (words: Words) => (name: string, title: string) => string): Promise<void> =>
766  inTurn(async () => {
767    const box: { gone?: Task } = {}
768
769    await update($, tasks, list => {
770      box.gone = list.find(task => task.id === id && isActive(task))
771
772      return box.gone === undefined ? list : list.filter(task => task !== box.gone)
773    })
774
775    const { gone } = box
776
777    // Turned off, the mode lets it go all the same, and says nothing of it.
778    if (gone === undefined || !(await read($, isOn))) return
779
780    await stamp($, await tell($, now, [['rodo', 'slow', lineOf('rodo', 'slow', 0), inAll(lang => why(WORDS[lang])(CAST[lang].names[gone.member], titleOf(lang, gone)))]]))
781  })
782
783/** One beat of an Orca worker: a demonstration ends when it is due, a real one when its result has landed. */
784const checkWorker = async ($: Engine, task: Task, now: number): Promise<void> => {
785  if (task.due !== undefined) {
786    const { at, isOk } = task.due
787    // A demonstration's title opens with the word for one, in every language.
788    const report = inAll(lang => WORDS[lang].demoReport(titleOf(lang, task).replace(/^[^:]+: /, '')))
789
790    if (now >= at) await settle($, task.id, { isOk, why: words => words.demo, ...(isOk ? { report } : {}) })
791
792    return
793  }
794  await pollWorker($, task)
795  // A result that landed just now has ended it: only one still at work is let go or marked.
796  if (!(await read($, tasks)).some(one => one.id === task.id && isActive(one))) return
797  // Its end cannot be learned with no result file to watch, and is not coming after two hours with none: it is let go.
798  if (now - task.startedAt > (task.out === undefined ? LOST_MS : LOST_MS * 2)) return letGo($, task.id, now, words => words.letGo)
799  if (task.out !== undefined && task.status === 'running' && now - task.startedAt > LOST_MS) {
800    const note = inAll(lang => WORDS[lang].noResult)
801
802    await update($, tasks, list => list.map(one => (one.id === task.id && isActive(one) ? { ...one, status: 'waiting' as const, note } : one)))
803  }
804}
805
806/**
807 * Whether a worker's end is in doubt: its launch was taken to have failed
808 * though it was handed on, so it may be running all the same, and its result
809 * is still looked for as long as one at work would be waited for.
810 */
811const isDoubted = (task: Task, now: number): boolean => task.kind === 'orca' && task.isGuessed === true && task.out !== undefined && now - task.startedAt <= LOST_MS * 2
812
813/** Beats in a row each agent has been missing from the engine's list. */
814const unlisted = new Map<string, number>()
815
816/** One beat of a subagent: what the engine's list says of it is what the task shows. */
817const checkAgent = async ($: Engine, task: Task, agents: readonly AgentInfo[], now: number): Promise<void> => {
818  const info = agents.find(agent => agent.id === task.id)
819
820  if (info !== undefined) {
821    unlisted.delete(task.id)
822    if (info.status === 'completed') await settle($, task.id, { isOk: true, isGuessed: true })
823    else if (info.status === 'failed' || info.status === 'killed') await settle($, task.id, { isOk: false, ...(info.status === 'killed' ? { why: words => words.stopped } : {}), isGuessed: true })
824    else if ((info.status === 'waiting') !== (task.status === 'waiting')) {
825      const status = info.status === 'waiting' ? ('waiting' as const) : ('running' as const)
826
827      await update($, tasks, list => list.map(one => (one.id === task.id && isActive(one) ? { ...one, status } : one)))
828    }
829
830    return
831  }
832  if (task.id.startsWith('spawn:')) {
833    // Its spawn answered no id: the list has it under the description the
834    // spawn hook gave it, as a row no task holds yet.
835    const held = new Set((await read($, tasks)).map(one => one.id))
836    const found = agents.find(agent => !held.has(agent.id) && agent.type === task.engine && isRowOf(agent.description, task))
837
838    if (found !== undefined) await claim($, found.id, found)
839
840    return
841  }
842  if (now - task.startedAt <= 30_000) return
843  const beats = (unlisted.get(task.id) ?? 0) + 1
844
845  unlisted.set(task.id, beats)
846  if (beats < UNLISTED_BEATS) return
847  // The engine lists an agent until it drops its task: one it has not listed
848  // for a while has ended, and its turn's end did not reach this hook.
849  unlisted.delete(task.id)
850  await settle($, task.id, { isOk: true, why: words => words.unseen, isGuessed: true })
851}
852
853/** A task three minutes on: its character says so, once, and 모몽가 with nothing to do grumbles at the wait. */
854const nudge = async ($: Engine, id: string, now: number): Promise<void> => {
855  const box: { slow?: Task; isMomongaBusy: boolean } = { isMomongaBusy: false }
856
857  await update($, tasks, list => {
858    box.slow = undefined
859    box.isMomongaBusy = list.some(one => one.member === 'momonga' && isActive(one))
860
861    return list.map(one => {
862      if (one.id !== id || !isActive(one) || one.isSlow === true || now - one.startedAt < SLOW_MS) return one
863      box.slow = { ...one, isSlow: true, quote: lineOf(one.member, 'slow', 0), note: inAll(lang => WORDS[lang].keeping(CAST[lang].job[one.role], WORDS[lang].spoken(now - one.startedAt))), mood: MOODS.slow }
864
865      return box.slow
866    })
867  })
868
869  const { slow } = box
870
871  if (slow === undefined) return
872  const lines: Line[] = [[slow.member, 'slow', slow.quote, slow.note]]
873
874  if (!box.isMomongaBusy) lines.push(['momonga', 'slow', lineOf('momonga', 'slow', 0), inAll(lang => WORDS[lang].waitingFor(CAST[lang].names[slow.member], WORDS[lang].spoken(now - slow.startedAt)))])
875  await tell($, now, lines)
876}
877
878let isTicking = false
879
880/** One beat: asks after every character at work, and moves the clock the drawings read. */
881const tick = async ($: Engine): Promise<void> => {
882  if (isTicking) return
883  isTicking = true
884  try {
885    // A session begun by a `/clear` is given what is kept here, where nothing was asked of the mode yet: a drawing may not write.
886    await ensure($).catch(() => undefined)
887    if (!(await read($, isOn))) {
888      isBusy = false
889
890      return
891    }
892    const list = await read($, tasks)
893    const active = list.filter(isActive)
894    const now = await $.clock.now()
895
896    for (const task of list.filter(one => isDoubted(one, now))) await pollWorker($, task).catch(() => undefined)
897    isBusy = active.length > 0
898    if (active.length === 0) {
899      unlisted.clear()
900      early.clear()
901      // The clock runs on until the last line said has aged out and the band
902      // has left, and until a hand lent is seen to have been taken back;
903      // after that a quiet session costs a few reads a beat.
904      const said = await read($, feed)
905      const drawn = await read($, clockNow)
906      const lent = Object.values(await read($, aids))
907
908      isBusy = lent.some(aid => now - aid.at < AID_MS)
909      if (drawn - (said[said.length - 1]?.at ?? 0) < LINGER_MS || lent.some(aid => drawn - aid.at < AID_MS)) await stamp($, now)
910
911      return
912    }
913    // A list that could not be read says nothing: nobody is ended on its silence.
914    const agents = active.some(task => task.kind === 'agent') ? await $.agent.list().catch(() => undefined) : []
915
916    for (const task of active) {
917      if (task.kind === 'orca') await checkWorker($, task, now)
918      else if (agents !== undefined) await checkAgent($, task, agents, now)
919      // A subagent's task no agent was told to be whose after two hours has an end that cannot be learned, as a worker's with no result: it is let go.
920      if (isUnnamed(task) && now - task.startedAt > LOST_MS * 2) await letGo($, task.id, now, words => words.lost)
921    }
922    for (const task of (await read($, tasks)).filter(isActive)) {
923      if (task.isSlow !== true && now - task.startedAt >= SLOW_MS) await nudge($, task.id, now)
924    }
925
926    await refreshUsage($).catch(() => undefined)
927    await stamp($, now)
928  } finally {
929    isTicking = false
930  }
931}
932
933/** Opens the pane because the person asked for it: it seats at any width, and stays theirs to close. */
934const openPane = async ($: Engine): Promise<void> => {
935  await refreshUsage($).catch(() => undefined)
936  await stamp($)
937  await $.ui.open({ id: PANE, title: WORDS[await langOf($)].brand, closeOnEscape: true, ...PANE_SIZE })
938  await update($, isPaneOpen, () => true)
939  await update($, isPaneDismissed, () => false)
940}
941
942/**
943 * Shows one character's task in full in the pane, opening it; asking for the
944 * one already shown, or for none, puts it away.
945 */
946const watch = async ($: Engine, id: MemberId | null): Promise<void> => {
947  const box: { now: MemberId | null } = { now: null }
948
949  await update($, watched, was => {
950    box.now = was === id ? null : id
951
952    return box.now
953  })
954  if (box.now !== null && !(await read($, isPaneOpen))) await openPane($)
955}
956
957/** A character with its mark before its name, as a line about it opens. */
958const whoOf = (spoke: Lang, id: MemberId): string => `${MEMBERS[id].mark} ${CAST[spoke].names[id]}`
959
960/** How many times the person has changed the language and the roles: what was kept is not read in over a change made while it was being read. */
961const edits = { lang: 0, roles: 0 }
962
963/**
964 * Keeps the roles for later sessions and answers whether they were kept.
965 * Each save is of the roles as they stand when its turn comes, so the last
966 * one made holds the last change whatever order the changes were made in.
967 * Roles that were never read into this session are not saved over: what is
968 * kept may hold more than the session knows of.
969 */
970const keep = ($: Engine): Promise<boolean> =>
971  inSave(async () => {
972    const box = { isRead: false }
973
974    // Whether they were read may have been written by another hook's read, since this one began: what stands now is what an update is handed.
975    await update($, isRolesRead, stands => (box.isRead = stands))
976
977    return (
978      box.isRead &&
979      $.store.set(ROLES_KEPT, await read($, roles)).then(
980        () => true,
981        () => false,
982      )
983    )
984  })
985
986/**
987 * Gives a character a role, or with none its own again; with no character
988 * everyone has their own again. A change is kept for later sessions, and the
989 * main loop is told of it beside the next prompt.
990 */
991const assign = async ($: Engine, id: MemberId | null, role: WorkRole | null): Promise<{ now: Roles; isChanged: boolean; isKept: boolean }> => {
992  const box: { now: Roles; isChanged: boolean } = { now: {}, isChanged: false }
993
994  edits.roles += 1
995  await update($, roles, was => {
996    box.now = id === null ? {} : withRole(was, id, role)
997    box.isChanged = WORKERS.some(one => was[one] !== box.now[one])
998
999    return box.now
1000  })
1001  if (!box.isChanged) return { ...box, isKept: true }
1002  await update($, roleVersion, version => version + 1)
1003
1004  return { ...box, isKept: await keep($).catch(() => false) }
1005}
1006
1007/** A role pressed on a character's sheet. */
1008const press = async ($: Engine, id: MemberId, role: WorkRole): Promise<void> => {
1009  const { isChanged, isKept } = await assign($, id, role)
1010
1011  const spoke = await langOf($)
1012
1013  if (isChanged) $.ui.toast(WORDS[spoke].roleToast(whoOf(spoke, id), CAST[spoke].roles[role], role === MEMBERS[id].role, isKept))
1014}
1015
1016/** Who has which role, a line each, and how to change one. */
1017const roleLines = (spoke: Lang, given: Roles): string[] => {
1018  const words = WORDS[spoke]
1019  const { roles: labels, specialties } = CAST[spoke]
1020
1021  return [
1022    words.rolesTitle,
1023    ...WORKERS.map(id => words.roleRow(whoOf(spoke, id), labels[roleOf(id, given)], jobOf(spoke, id, given), given[id] === undefined ? undefined : labels[MEMBERS[id].role])),
1024    '',
1025    ...words.roleHelp(
1026      WORK_ROLES.map(role => labels[role]),
1027      specialties,
1028    ),
1029  ]
1030}
1031
1032/** The words that give a character, or everyone, the role that is its own, in any of the languages. */
1033const RESET = /^(?:reset|default|original|기본|원래|원래대로|초기화|リセット|デフォルト|初期化|元に戻す)$/
1034
1035/** `/chiikawa role ...`: with nothing more who has which role; a name and a role gives it; `reset` gives everyone their own. */
1036const runRole = async ($: Engine, words: readonly string[]): Promise<string> => {
1037  const last = words.at(-1) ?? ''
1038  const spoke = await langOf($)
1039  const said = WORDS[spoke]
1040  const { names, roles: labels, specialties } = CAST[spoke]
1041
1042  if (words.length === 0) return roleLines(spoke, await read($, roles)).join('\n')
1043  if (words.length === 1 && RESET.test(last)) {
1044    const { isChanged, isKept } = await assign($, null, null)
1045
1046    return isChanged ? said.rolesReset(isKept) : said.rolesSame
1047  }
1048  const id = memberNamed(words.slice(0, -1).join(' '))
1049  const role = roleNamed(last)
1050
1051  if (id === LEAD) return said.leadRole
1052  if (id === undefined || (role === undefined && !RESET.test(last))) return [said.roleUnknown, '', ...roleLines(spoke, await read($, roles))].join('\n')
1053  const { now, isChanged, isKept } = await assign($, id, role ?? null)
1054  const who = whoOf(spoke, id)
1055  const own = labels[MEMBERS[id].role]
1056  const given = labels[roleOf(id, now)]
1057
1058  if (!isChanged) return said.roleSame(who, given)
1059
1060  return now[id] === undefined ? said.roleBack(who, own, isKept) : said.roleGiven(who, names[id], given, own, specialties, isKept)
1061}
1062
1063/** The person chooses who takes their next prompt; choosing the same one again takes the choice back. Answers the choice now. */
1064const pick = async ($: Engine, id: MemberId): Promise<MemberId | null> => {
1065  const box: { now: MemberId | null } = { now: null }
1066
1067  await update($, picked, was => {
1068    box.now = was === id ? null : id
1069
1070    return box.now
1071  })
1072  const spoke = await langOf($)
1073
1074  $.ui.toast(box.now === null ? WORDS[spoke].unpicked(whoOf(spoke, id)) : WORDS[spoke].pickedToast(MEMBERS[id].mark, CAST[spoke].names[id]))
1075
1076  return box.now
1077}
1078
1079/** Who takes the next prompt was pressed: a chosen friend is let go, and the person is told how the choosing goes. */
1080const auto = async ($: Engine): Promise<void> => {
1081  const was = await read($, picked)
1082
1083  if (was !== null) await update($, picked, () => null)
1084  const spoke = await langOf($)
1085
1086  $.ui.toast(was === null ? WORDS[spoke].autoHow : WORDS[spoke].autoBack(whoOf(spoke, was)))
1087}
1088
1089/** The demonstration's four tasks, in the order of their titles: who takes each as what, for how long, and how it ends. */
1090const DEMO: readonly (readonly [Role, MemberId, number, boolean])[] = [
1091  ['탐색', 'usagi', 4000, true],
1092  ['구현', 'rakko', 11_000, true],
1093  ['구현', 'chiikawa', 7000, false],
1094  ['검토', 'kurimanju', 14_000, true],
1095]
1096
1097/**
1098 * Keeps the languages for later sessions and answers whether they were kept.
1099 * Where they were not, they hold for this session all the same, and the
1100 * session notes that they are still to be kept. Languages that were never
1101 * read into this session are not saved over.
1102 */
1103const keepLang = ($: Engine): Promise<boolean> =>
1104  inSave(async () => {
1105    const box = { isRead: false }
1106
1107    await update($, isLangRead, stands => (box.isRead = stands))
1108    const isKept =
1109      box.isRead &&
1110      (await $.store.set(LANG_KEPT, await read($, langKept)).then(
1111        () => true,
1112        () => false,
1113      ))
1114
1115    await update($, isLangUnkept, () => !isKept)
1116
1117    return isKept
1118  })
1119
1120/**
1121 * Notes the language a prompt was typed in, where it tells one, for this
1122 * session and the ones after it. Answers whether that is another than was
1123 * noted, and whether what is noted is kept: a save that failed is tried
1124 * again with each prompt until one holds, and nothing is said of that one.
1125 */
1126const noteTyped = async ($: Engine, text: string): Promise<{ isNews: boolean; isKept: boolean }> => {
1127  const typed = typedLang(text)
1128  const isNews = typed !== undefined && (await read($, langKept)).typed !== typed
1129
1130  if (isNews) {
1131    edits.lang += 1
1132    await update($, langKept, was => ({ ...was, typed }))
1133  }
1134
1135  return { isNews, isKept: isNews || (await read($, isLangUnkept)) ? await keepLang($).catch(() => false) : true }
1136}
1137
1138const AUTO = /^(?:auto|자동|自動)$/
1139
1140/**
1141 * `/chiikawa lang ...`: with nothing more the language and what settled it; a
1142 * language chooses it; `auto` takes the choice back, and with it the language
1143 * noted from the prompts typed so far, which is the way out of a language
1144 * the letters turned the screen to by mistake.
1145 */
1146const runLang = async ($: Engine, asked: readonly string[]): Promise<string> => {
1147  const [word = ''] = asked
1148
1149  if (asked.length === 0) {
1150    const { lang: now, from } = await settledLang($)
1151
1152    return [WORDS[now].langNow(LANG_NAMES[now], from), WORDS[now].langHow].join('\n')
1153  }
1154  const chosen = langNamed(word)
1155
1156  if (asked.length > 1 || (chosen === undefined && !AUTO.test(word))) return WORDS[await langOf($)].langUnknown
1157  edits.lang += 1
1158  await update($, langKept, was => (chosen === undefined ? {} : { ...was, chosen }))
1159  const isKept = await keepLang($).catch(() => false)
1160
1161  await follow($)
1162  const { lang: now, from } = await settledLang($)
1163  const words = WORDS[now]
1164
1165  if (langWanted !== 'auto') return [words.langFixed(LANG_NAMES[langWanted]), ...(isKept ? [] : [words.unkept])].join('\n')
1166
1167  return chosen === undefined ? [words.langAuto, ...(isKept ? [] : [words.unkept]), words.langNow(LANG_NAMES[now], from)].join('\n') : words.langChosen(LANG_NAMES[chosen], isKept)
1168}
1169
1170let timer: Timer | undefined
1171let mover: Timer | undefined
1172
1173/**
1174 * Reads what is kept over sessions into this one: the language, the roles,
1175 * whether the pane is up, the usage. What the person changed while it was
1176 * being read is the newer, and is left as they made it.
1177 */
1178const load = async ($: Engine): Promise<void> => {
1179  const { lang: spokenEdits, roles: roleEdits } = edits
1180  // The language chosen or typed in an earlier session is the language from the first screen on. Where it cannot be read, it stays as it is.
1181  const spoken = await $.store.get(LANG_KEPT).then(langKeptFrom, () => undefined)
1182
1183  // Only what was read in counts as read: what the session does not hold of what is kept, it must not save over.
1184  if (spoken !== undefined && edits.lang === spokenEdits) {
1185    await update($, langKept, () => spoken)
1186    await update($, isLangRead, () => true)
1187  }
1188  await refreshLook($).catch(() => false)
1189  await enlist($)
1190  // The roles the person gave in an earlier session are the roles still. Where they cannot be read, the roles stay as they are.
1191  const kept = await $.store.get(ROLES_KEPT).then(rolesFrom, () => undefined)
1192
1193  if (kept !== undefined && edits.roles === roleEdits) {
1194    const box = { isChanged: false }
1195
1196    await update($, roles, was => {
1197      box.isChanged = WORKERS.some(id => was[id] !== kept[id])
1198
1199      return kept
1200    })
hooks/cast.ts 405 lines
1import type { Color } from 'claude-code'
2
3import type { Lang, MemberId, Mood, Phrase, Role, Roles, Task, WorkRole } from '../types'
4
5import { EN } from './cast.en'
6import { JA } from './cast.ja'
7import { KO } from './cast.ko'
8import { asIs, inAll, LANGS } from './words'
9
10/** What a character is whatever the language: its mark, its own role, whether it talks, its colors. */
11export type Member = {
12  id: MemberId
13  /** A mark in plain text, for a toast and an agent's description. */
14  mark: string
15  role: Role
16  /** Whether the character says whole sentences in the comic. */
17  speaks: boolean
18  /** The symbol color: the name badge. */
19  color: string
20  ink: string
21  /** The same hue as a line on a dark terminal; `toneOf` gives its fellow for a light one. */
22  line: Color
23}
24
25export const PINK = '#f7a8c0'
26export const LEAD: MemberId = 'hachiware'
27/** The three the comic is about: always on the page, whatever they are doing. */
28export const CORE: readonly MemberId[] = ['hachiware', 'chiikawa', 'usagi']
29
30// The pastel hues are drawn for a dark terminal; on a light one each gives
31// way to a deeper fellow that reads against white.
32const ON_LIGHT: Readonly<Record<string, string>> = {
33  '#6fa0ea': '#2f62b8',
34  '#f7a8c0': '#c2457a',
35  '#f3dc6b': '#8a6d00',
36  '#d8c36a': '#7a6a1c',
37  '#e0605a': '#b8322c',
38  '#f08a24': '#b05a00',
39  '#f6c9a0': '#a8703a',
40  '#c08a52': '#7a4a1e',
41  '#f0dcb4': '#9a7a3c',
42  '#e8566a': '#b82f48',
43  '#f29bb0': '#c23a66',
44  '#c9c9c9': '#6e6e6e',
45  '#f48fb1': '#b8477a',
46  '#a8d8f0': '#1f7fa8',
47  '#a89f91': '#6b6257',
48}
49
50/** One of this mod's colors as it reads on the person's theme; a theme's own key is left to the theme. */
51export const toneOf = (color: Color, isLight: boolean): Color => (isLight ? (ON_LIGHT[color] ?? color) : color)
52
53export const MEMBERS: Record<MemberId, Member> = {
54  hachiware: {
55    id: 'hachiware',
56    mark: '🐱',
57    role: '지휘',
58    speaks: true,
59    color: '#4a7fd4',
60    ink: '#ffffff',
61    line: '#6fa0ea',
62  },
63  chiikawa: {
64    id: 'chiikawa',
65    mark: '🐹',
66    role: '구현',
67    speaks: false,
68    color: '#f7a8c0',
69    ink: '#000000',
70    line: '#f7a8c0',
71  },
72  usagi: {
73    id: 'usagi',
74    mark: '🐰',
75    role: '탐색',
76    speaks: false,
77    color: '#f3dc6b',
78    ink: '#000000',
79    line: '#f3dc6b',
80  },
81  rakko: {
82    id: 'rakko',
83    mark: '🦦',
84    role: '구현',
85    speaks: true,
86    color: '#8c7a2e',
87    ink: '#ffffff',
88    line: '#d8c36a',
89  },
90  shisa: {
91    id: 'shisa',
92    mark: '🦁',
93    role: '구현',
94    speaks: true,
95    color: '#f08a24',
96    ink: '#000000',
97    line: '#f08a24',
98  },
99  kurimanju: {
100    id: 'kurimanju',
101    mark: '🌰',
102    role: '검토',
103    speaks: false,
104    color: '#8a5a2b',
105    ink: '#ffffff',
106    line: '#c08a52',
107  },
108  kani: {
109    id: 'kani',
110    mark: '🦀',
111    role: '조사',
112    speaks: false,
113    color: '#e8566a',
114    ink: '#ffffff',
115    line: '#f29bb0',
116  },
117  pochette: {
118    id: 'pochette',
119    mark: '👛',
120    role: '구현',
121    speaks: true,
122    color: '#c9c9c9',
123    ink: '#000000',
124    line: '#f48fb1',
125  },
126  momonga: {
127    id: 'momonga',
128    mark: '🍑',
129    role: '검토',
130    speaks: true,
131    color: '#a8d8f0',
132    ink: '#000000',
133    line: '#a8d8f0',
134  },
135  rodo: {
136    id: 'rodo',
137    mark: '🔔',
138    role: '지휘',
139    speaks: true,
140    color: '#a89f91',
141    ink: '#000000',
142    line: '#a89f91',
143  },
144}
145
146export const ORDER: readonly MemberId[] = ['hachiware', 'chiikawa', 'usagi', 'rakko', 'shisa', 'kurimanju', 'kani', 'pochette', 'momonga', 'rodo']
147/** The ones who take tasks: 하치와레 conducts, and 노동 갑옷 씨 only rings the bell. */
148export const WORKERS: readonly MemberId[] = ['chiikawa', 'usagi', 'rakko', 'shisa', 'kurimanju', 'kani', 'pochette', 'momonga']
149
150export type Situation = 'idle' | 'start' | 'done' | 'fail' | 'slow' | 'denied'
151
152export const MOODS: Record<Situation, Mood> = { idle: 'calm', start: 'calm', done: 'glad', fail: 'sad', slow: 'tired', denied: 'shock' }
153
154const SITUATIONS: readonly Situation[] = ['idle', 'start', 'done', 'fail', 'slow', 'denied']
155
156/** 하치와레's own lines for what he does as the conductor. */
157export type Leader = Record<'idle' | 'allDone' | 'sweep' | 'trouble' | 'cry' | 'cheer' | 'sure' | 'found', string>
158
159/**
160 * The cast in one language: what each character is called and known for, the
161 * work it takes, how it talks and what it says. A language has every part,
162 * so one that lacks a line does not load.
163 */
164export type Cast = {
165  names: Record<MemberId, string>
166  /** A character's name where there is little room for it, for one whose name is long. */
167  short: Partial<Record<MemberId, string>>
168  /** The animal the character is drawn after; none where its name already says so. */
169  kinds: Record<MemberId, string>
170  /** What the character is known for, the reason it gets its kind of work. */
171  titles: Record<MemberId, string>
172  /** The work it takes, in a few words. */
173  jobs: Record<MemberId, string>
174  /** What the roles are called on the screen. */
175  roles: Record<Role, string>
176  /** What the kinds of work are called in the comic's own words. */
177  job: Record<Role, string>
178  /** The work of a role in a few words, for a character given a role that is not its own. */
179  duty: Record<WorkRole, string>
180  /** Whose each kind of work is, for the lines that tell of a role changed. */
181  specialties: string
182  /** A line or a gesture the character has for each occasion; a gesture is in parentheses. */
183  quotes: Record<MemberId, Record<Situation, readonly string[]>>
184  leader: Leader
185  /** His way of reading a friend who cannot talk: "그 말은 ○○라는 거?" with the blank filled. */
186  means: (what: string) => string
187  /** What the blank is filled with: a task taken, failed, passed, a thing found, a task done, a hand lent to find or to mend. */
188  read: Record<'taken' | 'failed' | 'passed' | 'found' | 'done' | 'seeking' | 'mending', string>
189  /**
190   * Every line a character's voice may use, for the instruction blocks. Each is
191   * a line or a sound the character has in the comic; where it was checked
192   * against a Japanese source, the original stands beside it.
193   */
194  lines: Record<MemberId, readonly string[]>
195  manner: Record<MemberId, string>
196  /** 하치와레 said politely, for a person who would rather be spoken to so. */
197  politeLeader: string
198}
199
200export const CAST: Record<Lang, Cast> = { en: EN, ko: KO, ja: JA }
201
202/** A character's name where there is little room for it. */
203export const shortName = (lang: Lang, id: MemberId): string => CAST[lang].short[id] ?? CAST[lang].names[id]
204
205/** One of the character's own lines for the situation; `turn` rotates them. */
206export const say = (lang: Lang, member: MemberId, situation: Situation, turn: number): string => {
207  const lines = CAST[lang].quotes[member][situation]
208
209  return lines[Math.abs(turn) % lines.length] ?? lines[0] ?? ''
210}
211
212/** Every sound and gesture a character has, as the comic spells it: its lines for each situation, and the ones its voice lists. */
213export const soundsOf = (lang: Lang, member: MemberId): string[] => [
214  ...Object.values(CAST[lang].quotes[member]).flat(),
215  ...CAST[lang].lines[member].flatMap(line => [...line.matchAll(/"([^"]+)"/g)].map(found => found[1] ?? '')),
216]
217
218/** A gesture is told, not said: it is drawn without quotation marks. */
219export const isGesture = (quote: string): boolean => quote.startsWith('(')
220
221export const shown = (quote: string): string => (isGesture(quote) ? quote : `“${quote}”`)
222
223/** A gesture as what was done, without the parentheses that mark it; a line as it is. */
224export const bare = (quote: string): string => quote.replace(/^\((.*)\)$/, '$1')
225
226/**
227 * Where the Korean cast has the very sound or gesture the Japanese one has,
228 * for the friends who cannot talk: for each occasion, the Korean place of
229 * the line at each Japanese place in turn, as far as one is known. The two
230 * need not be the same place, as わぁ~…… is the first of its occasion and
231 * 와아…… the second. Each was checked by hand, a sound against the original
232 * the Korean cast's own list names beside it, a gesture against what it
233 * tells. Two lines that only share a place are not here, as 나도! and
234 * ヤーッ!!: nothing is made of those. The English cast is the Japanese one put
235 * into English place for place, so every place of those two is one line.
236 */
237const ALIKE: Readonly<Partial<Record<MemberId, Partial<Record<Situation, readonly number[]>>>>> = {
238  chiikawa: { start: [0], done: [1], fail: [0], denied: [0, 1] },
239  usagi: { idle: [0, 1], start: [0, 1], done: [2, 3], fail: [0], slow: [0] },
240  kurimanju: { idle: [0], start: [0], done: [0, 1, 2], fail: [0], slow: [0], denied: [0] },
241  kani: { idle: [0, 1], start: [0], done: [0, 1], fail: [0], slow: [0], denied: [0] },
242}
243
244/** Each sound and gesture of a character as the languages have it, a Japanese place at a time: the Korean one only where it is that very line. */
245const voicesOf = (member: MemberId): Partial<Phrase>[] =>
246  SITUATIONS.flatMap(situation =>
247    CAST.ja.quotes[member][situation].map((ja, at) => {
248      const same = ALIKE[member]?.[situation]?.[at]
249
250      return { ja, en: CAST.en.quotes[member][situation][at], ko: same === undefined ? undefined : CAST.ko.quotes[member][situation][same] }
251    }),
252  )
253
254/**
255 * What a character wrote after its own name as all its report, as each
256 * language shows it. It is the worker's own writing and stays as written,
257 * but for one case: a character that cannot talk wrote one of its own sounds
258 * or gestures, letter for letter, and another language has that very line
259 * and no other for it. A gesture written without its parentheses is known by
260 * what it tells, and is shown without them. No line is made up: a language
261 * with no such line shows the one written.
262 */
263export const reported = (member: MemberId, line: string): Phrase => {
264  if (MEMBERS[member].speaks) return asIs(line)
265  const told = (voice: string): string => (isGesture(line) ? voice : bare(voice))
266  const known = voicesOf(member).filter(voice => LANGS.some(lang => voice[lang] !== undefined && told(voice[lang]) === line))
267
268  return inAll(lang => {
269    const [only, ...others] = new Set(known.flatMap(voice => voice[lang] ?? []))
270
271    return only === undefined || others.length > 0 ? line : told(only)
272  })
273}
274
275/** How 하치와레 reads what a friend who cannot talk just did. */
276export const readingOf = (lang: Lang, member: MemberId, situation: 'start' | 'done' | 'fail', role: Role): string | undefined => {
277  const { leader, means, read } = CAST[lang]
278
279  if (MEMBERS[member].speaks) return situation === 'fail' ? leader.trouble : undefined
280  if (situation === 'start') return means(read.taken)
281  if (situation === 'fail') return member === 'chiikawa' ? leader.cry : means(read.failed)
282  if (role === '검토') return means(read.passed)
283  if (role === '탐색') return leader.found
284
285  return means(role === '조사' ? read.found : read.done)
286}
287
288export const isActive = (task: Task): boolean => task.status === 'running' || task.status === 'waiting'
289
290/** What a task is called on a screen in that language: as it was handed over, but for one the mode named itself. */
291export const titleOf = (lang: Lang, task: Pick<Task, 'title' | 'titles'>): string => task.titles?.[lang] ?? task.title
292
293const PREFERENCE: Record<WorkRole, readonly MemberId[]> = {
294  구현: ['shisa', 'rakko', 'pochette', 'chiikawa'],
295  검토: ['kurimanju', 'momonga', 'rakko'],
296  조사: ['kani', 'shisa', 'usagi'],
297  탐색: ['usagi', 'kani', 'chiikawa'],
298}
299
300/**
301 * A test for the English words of a pattern taken whole, so that `format` is
302 * not found in `information`, and for the Korean and Japanese ones wherever
303 * they stand.
304 */
305const worded = (english: string, ...others: readonly string[]): RegExp => new RegExp([`(?<![a-z])(?:${english})(?![a-z])`, ...others].join('|'), 'i')
306
307const SPECIALTY: readonly (readonly [RegExp, MemberId])[] = [
308  [worded('security|adversarial|agy-review', '반박|보안', 'セキュリティ|反論|脆弱性'), 'momonga'],
309  [worded('front-?end|agy-pro|ui|(?:re)?design(?:s|er|ers|ed|ing)?|css', '화면|디자인|스타일', '画面|デザイン|スタイル'), 'pochette'],
310  [worded('root-cause|performance|codex-hard|debug(?:s|ged|ger|ging)?', '디버그|디버깅|원인|어려운', 'デバッグ|原因|難しい'), 'rakko'],
311  [worded('agy-fast|renam(?:e|es|ed|ing)|(?:re)?format(?:s|ted|ter|ting)?|typos?|clean-?up', '오타|포맷|이름 바꾸|풀 ?뽑기', '誤字|タイポ|フォーマット|リネーム|草むしり'), 'chiikawa'],
312]
313const SMALL = worded('haiku')
314
315/**
316 * The character a kind of work belongs to before its role is weighed: screens
317 * to 포쉐트 갑옷 씨, hard bugs to 랏코. Where two fit, the earlier test wins: a
318 * security check of a screen is 모몽가's, a screen's bug 포쉐트 갑옷 씨's.
319 */
320export const specialistOf = (engine: string, title: string, role?: Role): MemberId | undefined => {
321  const text = `${engine} ${title}`
322
323  return SPECIALTY.find(([test]) => test.test(text))?.[1] ?? (role === '구현' && SMALL.test(text) ? 'chiikawa' : undefined)
324}
325
326export const WORK_ROLES: readonly WorkRole[] = ['구현', '검토', '조사', '탐색']
327
328/** The role a character has: the one the person gave it, or its own. */
329export const roleOf = (id: MemberId, roles: Roles = {}): Role => roles[id] ?? MEMBERS[id].role
330
331/** The work a character takes, in a few words: its own, or that of the role the person gave it. */
332export const jobOf = (lang: Lang, id: MemberId, roles: Roles = {}): string => {
333  const given = roles[id]
334
335  return given === undefined ? CAST[lang].jobs[id] : CAST[lang].duty[given]
336}
337
338/** The roles with one character's set: its own role, or none, is no change kept, and only one who takes tasks has a role to change. */
339export const withRole = (roles: Roles, id: MemberId, role: WorkRole | null): Roles => {
340  const rest: Roles = Object.fromEntries(Object.entries(roles).filter(([key]) => key !== id))
341
342  return role === null || role === MEMBERS[id].role || !WORKERS.includes(id) ? rest : { ...rest, [id]: role }
343}
344
345/** The roles an earlier session kept, with whatever is not a role of one who takes tasks left out. */
346export const rolesFrom = (kept: unknown): Roles => {
347  if (typeof kept !== 'object' || kept === null) return {}
348  const held: Readonly<Record<string, unknown>> = { ...kept }
349
350  return WORKERS.reduce<Roles>((roles, id) => {
351    const role = WORK_ROLES.find(one => one === held[id])
352
353    return role === undefined ? roles : withRole(roles, id, role)
354  }, {})
355}
356
357/**
358 * The specialist, or the role's own character, or while that one is busy the
359 * next with free hands. A specialty is a kind of work, so its character comes
360 * before one the person gave the role; that one comes before those whose own
361 * the role is; one given another role leaves this one, and its specialty too.
362 */
363export const pickMember = (role: Role, tasks: readonly Task[], specialist?: MemberId, roles: Roles = {}): MemberId => {
364  const wanted = role === '지휘' ? '구현' : role
365  const given = WORKERS.filter(id => roles[id] === wanted)
366  const stayed = PREFERENCE[wanted].filter(id => roles[id] === undefined)
367  const own = given.length + stayed.length > 0 ? [...given, ...stayed] : PREFERENCE[wanted]
368  const first = specialist !== undefined && roles[specialist] === undefined ? specialist : undefined
369  const order = first === undefined ? own : [first, ...own.filter(id => id !== first)]
370  const free = order.find(id => !tasks.some(task => task.member === id && isActive(task)))
371
372  return free ?? order[0] ?? 'shisa'
373}
374
375// A kind that looks things up is not one that finds a place, whatever else its name holds: the first test that fits wins.
376const TYPE_ROLES: readonly (readonly [RegExp, Role])[] = [
377  [worded('research(?:er)?|writer|document(?:s|er|ation)?|requirements?|learning|mentor|plan(?:ner|ning)?'), '조사'],
378  [worded('explorer?|scout|search(?:er)?|guide'), '탐색'],
379  [worded('review(?:er)?|security|quality|audit(?:or)?|test(?:s|er|ing)?|root-cause|analy(?:st|sis|ze|zer|tics)'), '검토'],
380  [worded('architect|engineer|expert|refactor(?:ing)?'), '구현'],
381]
382const WORK_WORDS: readonly (readonly [RegExp, Role])[] = [
383  [worded('review(?:s|ed|er|ing)?|verif(?:y|ies|ied|ication)|audit(?:s|ed|ing)?', '검토|리뷰|검증|다시 보', 'レビュー|検証|見直'), '검토'],
384  [worded('explor(?:e|es|ed|ing)|locat(?:e|es|ed|ing)|find(?:s|ing)?', '탐색|찾|위치', '探索|探す|さがす|場所'), '탐색'],
385  [worded('research(?:es|ed|ing)?|document(?:s|ed|ing|ation)?|summar(?:y|ies|ize|izes|ized|izing)', '조사|정리|문서', '調査|整理|文書|ドキュメント|まとめ'), '조사'],
386  [worded('implement(?:s|ed|ing|ation)?|fix(?:es|ed|ing)?|(?:re)?build(?:s|ing)?|(?:re)?writ(?:e|es|ing)', '구현|수정|고치|작성|만들|토벌', '実装|修正|直す|作成|作る|討伐'), '구현'],
387]
388
389/** The role an agent type or a description tells, when one does. */
390export const roleOfAgent = (subagentType: string, description: string): Role | undefined =>
391  (TYPE_ROLES.find(([test]) => test.test(subagentType)) ?? WORK_WORDS.find(([test]) => test.test(description)))?.[1]
392
393export const PROFILES: Record<string, Role> = {
394  'codex-build': '구현',
395  'codex-hard': '구현',
396  'codex-scout': '탐색',
397  'grok-research': '조사',
398  'grok-build': '구현',
399  'agy-fast': '구현',
400  'agy-pro': '구현',
401  'agy-review': '검토',
402  'claude-build': '구현',
403  'claude-review': '검토',
404}
405
hooks/orca.ts 854 lines
1// Reads `fleet-run <profile> --spec <file> --out <file>` out of a shell
2// command: the Orca worker launches the conductor makes through Bash.
3//
4// The command is read the way a shell reads it, as far as that is plain: a
5// statement at a time, word by word, its quotes, comments and here-documents
6// told apart. A launch counts only where the shell is sure to run it, and a
7// path only where what it comes to is sure: written out, or made of
8// variables this very command assigned as plain text, with nothing run since
9// that may have assigned them again. What is sure is told narrowly, the same
10// for bash, zsh and sh, and anything else is left out, so that nothing is
11// tracked or written over on a guess.
12
13import type { Tokens } from '../types'
14
15import type { Words } from './words'
16
17export type FleetRun = {
18  profile: string
19  /**
20   * Where in the command the spec's path stands, without its quotes: from,
21   * and up to. Only with `spec`, and only where another path can be written
22   * there.
23   */
24  specSpan?: readonly [number, number]
25  /** The paths with the command's own variables filled in, when plain. */
26  spec?: string
27  out?: string
28  title: string
29  /** Whether `--out` was given at all: given and not here, its path could not be read. */
30  hasOut: boolean
31  /** An `&` closes its command: the shell does not wait for it. */
32  isBackground: boolean
33  /** It is handed to something that answers at once, an Orca terminal or `nohup`: the command does not wait for it either. */
34  isDetached: boolean
35}
36
37/**
38 * A stretch of a word read one way: bare, inside one kind of quotes, or a
39 * command run to make it, `$(…)` or `<(…)`. Its place is its text's, without
40 * the quotes or the parentheses.
41 */
42type Piece = { text: string; quote: '' | '"' | "'" | '('; from: number; to: number }
43/** A word of a command, and its place with its quotes. One that runs into a `<` with no space between is cut: what it comes to is not told. */
44type Word = { pieces: Piece[]; from: number; to: number; isCut?: boolean }
45/** One command of a script: its words, and the operators it stands between. One that is odd holds something not read here as the shell reads it. */
46type Statement = { words: Word[]; before: string; after: string; isOdd: boolean }
47/** What the variables assigned so far are sure to hold. */
48type Vars = Map<string, string>
49/** A script a command hands a shell in quotes, as that shell reads it, and where in the command each character of it was written. */
50type Script = { text: string; from: number[]; to: number[] }
51/** What is sure of the shell at one point of a script. */
52type Shell = {
53  /** The variables, the home directory among them as `HOME`. */
54  vars: Vars
55  /** The variables made read-only: assigning one again does not go as written. */
56  sealed: Set<string>
57  /** Whether what a variable written bare holds stays one word: nothing so far may have changed where the shell parts it. */
58  isWhole: boolean
59  /** Nothing is sure from here on: something was met that this does not follow. */
60  isLost: boolean
61}
62
63/** A path that goes into a command as it is, with nothing for a shell to read into it: letters and digits of any script, and the few signs a path is written with. */
64const PLAIN = /^[\p{L}\p{N}\p{M}_@%+=:,./~-]+$/u
65/** Such a path with spaces in it, which only quotes hold together. */
66const SPACED = /^[\p{L}\p{N}\p{M}_@%+=:,./~ -]+$/u
67/** What parts two words for the shell: no other space does, however much it looks like one. */
68const BLANK = /^[ \t]$/
69const BARE = /[^ \t\n\\'"`;&|()<>]+/y
70const OPERATOR = /;;|&&|\|\||\|&|[;&|()`]/y
71const REDIRECT = /&>>?|[<>][<>&|]*/y
72const HERE = /<<(-?)[ \t]*(?:'([^'\n]*)'|"([^"\n]*)"|\\?([^\s\\'"`;&|()<>]+))/y
73/** What stands right before a redirection as a part of it: the number of what is redirected, or the variable it is kept in. */
74const REDIRECTED = /^(?:\d+|\{\w+\})$/
75const ASSIGNED = /^([A-Za-z_]\w*)(\+?)=/
76const NAME = /^[A-Za-z_]\w*$/
77const FUNCTION_NAME = /^[A-Za-z_][\w.:-]*$/
78/** A command's name written out: a program's, with its path or not, or a builtin's. */
79const COMMAND = /^(?:[:[]|[\p{L}\p{N}_./~][\p{L}\p{N}_.+/-]*)$/u
80/**
81 * What the shell fills in with more than a variable holds, or what is not
82 * read here as the shell reads it: a `${…}` that is not a name alone or a
83 * name with a plain default or a plain cut, arithmetic, a subscript, a
84 * backquote.
85 */
86const ODD = /\$\{(?!#?[A-Za-z_]\w*(?:(?::?[-+]|%%?|##?)[\w./*?-]*)?\})|\$\(\(|\$\[|\$\w+\[|`/
87/** In a command run to make a word, what hides where it ends: a here-document, a `case`, a quote of another kind. */
88const KNOTTY = /<<|\besac\b|\$['"]/
89/** What `set` is given to stop at an error or to show what is run: nothing a word is read by. */
90const SET = /^(?:[-+][euvx]*o?|errexit|nounset|pipefail|verbose|xtrace)$/
91/** After a variable written bare with no braces, what is sure to be no part of its name, a modifier or a subscript, in quotes and out of them. */
92const AFTER = { '': /^[/.-]?$/, '"': /^[^:[\p{L}\p{N}\p{M}]?$/u } as const
93
94/** The character at a place of a text, whole where it is written as two units; none past the end. */
95const charAt = (text: string, at: number): string => {
96  const point = text.codePointAt(at)
97
98  return point === undefined ? '' : String.fromCodePoint(point)
99}
100
101/** Reserved words a command may stand behind, and how each moves the count of the blocks around it. */
102const KEYWORDS = new Map<string, number>([
103  ['if', 1],
104  ['case', 1],
105  ['while', 1],
106  ['until', 1],
107  ['for', 1],
108  ['select', 1],
109  ['{', 1],
110  ['fi', -1],
111  ['esac', -1],
112  ['done', -1],
113  ['}', -1],
114  ['then', 0],
115  ['do', 0],
116  ['else', 0],
117  ['elif', 0],
118  ['!', 0],
119  ['time', 0],
120])
121const LOOPS = new Set(['while', 'until', 'for', 'select'])
122/** Commands that run the command named after them. */
123const WRAPPERS = new Set(['nohup', 'exec', 'command', 'env'])
124/** Commands whose arguments assign, where the shell has them: only where they stand alone, with no option, is no more than that done. */
125const DECLARES = new Set(['declare', 'typeset', 'local'])
126/** Every builtin whose arguments assign. Behind a word that runs the command named after it, none is sure to be the shell's own. */
127const ASSIGNERS = new Set(['export', 'readonly', ...DECLARES])
128/** Builtins that assign variables and do no more: nothing known before one is sure after it. */
129const WRITERS = new Set(['read', 'unset', 'getopts', 'mapfile', 'readarray', 'let', 'shift', 'print', 'getln', 'set'])
130/** Builtins given a count, which zsh reads as arithmetic. */
131const COUNTED = new Set(['break', 'continue', 'exit', 'return'])
132/** What `printf` is told to print with no option and no number in it: text, `%s`, `%b` and `%q`. */
133const FORMAT = /^(?!-)(?:[^%]|%%|%[-0-9.]*[sbq])*$/
134/** What `test` is given that some shell reads a word after it as arithmetic for. */
135const TESTED = /^-(?:[tvR]|eq|ne|lt|le|gt|ge)$/
136/**
137 * Every other word bash, zsh and sh keep for themselves that may change the
138 * shell: its variables' kinds, its options, what a name runs. Past one,
139 * nothing is sure. A builtin in none of these lists changes no variable, and
140 * neither does a program.
141 */
142const SHELL_WORDS = new Set(
143  (
144    '. alias autoload bg bind bindkey builtin bye caller compadd comparguments compcall compctl compdescribe compfiles compgen compgroups complete compopt compquote compset comptags comptry compvalues ' +
145    'coproc disable echotc echoti emulate enable end eval fc fg float foreach function functions help history integer limit log logout nocorrect noglob private pushln r rehash repeat sched setopt shopt ' +
146    'source suspend trap ttyctl unalias unfunction unhash unlimit unsetopt vared zcompile zformat zle zmodload zparseopts zregexparse zstyle'
147  ).split(' '),
148)
149/** Variables the shells keep for themselves, by how their names begin and by name: what one holds is the shell's to change, and is never taken as known. */
150const SPECIAL = new RegExp(
151  `^(?:(?:BASH|ZSH_|zsh_|COMP_|READLINE_|ZLE_|dis_|EPOCH|LC_)\\w*|${(
152    '_ ARGC BAUD CDPATH CHILD_MAX COLUMNS COPROC CPUTYPE DIRSTACK DIRSTACKSIZE EGID EMACS ENV ERRNO EUID EXECIGNORE FCEDIT FIGNORE FPATH FUNCNAME FUNCNEST GID GLOBIGNORE GROUPS HISTCHARS HISTCMD HISTCONTROL ' +
153    'HISTFILE HISTFILESIZE HISTIGNORE HISTSIZE HISTTIMEFORMAT HOST HOSTFILE HOSTNAME HOSTTYPE IFS IGNOREEOF INPUTRC INSIDE_EMACS KEYBOARD_HACK KEYTIMEOUT LANG LINENO LINES LISTMAX LOGCHECK LOGNAME MACHTYPE MAIL ' +
154    'MAILCHECK MAILPATH MANPATH MAPFILE MATCH MBEGIN MEND MODULE_PATH NULLCMD OLDPWD OPTARG OPTERR OPTIND OSTYPE PATH PERIOD PIPESTATUS POSIXLY_CORRECT POSTEDIT PPID PROMPT PROMPT2 PROMPT3 PROMPT4 PROMPT_COMMAND ' +
155    'PROMPT_DIRTRIM PS0 PS1 PS2 PS3 PS4 PSVAR PWD RANDOM READNULLCMD REPLY REPORTMEMORY REPORTTIME RPROMPT RPROMPT2 RPS1 RPS2 SAVEHIST SECONDS SHELL SHELLOPTS SHLVL SPROMPT SRANDOM STTY TERM TERMINFO TIMEFMT ' +
156    'TIMEFORMAT TMOUT TMPPREFIX TMPSUFFIX TRY_BLOCK_ERROR TRY_BLOCK_INTERRUPT TTY TTYIDLE UID USERNAME VENDOR WATCH WATCHFMT WORDCHARS ZBEEP ZDOTDIR aliases argv auto_resume builtins cdpath commands dirstack ' +
157    'epochtime errnos fignore fpath funcfiletrace funcsourcetrace funcstack functions functions_source functrace galiases histchars history historywords jobdirs jobstates jobtexts keymaps langinfo mailpath manpath ' +
158    'mapfile match mbegin mend module_path modules nameddirs options parameters patchars path pipestatus prompt psvar reply reswords saliases signals status sysparams termcap terminfo userdirs usergroups watch widgets'
159  ).replace(/ /g, '|')})$`,
160)
161/** Those of them a shell is ruled by: one assigned, the shell may read what follows another way, or run what it holds as it shows each command. */
162const RULING = new Set(['POSIXLY_CORRECT', 'BASH_COMPAT', 'BASH_ENV', 'BASHOPTS', 'SHELLOPTS', 'ENV', 'ZDOTDIR', 'HISTCHARS', 'histchars', 'NULLCMD', 'READNULLCMD', 'PS4', 'PROMPT4'])
163const SHELLS = new Set(['bash', 'zsh', 'sh'])
164/** After one of these a statement runs only if the one before it went a certain way, or in a shell of its own. */
165const CONDITIONAL = new Set(['&&', '||', '|', '|&'])
166/** Before one of these a statement runs in a shell of its own. */
167const UNWAITED = new Set(['&', '|', '|&'])
168
169const found = (pattern: RegExp, text: string, at: number): RegExpExecArray | null => {
170  pattern.lastIndex = at
171
172  return pattern.exec(text)
173}
174
175/** Where the parenthesis opened before `at` closes, the quotes and the comments inside it passed over; -1 where it does not. */
176const parenEnd = (text: string, at: number): number => {
177  let depth = 1
178
179  for (let end = at; end < text.length; end += 1) {
180    const char = text[end]
181
182    if (char === '\\') end += 1
183    else if (char === "'") end = text.indexOf("'", end + 1)
184    else if (char === '"') end = quoteEnd(text, end + 1)
185    else if (char === '#' && /^[\s;&|()]$/.test(text[end - 1] ?? '')) end = text.indexOf('\n', end)
186    else if (char === '(') depth += 1
187    else if (char === ')' && (depth -= 1) === 0) return end
188    if (end < 0) return -1
189  }
190
191  return -1
192}
193
194/** Where the double quote opened before `at` closes, the commands it holds passed over; -1 where it does not. */
195const quoteEnd = (text: string, at: number): number => {
196  for (let end = at; end < text.length; end += 1) {
197    const char = text[end]
198
199    if (char === '"') return end
200    if (char === '\\') end += 1
201    else if (char === '`') end = text.indexOf('`', end + 1)
202    else if (char === '$' && text[end + 1] === '(') end = parenEnd(text, end + 2)
203    if (end < 0) return -1
204  }
205
206  return -1
207}
208
209/** Whether a stretch of a word holds something not read here as the shell reads it. */
210const isOddPiece = ({ text, quote }: Piece): boolean => quote !== "'" && (ODD.test(text) || ((quote === '(' || text.includes('$(')) && KNOTTY.test(text)))
211
212/**
213 * A script as its commands, in the order written. A comment and the lines
214 * of a here-document are no part of any, and neither is a redirection: its
215 * sign, the number before it and the word it leads to are none of the
216 * command's words, wherever among them they stand. A command run to make a
217 * word, `$(…)` or `<(…)`, is a piece of that word. Undefined where a quote
218 * or such a command is left open: nothing after it can be told.
219 */
220const statementsOf = (text: string): Statement[] | undefined => {
221  const statements: Statement[] = []
222  /** The here-documents opened on this line: the line that closes each, whether tabs before it are dropped, whether the shell fills its lines in, and the statement it is of. */
223  const waiting: { mark: string; isIndented: boolean; isFilled: boolean; owner: number }[] = []
224  let words: Word[] = []
225  let word: Word | undefined
226  let before = ''
227  let at = 0
228  /** Whether the next word is what a redirection leads to. */
229  let isTarget = false
230  /** Whether the statement being read holds something not read here as the shell reads it. */
231  let isOdd = false
232  const add = (piece: Piece, from: number, to: number): void => {
233    word ??= { pieces: [], from, to }
234    word.pieces.push(piece)
235    word.to = to
236    isOdd ||= isOddPiece(piece)
237  }
238  /** Ends the word being read, and with an operator the statement. */
239  const close = (after?: string): void => {
240    if (word !== undefined && !isTarget) words.push(word)
241    if (word !== undefined || after !== undefined) isTarget = false
242    word = undefined
243    if (after === undefined) return
244    statements.push({ words, before, after, isOdd })
245    words = []
246    before = after
247    isOdd = false
248  }
249  /** Takes the command run to make a part of the word being read, whose parenthesis opens at `open`. Answers whether it closes. */
250  const run = (open: number, from: number): boolean => {
251    const end = parenEnd(text, open + 1)
252
253    if (end >= 0) add({ text: text.slice(open + 1, end), quote: '(', from: open + 1, to: end }, from, end + 1)
254    at = end + 1
255
256    return end >= 0
257  }
258
259  while (at < text.length) {
260    const char = text[at] ?? ''
261    const last = word?.pieces.at(-1)
262    /** Whether a `$` written bare stands right before this character. */
263    const isDollared = last?.quote === '' && last.text.endsWith('$')
264
265    if (char === '\\') {
266      // A backslash keeps the next character as it is written; at a line's end it joins the next line on.
267      if (text[at + 1] !== '\n') add({ text: text[at + 1] ?? '', quote: "'", from: at, to: at + 2 }, at, at + 2)
268      at += 2
269    } else if (char === "'" || char === '"') {
270      const end = char === "'" ? text.indexOf("'", at + 1) : quoteEnd(text, at + 1)
271
272      if (end < 0) return undefined
273      // `$'…'` and `$"…"` are quotes of another kind, and a quote that runs over a line's end moves where a here-document begins.
274      if (isDollared || (waiting.length > 0 && text.slice(at, end).includes('\n'))) isOdd = true
275      add({ text: text.slice(at + 1, end), quote: char, from: at + 1, to: end }, at, end + 1)
276      at = end + 1
277    } else if (char === '\n') {
278      close('\n')
279      at += 1
280      for (const { mark, isIndented, isFilled, owner } of waiting.splice(0)) {
281        const from = at
282        const opened = statements[owner]
283
284        while (at < text.length) {
285          const end = text.indexOf('\n', at)
286          const line = text.slice(at, end < 0 ? text.length : end)
287
288          at = end < 0 ? text.length : end + 1
289          if ((isIndented ? line.replace(/^\t+/, '') : line) === mark) break
290        }
291        if (isFilled && opened !== undefined && ODD.test(text.slice(from, at))) opened.isOdd = true
292      }
293    } else if (BLANK.test(char)) {
294      close()
295      at += 1
296    } else if (char === '#' && word === undefined) {
297      const end = text.indexOf('\n', at)
298
299      // Right after a parenthesis, zsh reads it as a part of a pattern.
300      if (text[at - 1] === '(') isOdd = true
301      at = end < 0 ? text.length : end
302    } else if ((char === '(' && isDollared) || ((char === '<' || char === '>') && text[at + 1] === '(')) {
303      // `$((…))` is arithmetic, which may assign.
304      if (text[at + 1] === '(' && char === '(') isOdd = true
305      if (!run(char === '(' ? at : at + 1, at)) return undefined
306    } else {
307      const here = found(HERE, text, at)
308      const sign = here ?? found(REDIRECT, text, at)
309      const operator = sign ?? found(OPERATOR, text, at)
310      const bare = operator ?? found(BARE, text, at)
311
312      if (here !== null) {
313        waiting.push({ mark: here[2] ?? here[3] ?? here[4] ?? '', isIndented: here[1] === '-', isFilled: here[4] !== undefined && !here[0].includes('\\'), owner: statements.length })
314        // A closing line written partly in quotes is not the one read here.
315        if (/^[^\s;&|()<>]$/.test(text[at + here[0].length] ?? '')) isOdd = true
316      }
317      // A redirection parts two words, and an operator ends the statement.
318      if (sign !== null) {
319        if (word !== undefined && REDIRECTED.test(bareOf(word) ?? '')) {
320          // `{name}>` assigns the variable the number of what it opens.
321          if (bareOf(word)?.startsWith('{') === true) isOdd = true
322          word = undefined
323        }
324        // `<1-9>` right after a word is a part of a pattern to zsh.
325        else if (word !== undefined && sign[0].startsWith('<')) word.isCut = true
326        close()
327        // A here-document's sign has its closing line with it; any other leads to the word after it.
328        isTarget = here === null
329        // `>!` is one sign to zsh, and a file named `!` to the others.
330        if (text[at + sign[0].length] === '!') isOdd = true
331      } else if (operator !== null) {
332        // A backquote, arithmetic, and a parenthesis that goes on a word, as an array's or a pattern's does, are not followed; a function's own `()` is.
333        if (operator[0] === '`' || (operator[0] === '(' && (text[at + 1] === '(' || (word !== undefined && !/^[ \t]*\)/.test(text.slice(at + 1)))))) isOdd = true
334        close(operator[0])
335      } else if (bare !== null) add({ text: bare[0], quote: '', from: at, to: at + bare[0].length }, at, at + bare[0].length)
336      at += bare?.[0].length ?? 1
337    }
338  }
339  close('')
340
341  return statements
342}
343
344/** A word written bare with nothing for the shell to fill in: an option, a keyword, a profile. */
345const bareOf = (word: Word | undefined): string | undefined => {
346  const [piece, ...more] = word?.pieces ?? []
347
348  return piece !== undefined && more.length === 0 && piece.quote === '' && !piece.text.includes('$') ? piece.text : undefined
349}
350
351/** A word the shell hands on as it is written, in quotes or not, with nothing to fill in: a profile, an option. */
352const literalOf = (word: Word | undefined): string | undefined => {
353  const pieces = word?.pieces ?? []
354  const isFilled = pieces.some(piece => piece.quote === '(' || (piece.quote !== "'" && /[$`\\]/.test(piece.text)))
355
356  return pieces.length === 0 || isFilled || word?.isCut === true ? undefined : pieces.map(piece => piece.text).join('')
357}
358
359/** The command a word names: the last part of its path, where that part is written out. */
360const nameOf = (word: Word | undefined): string | undefined => {
361  const pieces = word?.pieces ?? []
362  const text = pieces.map(piece => piece.text).join('')
363  const name = text.slice(text.lastIndexOf('/') + 1)
364
365  return word === undefined || pieces.some(piece => piece.quote === '(') || /[$`\\]/.test(name) ? undefined : name
366}
367
368/** What a word assigns, where it is an assignment: `NAME=` at its start, written bare. */
369const assigned = (word: Word | undefined): RegExpExecArray | null => {
370  const first = word?.pieces[0]
371
372  return first?.quote === '' ? ASSIGNED.exec(first.text) : null
373}
374
375/** A word from so many characters into its first piece: what follows `NAME=` or `--option=`. */
376const rest = (word: Word, skip: number): Word => {
377  const [first, ...more] = word.pieces
378
379  if (first === undefined) return word
380  const from = first.from + skip
381  const head = { ...first, text: first.text.slice(skip), from }
382
383  return { ...word, pieces: head.text === '' ? more : [head, ...more], from }
384}
385
386/**
387 * What a word comes to once the shell has filled it in, where that is sure
388 * and plain: single quotes keep what they hold, a variable is one assigned
389 * earlier, and `~/` opening a bare word is the home directory. A space is
390 * plain only in a word that is one stretch in quotes: there the shell does
391 * not part what a variable holds, and a path written in its place stays
392 * inside the quotes.
393 *
394 * A variable is followed only as `$NAME` or `${NAME}`, and `$NAME` only
395 * before what no shell takes for more of the name, a modifier or a
396 * subscript. Bare, where the shell may part what it holds, it is followed
397 * only while nothing may have changed how: `isParted` is off for an
398 * assignment's own value, which no shell parts. A `~` anywhere else and an
399 * `=` that opens a word are filled in by some shell, and are not followed.
400 */
401const resolve = (word: Word, shell: Shell, isTildeRead = true, isParted = true): string | undefined => {
402  const home = shell.vars.get('HOME')
403  let isSure = !shell.isLost && word.isCut !== true
404  const text = word.pieces
405    .map((piece, index) => {
406      const { text: written, quote } = piece
407
408      if (quote === "'") return written
409      if (quote === '(') {
410        isSure = false
411
412        return ''
413      }
414      const filled = written.replace(/\$\{(\w+)\}|\$(\w+)|\$/g, (whole, braced: string | undefined, bare: string | undefined, at: number) => {
415        const value = shell.vars.get(braced ?? bare ?? '')
416        const isJoined = bare !== undefined && !AFTER[quote].test(charAt(written, at + whole.length))
417
418        if (value === undefined || isJoined || (quote === '' && isParted && !shell.isWhole)) isSure = false
419
420        return value ?? ''
421      })
422
423      if (quote === '"' || !/^=|~/.test(written)) return filled
424      if (index > 0 || !isTildeRead || home === undefined || !/^~\/[^~]*$/.test(written)) isSure = false
425
426      return `${home ?? ''}${filled.slice(1)}`
427    })
428    .join('')
429
430  const [only, ...more] = word.pieces
431  const isHeld = only !== undefined && more.length === 0 && only.quote !== ''
432
433  return isSure && (isHeld ? SPACED : PLAIN).test(text) && !/^[~=]/.test(text) ? text : undefined
434}
435
436/** Leaves nothing known of the variables: any of them may have been assigned. */
437const forget = (shell: Shell): void => shell.vars.clear()
438
439/** Leaves nothing sure from here on. */
440const lose = (shell: Shell): void => {
441  shell.isLost = true
442  shell.vars.clear()
443}
444
445/** Leaves the variables some words assign unknown. One the shell is ruled by, or one that is read-only, leaves nothing sure. */
446const strike = (words: readonly Word[], shell: Shell): void => {
447  for (const word of words) {
448    const name = assigned(word)?.[1] ?? bareOf(word) ?? ''
449
450    shell.vars.delete(name)
451    if (shell.sealed.has(name) || RULING.has(name)) lose(shell)
452  }
453}
454
455/**
456 * Takes the assignments the shell is sure to make: one of plain text is
457 * kept, and one that is not (appended to, not plain, or of a variable the
458 * shells keep for themselves) leaves its variable unknown. `known` is what
459 * their own variables are read from.
460 */
461const assign = (words: readonly Word[], shell: Shell, known: Vars, isParted: boolean): void => {
462  for (const word of words) {
463    const [whole, name, plus] = assigned(word) ?? []
464
465    if (whole === undefined || name === undefined) continue
466    const value = plus === '' && !SPECIAL.test(name) ? resolve(rest(word, whole.length), { ...shell, vars: known }, true, isParted) : undefined
467
468    strike([word], shell)
469    if (value !== undefined && !shell.isLost) shell.vars.set(name, value)
470  }
471}
472
473/** The value an option is given, as the next word or after `=` in its own; none where it is given twice or not at all. */
474const valueOf = (args: readonly Word[], name: string): { word: Word; isApart: boolean } | undefined => {
475  const flag = `--${name}`
476  const given = args.flatMap((word, index) => {
477    const next = args[index + 1]
478    const first = word.pieces[0]
479
480    if (literalOf(word) === flag) return next === undefined ? [] : [{ word: next, isApart: true }]
481
482    return first?.quote === '' && first.text.startsWith(`${flag}=`) ? [{ word: rest(word, flag.length + 1), isApart: false }] : []
483  })
484
485  return given.length === 1 ? given[0] : undefined
486}
487
488const baseName = (path: string): string => path.slice(path.lastIndexOf('/') + 1)
489
490/** The launch a `fleet-run`'s arguments make, its paths as far as they are sure; none where no profile is named. */
491const runOf = (args: readonly Word[], shell: Shell, isBackground: boolean, isDetached: boolean): FleetRun | undefined => {
492  const profile = literalOf(args[0])
493
494  if (profile === undefined || !/^[a-z]+-[a-z]+$/.test(profile)) return undefined
495  const given = valueOf(args, 'spec')
496  const kept = valueOf(args, 'out')
497  // A `~` after `--spec=` is not the home directory: only one that opens a word is.
498  const spec = given === undefined ? undefined : resolve(given.word, shell, given.isApart)
499  const out = kept === undefined ? undefined : resolve(kept.word, shell, kept.isApart)
500  const named = baseName(spec ?? out ?? '').replace(/\.(?:spec|out)\.md$|\.md$/, '')
501  const run: FleetRun = { profile, title: named === '' ? profile : named, hasOut: kept !== undefined, isBackground, isDetached }
502
503  if (given !== undefined && spec !== undefined) {
504    const [piece, ...more] = given.word.pieces
505
506    run.spec = spec
507    // One piece is written over inside its quotes; a word of several, quotes and all.
508    run.specSpan = piece !== undefined && more.length === 0 ? [piece.from, piece.to] : [given.word.from, given.word.to]
509  }
510  if (out !== undefined) run.out = out
511
512  return run
513}
514
515/**
516 * The word a command hands a shell to run: `orca terminal create --command
517 * <word>`, `sh -c <word>`. A shell given any option before its `-c` may read
518 * the word another way: it is not plain.
519 */
520const handedOf = (name: string | undefined, args: readonly Word[]): { word: Word; isPlain: boolean } | undefined => {
521  const command = name === 'orca' && bareOf(args[0]) === 'terminal' && bareOf(args[1]) === 'create' ? valueOf(args, 'command')?.word : undefined
522
523  if (command !== undefined) return { word: command, isPlain: true }
524  if (name === undefined || !SHELLS.has(name)) return undefined
525  for (const [index, arg] of args.entries()) {
526    const flag = bareOf(arg)
527    const word = args[index + 1]
528
529    if (flag === '-c' || flag === '-lc') return word === undefined ? undefined : { word, isPlain: index === 0 }
530    if (flag === undefined || !flag.startsWith('-')) return undefined
531  }
532
533  return undefined
534}
535
536/**
537 * A handed word as the script the shell it is handed to reads. Only a word
538 * that is one quoted stretch is read, and not one that runs a command to
539 * make its text. Double quotes are filled in before the script is handed
540 * on: an escape gives its character, a variable what it holds, and one not
541 * known a character no path has. So does one that holds a space, where the
542 * shell it is handed to parts it, and anything else a `$` opens.
543 */
544const scriptOf = (word: Word, shell: Shell): Script | undefined => {
545  const [piece, ...more] = word.pieces
546  const script: Script = { text: '', from: [], to: [] }
547
548  if (piece === undefined || more.length > 0 || piece.quote === '' || piece.quote === '(' || (piece.quote === '"' && /`|\$\(/.test(piece.text))) return undefined
549  const { text: written } = piece
550  let at = piece.from
551
552  for (const { 0: whole, 1: escaped, 2: braced, 3: bare, index } of written.matchAll(piece.quote === '"' ? /\\([\s\S])|\$\{(\w+)\}|\$(\w+)|[\s\S]/g : /[\s\S]/g)) {
553    const held = shell.vars.get(braced ?? bare ?? '')
554    const isJoined = bare !== undefined && !AFTER['"'].test(charAt(written, index + whole.length))
555    const filled = held === undefined || held.includes(' ') || isJoined ? '\0' : held
556    const text = escaped !== undefined ? (escaped === '\n' ? '' : '"\\$`'.includes(escaped) ? escaped : whole) : piece.quote === '"' && whole.startsWith('$') ? filled : whole
557
558    script.text += text
559    for (let unit = 0; unit < text.length; unit += 1) {
560      script.from.push(at)
561      script.to.push(at + whole.length)
562    }
563    at += whole.length
564  }
565
566  return script
567}
568
569/**
570 * A run found in a script handed on, with its spec's place given in the
571 * command the script stands in. A place that begins or ends partway through
572 * what a variable was filled with is not one to write a path into: the run
573 * then has none.
574 */
575const placed = (run: FleetRun, script: Script, isBackground: boolean, isDetached: boolean): FleetRun => {
576  const { specSpan, ...more } = run
577  const moved = { ...more, isBackground: run.isBackground || isBackground, isDetached: run.isDetached || isDetached }
578  const from = specSpan === undefined ? undefined : script.from[specSpan[0]]
579  const to = specSpan === undefined ? undefined : script.to[specSpan[1] - 1]
580
581  if (specSpan === undefined || from === undefined || to === undefined || from === script.from[specSpan[0] - 1] || to === script.to[specSpan[1]]) return moved
582
583  return { ...moved, specSpan: [from, to] }
584}
585
586/**
587 * How many words of a statement open a function's definition: `name` before
588 * its own `()`, or `function name`. The compound command that comes next is
589 * the function's body.
590 */
591const definedAt = (statements: readonly Statement[], index: number): number | undefined => {
592  const { words = [], after = '' } = statements[index] ?? {}
593  const next = statements[index + 1]
594  const first = bareOf(words[0])
595  const second = bareOf(words[1])
596
597  if (first === 'function' && second !== undefined && FUNCTION_NAME.test(second)) return 2
598  const isNamed = words.length === 1 && after === '(' && next !== undefined && next.words.length === 0 && next.after === ')'
599
600  return isNamed && first !== undefined && FUNCTION_NAME.test(first) && !KEYWORDS.has(first) ? 1 : undefined
601}
602
603/**
604 * Whether the list a statement is of may run in a shell of its own:
605 * `a && b &` runs both in one. Where a list ends is not followed past a
606 * block or a parenthesis.
607 */
608const isApart = (statements: readonly Statement[], index: number): boolean => {
609  let at = index
610
611  while (CONDITIONAL.has(statements[at]?.after ?? '')) {
612    at += 1
613    if (KEYWORDS.has(bareOf(statements[at]?.words[0]) ?? '')) return true
614  }
615
616  return /^[&(]$/.test(statements[at]?.after ?? '')
617}
618
619/**
620 * Whether a builtin that changes no variable by itself is sure to change
621 * none as it is given here. `printf -v` and `wait -p` assign. zsh reads as
622 * arithmetic, which may assign, what `printf` prints as a number, what a
623 * loop or the shell is left with, and what `test -t` is given; a newer bash
624 * reads so the subscript of what `test -v` names, and a Korn shell what
625 * `test` compares as numbers.
626 */
627const isTame = (name: string, args: readonly Word[], shell: Shell): boolean => {
628  /** Whether a word is sure to open with no `-`, whatever it is filled in with: it is no option. */
629  const isGiven = (arg: Word): boolean => arg.pieces[0]?.quote !== '(' && /^[^-$`\\]/.test(arg.pieces[0]?.text ?? '')
630  const [format] = args
631
632  if (name === 'printf') return format?.pieces.every(piece => piece.quote === "'" || (piece.quote !== '(' && !/[$`]/.test(piece.text))) === true && FORMAT.test(format.pieces.map(piece => piece.text).join(''))
633  if (name === 'wait') return args.every(isGiven)
634  if (COUNTED.has(name)) return args.every(arg => /^\d+$/.test(literalOf(arg) ?? ''))
635  if (name === 'test' || name === '[') return args.every(arg => !TESTED.test(literalOf(arg) ?? resolve(arg, shell) ?? '-t'))
636
637  return !WRITERS.has(name) && !DECLARES.has(name)
638}
639
640/**
641 * Takes what a command may have done to the shell it ran in. A program, and
642 * a builtin that only prints, tests or moves about, changes no variable.
643 * `export` and `readonly` with nothing but names and assignments assign as
644 * a statement of assignments does, where the shell is sure to run them. A
645 * builtin that assigns, or that may as it is given here, leaves no variable
646 * known. Anything else the shells keep for themselves, and a name not
647 * written out, leave nothing sure.
648 */
649const affect = (word: Word | undefined, args: readonly Word[], shell: Shell, isRun: boolean): void => {
650  const name = bareOf(word) ?? ''
651  const isNamed = args.every(arg => assigned(arg) !== null || NAME.test(bareOf(arg) ?? ''))
652  const isWritten = args.every(arg => literalOf(arg) !== undefined)
653
654  if (!COMMAND.test(name) || SHELL_WORDS.has(name)) return lose(shell)
655  // A name with a path in it is a program's.
656  if (name.includes('/')) return
657  if (name === 'export' || name === 'readonly') {
658    // An option makes it more than an assignment, and a variable that may or may not have been made read-only is not one to assign again.
659    if (!isNamed || (name === 'readonly' && !isRun)) return lose(shell)
660    if (isRun) assign(args, shell, new Map(shell.vars), true)
661    else {
662      strike(args, shell)
663      forget(shell)
664    }
665    if (name === 'readonly') for (const arg of args) shell.sealed.add(assigned(arg)?.[1] ?? bareOf(arg) ?? '')
666
667    return
668  }
669  if ((DECLARES.has(name) && !isNamed) || (name === 'set' && !args.every(arg => SET.test(literalOf(arg) ?? '')))) return lose(shell)
670  if (isTame(name, args, shell)) return
671  strike(args, shell)
672  forget(shell)
673  // A name that is filled in may be the one the shell parts words by.
674  if (!isWritten) shell.isWhole = false
675}
676
677/**
678 * The launches a list of statements makes, in the order written, with what
679 * is sure of the shell kept as each statement is passed. `home` is the home
680 * directory the command began with.
681 */
682const scan = (statements: readonly Statement[], shell: Shell, home: string | undefined): FleetRun[] => {
683  const runs: FleetRun[] = []
684  let blocks = 0
685  let parens = 0
686  /** A function's body: due after its name, or open since the blocks and parentheses around it were this many. */
687  let body: 'due' | number | undefined
688  /** Whether a `[[` is open: what stands up to its `]]` is a test's words, and no command. */
689  let isTesting = false
690
691  for (const [index, { words, before, after, isOdd }] of statements.entries()) {
692    const defined = definedAt(statements, index)
693    let at = defined ?? 0
694    let isTimed = false
695    let isHeader = false
696
697    // A function may stand for any command, and the shell calls some by itself, as zsh calls `chpwd` after a `cd`: past a definition, as past what is odd, nothing is sure.
698    if (isOdd || defined !== undefined || (words.length === 0 && before === '(' && after === ')')) lose(shell)
699    if (defined !== undefined) body = 'due'
700    // The body opens with the word after the name, or with what follows the name's own parentheses.
701    if (body === 'due' && (at < words.length || (defined === undefined && !(words.length === 0 && before === '(' && after === ')')))) body = blocks + parens
702    for (let key = bareOf(words[at]) ?? ''; KEYWORDS.has(key) && !isTesting; key = bareOf(words[at]) ?? '') {
703      // A loop runs its body again with what the body assigned: nothing known before it is sure inside.
704      if (LOOPS.has(key)) forget(shell)
705      // A `case` has patterns that close parentheses they did not open: where it ends is not followed.
706      if (key === 'case') lose(shell)
707      isTimed ||= key === 'time'
708      // A loop's first line names its variable and what it goes through: there is no command in it.
709      isHeader ||= key === 'for' || key === 'select'
710      blocks = Math.max(0, blocks + (KEYWORDS.get(key) ?? 0))
711      at += 1
712    }
713    // An assignment holds from here on only where the shell is sure to make it, in the shell the next statements run in.
714    const isRun = blocks + parens === 0 && !CONDITIONAL.has(before) && !UNWAITED.has(after) && !isTimed && !isApart(statements, index)
715    const first = at
716
717    while (assigned(words[at]) !== null) at += 1
718    const prefixed = words.slice(first, at)
719
720    if (isTesting || nameOf(words[at]) === '[[') {
721      // A test may assign, as arithmetic does.
722      isTesting = !words.some(word => bareOf(word) === ']]')
723      forget(shell)
724    } else if (typeof body !== 'number') {
725      // Whether a function is called is not told here: nothing in its body is taken to be run or assigned.
726      // A command run to make a word runs in a shell of its own, which begins with what this one holds, less what this statement assigns.
727      for (const piece of words.flatMap(word => word.pieces.filter(one => one.quote === '('))) {
728        const inner = { ...shell, vars: new Map(shell.vars), sealed: new Set(shell.sealed) }
729
730        strike(words.filter(word => assigned(word) !== null), inner)
731        for (const run of scan(statementsOf(piece.text) ?? [], inner, home)) runs.push(run.specSpan === undefined ? run : { ...run, specSpan: [run.specSpan[0] + piece.from, run.specSpan[1] + piece.from] })
732      }
733      if (at === words.length && isRun) assign(prefixed, shell, shell.vars, false)
734      else if (at === words.length) {
735        // One made under a condition, in a block or in a shell of its own may not have been made.
736        strike(prefixed, shell)
737        if (prefixed.length > 0) forget(shell)
738      } else if (!isHeader) {
739        const wrappers: string[] = []
740        let name = nameOf(words[at])
741
742        // What is assigned before a command is that command's alone: its own arguments are filled in without it.
743        while (name === 'timeout' || (name !== undefined && WRAPPERS.has(name))) {
744          wrappers.push(name)
745          at += name === 'timeout' ? 2 : 1
746          while (assigned(words[at]) !== null) at += 1
747          name = nameOf(words[at])
748        }
749        const args = words.slice(at + 1)
750        /** What this command alone is given, before it or after `env`. */
751        const lent = words.slice(first, at).map(word => assigned(word)?.[1] ?? '')
752        const isDetached = wrappers.includes('nohup')
753
754        // A variable a shell is ruled by, given to a command, rules the shell that command starts.
755        if (lent.some(one => RULING.has(one))) lose(shell)
756        const run = name === 'fleet-run' ? runOf(args, shell, after === '&', isDetached) : undefined
757        const handed = run === undefined ? handedOf(name, args) : undefined
758        const script = handed === undefined ? undefined : scriptOf(handed.word, shell)
759
760        if (run !== undefined) runs.push(run)
761        // The shell a script is handed to has this one's home directory only while this one has the one it began with, and is given no other.
762        if (handed !== undefined && script !== undefined) {
763          const inner = runsIn(script.text, shell.vars.get('HOME') === home && !lent.includes('HOME') ? home : undefined, shell.isLost || !handed.isPlain)
764
765          runs.push(...inner.map(one => placed(one, script, after === '&', isDetached || name === 'orca')))
766        }
767        // `exec` may hand the shell's own variables on, and where it stands in a shell of its own zsh goes on from it in that one too, running
768        // what follows twice; `env`, `nohup` and `timeout` run a program, which changes none; `command -v` only asks. Behind any of them a
769        // builtin that assigns is not sure to assign: `command` runs it in bash and sh, and zsh looks for a program of its name.
770        if (wrappers.includes('exec') && !isRun) lose(shell)
771        else if (wrappers.includes('exec')) forget(shell)
772        else if (wrappers.length > 0 && ASSIGNERS.has(bareOf(words[at]) ?? '')) affect(words[at], args, shell, false)
773        else if (wrappers.every(one => one === 'command') && !(wrappers.length > 0 && /^-[vV]$/.test(bareOf(words[at]) ?? ''))) affect(words[at], args, shell, isRun)
774        // Yet what is assigned before a command may outlast it, as before `export` or a special builtin of `sh`: what the variable held before is not sure after.
775        strike(prefixed, shell)
776      }
777    }
778    if (after === '(') parens += 1
779    else if (after === ')') parens = Math.max(0, parens - 1)
780    if (typeof body === 'number' && blocks + parens <= body) body = undefined
781  }
782
783  return runs
784}
785
786/** The launches a script makes, read by a shell that begins with this home directory; `isLost` where how that shell reads is not sure. */
787const runsIn = (script: string, home: string | undefined, isLost: boolean): FleetRun[] => {
788  const shell: Shell = {
789    vars: new Map(home === undefined ? [] : [['HOME', home]]),
790    sealed: new Set(),
791    // `IFS` named anywhere may be `IFS` assigned: what a variable written bare holds may then be parted at any character.
792    isWhole: !/\bIFS\b/.test(script),
793    isLost,
794  }
795
796  return scan(statementsOf(script) ?? [], shell, home)
797}
798
799/**
800 * The launches a command makes, in the order written: each `fleet-run` that
801 * stands as a command of its own, behind `nohup` and its like or not, and
802 * each in a script handed to a shell or to an Orca terminal in quotes. One
803 * that is only text (a comment, a here-document's line, an argument another
804 * command prints) is none, and neither is one in a function's body, which
805 * runs only where the function is called.
806 */
807export const findFleetRuns = (command: string, home?: string): FleetRun[] => runsIn(command, home, false)
808
809/** Where a character's copy of a spec goes: beside it, one name on, the character's own so that two do not share one. */
810export const voicedSpecPath = (spec: string, who: string): string =>
811  spec.endsWith('.md') ? `${spec.slice(0, -3)}.chiikawa-${who}.md` : `${spec}.chiikawa-${who}.md`
812
813export type Meta = { isOk: boolean; note: string; tokens?: Tokens }
814
815const count = (usage: Record<string, unknown>, key: string): number => {
816  const value = usage[key]
817
818  return typeof value === 'number' && Number.isFinite(value) ? value : 0
819}
820
821/**
822 * A worker's usage as one shape. Each engine spells its own: Codex counts
823 * the cached tokens inside `input_tokens`, the others beside them.
824 */
825export const readTokens = (usage: unknown): Tokens | undefined => {
826  if (typeof usage !== 'object' || usage === null) return undefined
827  const row = usage as Record<string, unknown>
828  const inside = count(row, 'cached_input_tokens')
829  const cached = inside + count(row, 'cache_read_input_tokens') + count(row, 'cache_read_tokens')
830  const fresh = count(row, 'input_tokens') - inside + count(row, 'cache_creation_input_tokens')
831  const out = count(row, 'output_tokens')
832  const usd = typeof row.cost_usd === 'number' ? row.cost_usd : undefined
833
834  return fresh + cached + out === 0 && usd === undefined ? undefined : { fresh: Math.max(0, fresh), cached, out, usd }
835}
836
837/** What a worker's `<out>.meta.json` says about how it ended. */
838export const readMeta = (text: string, words: Pick<Words, 'took' | 'exitCode'>): Meta | undefined => {
839  try {
840    const meta: unknown = JSON.parse(text)
841
842    if (typeof meta !== 'object' || meta === null) return undefined
843    const { exit_code: code, model, seconds, status, usage } = meta as Record<string, unknown>
844    const isOk = code === 0
845    const took = typeof seconds === 'number' ? words.took(Math.round(seconds)) : ''
846    const how = isOk ? '' : typeof status === 'string' ? status : words.exitCode(String(code))
847    const note = [typeof model === 'string' ? model : '', took, how].filter(part => part !== '').join(' · ')
848
849    return { isOk, note, tokens: readTokens(usage) }
850  } catch {
851    return undefined
852  }
853}
854
hooks/view.tsx 1535 lines
1import type { Color, Elements, RenderElement } from 'claude-code'
2
3import type { Aid, Aids, Lang, MemberId, Mood, Roles, Said, Task, Tokens, Usage, WorkRole } from '../types'
4
5import { ART_COLUMNS, ART_ROWS, artRows, ICON_COLUMNS, ICON_ROWS, iconOf } from './art'
6import { bare, CAST, CORE, isActive, isGesture, jobOf, LEAD, MEMBERS, ORDER, PINK, roleOf, say, shortName, shown, titleOf, toneOf, WORK_ROLES, WORKERS } from './cast'
7import type { Words } from './words'
8import { inAll, WORDS } from './words'
9
10/** How the drawings look just now: the tones of the person's theme, and the frame of what moves. */
11export type Look = { isLight: boolean; frame: number }
12
13export type Kit = Pick<Elements['terminal'], 'Box' | 'Text' | 'Raster' | 'Button'> & Look
14
15export type Scene = {
16  /** The language the screen is written in. */
17  lang: Lang
18  tasks: readonly Task[]
19  now: number
20  waveAt: number
21  usage: Usage | null
22  leaderTokens: Tokens
23  feed: readonly Said[]
24  /** The character the person's next prompt goes to, where they chose one. */
25  picked: MemberId | null
26  /** The character the pane tells of in full, where they asked about one. */
27  watched: MemberId | null
28  /** The hands lent to the main loop's own work, by whom and when. */
29  aids: Aids
30  /** Whether the pane shows the usage in full; one line of it otherwise. */
31  isUsageOpen: boolean
32  /** How many lines back from the last the pane's conversation is turned. */
33  talkBack: number
34  /** Whether the pane shows the conversation alone, as much of it as the pane holds. */
35  isTalkOnly: boolean
36  /** The roles the person gave; a character not named has its own. */
37  roles: Roles
38  /** What each character's tasks have cost so far, whatever has become of the tasks. */
39  paid: Partial<Record<MemberId, Tokens>>
40}
41
42/** The room a pane has: the cells across its body, and the rows it may take. */
43export type Room = { columns: number; rows: number }
44
45/** Shows one character's task in full, or with `null` puts it away. */
46export type OnWatch = (id: MemberId | null) => void
47
48const MARK: Record<Task['status'], string> = { running: '●', waiting: '◐', done: '✓', failed: '✗' }
49const TINT: Record<Task['status'], Color | undefined> = {
50  running: undefined,
51  waiting: 'warning',
52  done: 'success',
53  failed: 'error',
54}
55
56/** The mark of one at work, a frame at a time. */
57const SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
58
59const spin = (kit: Kit): string => SPIN[kit.frame % SPIN.length] ?? MARK.running
60
61const lineOf = (kit: Kit, id: MemberId): Color => toneOf(MEMBERS[id].line, kit.isLight)
62
63/** A name under the pointer: lit in the character's own colors. */
64const lit = (id: MemberId): { backgroundColor: Color; color: Color; bold: true } => ({ backgroundColor: MEMBERS[id].color, color: MEMBERS[id].ink, bold: true })
65
66/** How long a hand lent to the main loop's work shows as one. */
67export const AID_MS = 12_000
68
69/** The hand a character is lending just now, if it is. */
70export const aidOf = (scene: Pick<Scene, 'aids' | 'now'>, id: MemberId): Aid | undefined => {
71  const aid = scene.aids[id]
72
73  return aid !== undefined && scene.now - aid.at < AID_MS ? aid : undefined
74}
75
76const WIDE: readonly (readonly [number, number])[] = [
77  [0x1100, 0x115f],
78  [0x2e80, 0xa4cf],
79  [0xac00, 0xd7a3],
80  [0xf900, 0xfaff],
81  [0xfe30, 0xfe4f],
82  [0xff00, 0xff60],
83  [0xffe0, 0xffe6],
84  [0x1f1e6, 0x1f1ff],
85  [0x1f300, 0x1faff],
86  [0x20000, 0x3fffd],
87]
88
89const JOINED = /^(?:\p{M}|[\u200d\ufe0e\ufe0f\u{1f3fb}-\u{1f3ff}])$/u
90const FLAG = /^[\u{1f1e6}-\u{1f1ff}]$/u
91/** A sign a terminal draws as a picture with no selector asking for it: `✅`, `⌚`, `⏰`. */
92const PICTURED = /^\p{Emoji_Presentation}/u
93
94/** The text as the units a terminal draws: a letter with its accents, an emoji with all that is joined to it. */
95const clusters = (text: string): string[] => {
96  const units: string[] = []
97  let isJoining = false
98
99  for (const char of text) {
100    const last = units[units.length - 1]
101    const isPair = last !== undefined && FLAG.test(last) && FLAG.test(char)
102
103    if (last !== undefined && (isJoining || isPair || JOINED.test(char))) units[units.length - 1] = last + char
104    else units.push(char)
105    isJoining = char === '\u200d'
106  }
107
108  return units
109}
110
111const cellsOfCluster = (unit: string): number => {
112  const code = unit.codePointAt(0) ?? 0
113
114  // A mark with nothing to sit on takes no cell.
115  if (JOINED.test(String.fromCodePoint(code))) return 0
116
117  // A sign in its picture form is two cells, whatever its own width: one the selector asks it of, or one drawn so unasked.
118  return unit.includes('\ufe0f') || PICTURED.test(unit) || WIDE.some(([from, to]) => code >= from && code <= to) ? 2 : 1
119}
120
121/** Cells a string takes on a terminal row: Hangul, kana and emoji take two. */
122export const cells = (text: string): number => clusters(text).reduce((total, unit) => total + cellsOfCluster(unit), 0)
123
124/** The start of the text that goes in a width in cells, with nothing standing for the rest. */
125const clip = (text: string, width: number): string => {
126  let kept = ''
127  let used = 0
128
129  for (const unit of clusters(text)) {
130    const next = cellsOfCluster(unit)
131
132    if (used + next > width) break
133    kept += unit
134    used += next
135  }
136
137  return kept
138}
139
140/** The text cut to a width in cells, an ellipsis standing for what was cut. */
141export const fit = (text: string, width: number): string => {
142  if (width <= 0) return ''
143
144  return cells(text) <= width ? text : `${clip(text, width - 1)}…`
145}
146
147/** The text cut to a width from its start, an ellipsis standing for what was cut: a path keeps its file's name. */
148export const fitEnd = (text: string, width: number): string => {
149  if (width <= 0) return ''
150  if (cells(text) <= width) return text
151  const units = clusters(text)
152  let kept = ''
153  let used = 0
154
155  for (let at = units.length - 1; at >= 0; at -= 1) {
156    const unit = units[at] ?? ''
157    const next = cellsOfCluster(unit)
158
159    if (used + next > width - 1) break
160    kept = unit + kept
161    used += next
162  }
163
164  return `…${kept}`
165}
166
167export const pad = (text: string, width: number): string => text + ' '.repeat(Math.max(0, width - cells(text)))
168
169const wordsOf = (scene: Pick<Scene, 'lang'>): Words => WORDS[scene.lang]
170
171const nameOf = (scene: Pick<Scene, 'lang'>, id: MemberId): string => CAST[scene.lang].names[id]
172
173/** A character's name in a width: its short one where the whole does not go in, cut where even that does not. */
174const nameIn = (lang: Lang, id: MemberId, width: number, most = width): string => {
175  const whole = CAST[lang].names[id]
176
177  return cells(whole) <= width ? whole : fit(shortName(lang, id), most)
178}
179
180export const clock = (ms: number): string => {
181  const seconds = Math.max(0, Math.floor(ms / 1000))
182
183  return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
184}
185
186export const elapsed = (task: Task, now: number): number => (task.endedAt ?? Math.max(now, task.startedAt)) - task.startedAt
187
188/** The tasks of the wave in hand: the active ones first, then the newest. */
189export const onStage = (scene: Scene): Task[] =>
190  scene.tasks
191    .filter(task => task.startedAt >= scene.waveAt || isActive(task))
192    .sort((a, b) => Number(isActive(b)) - Number(isActive(a)) || b.startedAt - a.startedAt)
193
194/** The last line said, by anyone or by one character. */
195export const lastSaid = (scene: Pick<Scene, 'feed'>, id?: MemberId): Said | undefined => {
196  for (let at = scene.feed.length - 1; at >= 0; at -= 1) {
197    const said = scene.feed[at]
198
199    if (said !== undefined && (id === undefined || said.member === id)) return said
200  }
201
202  return undefined
203}
204
205const isRunning = (task: Task): boolean => task.status === 'running'
206
207/** The ones at work. One whose task waits, on an answer or on a result that has not come, is not: it rests until it goes on. */
208const busyOf = (scene: Scene): MemberId[] => WORKERS.filter(id => scene.tasks.some(task => task.member === id && isRunning(task)))
209
210const waitingOf = (scene: Scene): MemberId[] => WORKERS.filter(id => !scene.tasks.some(task => task.member === id && isRunning(task)) && scene.tasks.some(task => task.member === id && isActive(task)))
211
212/** Whether a character is at work: one with a task in hand or lending a hand, and 하치와레 while anyone has a task. */
213const isAtWork = (scene: Scene, id: MemberId): boolean => aidOf(scene, id) !== undefined || scene.tasks.some(task => isRunning(task) && (id === LEAD || task.member === id))
214
215const Badge = ({ Text }: Kit, id: MemberId, name: string): RenderElement => (
216  <Text backgroundColor={MEMBERS[id].color} color={MEMBERS[id].ink} bold>
217    {` ${name} `}
218  </Text>
219)
220
221const Dot = (kit: Kit, id: MemberId): RenderElement => <kit.Text color={lineOf(kit, id)}>● </kit.Text>
222
223/** A character's picture in keyboard characters, wearing the mood's face; one at work walks. */
224const Picture = (kit: Kit, id: MemberId, mood: Mood, isWalking = false): RenderElement => {
225  const { Box, Text } = kit
226
227  return (
228    <Box flexDirection="column" width={ART_COLUMNS}>
229      {artRows(id, mood, kit.isLight, isWalking && kit.frame % 2 === 1).map((runs, row) => (
230        <Text key={`art-${row}`}>
231          {runs.map((run, index) => (
232            <Text key={`run-${index}`} color={run.color}>
233              {run.text}
234            </Text>
235          ))}
236        </Text>
237      ))}
238    </Box>
239  )
240}
241
242/** A character's pixel icon; one at work bounces. */
243const Icon = (kit: Kit, id: MemberId, isBouncing = false): RenderElement => (
244  <kit.Raster key={`icon-${id}`} columns={ICON_COLUMNS} rows={ICON_ROWS} cells={iconOf(id, kit.isLight, isBouncing && kit.frame % 2 === 1)} />
245)
246
247/** A line or a gesture in the character's color; a gesture is told plainly, a line in italics. */
248const Quote = (kit: Kit, id: MemberId, quote: string, width: number): RenderElement => (
249  <kit.Text color={lineOf(kit, id)} italic={!isGesture(quote)}>
250    {fit(shown(quote), width)}
251  </kit.Text>
252)
253
254// ---- usage
255
256export const ZERO: Tokens = { fresh: 0, cached: 0, out: 0 }
257
258export const plus = (a: Tokens, b: Tokens | undefined): Tokens => {
259  if (b === undefined) return a
260  const usd = a.usd === undefined && b.usd === undefined ? undefined : (a.usd ?? 0) + (b.usd ?? 0)
261
262  return { fresh: a.fresh + b.fresh, cached: a.cached + b.cached, out: a.out + b.out, usd }
263}
264
265export const spent = (lang: Lang, tokens: Tokens): string => {
266  const { amount, tokens: told } = WORDS[lang]
267  const cost = tokens.usd === undefined ? '' : ` · $${tokens.usd.toFixed(2)}`
268
269  return `${told(amount(tokens.fresh), amount(tokens.cached), amount(tokens.out))}${cost}`
270}
271
272/** What each character has spent so far: what its tasks have cost, 하치와레's the main loop's. */
273export const spentBy = (scene: Pick<Scene, 'paid' | 'leaderTokens'>): Record<MemberId, Tokens> => {
274  const by = Object.fromEntries(ORDER.map(id => [id, scene.paid[id] ?? ZERO])) as Record<MemberId, Tokens>
275
276  by[LEAD] = scene.leaderTokens
277
278  return by
279}
280
281const isSpent = (tokens: Tokens): boolean => tokens.fresh + tokens.cached + tokens.out > 0 || tokens.usd !== undefined
282
283/** When a window resets, as the language says it. */
284export const until = (lang: Lang, at: number, now: number): string => WORDS[lang].until(Math.max(0, Math.round((at - now) / 60_000)))
285
286const bar = (percent: number): string => {
287  const lit = percent <= 0 ? 0 : Math.max(1, Math.min(10, Math.round(percent / 10)))
288
289  return '▰'.repeat(lit) + '▱'.repeat(10 - lit)
290}
291
292/** The context window still free, 0 to 100, when the engine has a reading. */
293export const batteryOf = (usage: Usage | null): number | undefined =>
294  usage?.contextPercent === undefined ? undefined : Math.max(0, 100 - usage.contextPercent)
295
296/** The band's one-glance gauge, the part to keep longest first: battery left, and the five-hour window. */
297export const gaugeParts = (lang: Lang, usage: Usage | null): string[] => {
298  const words = WORDS[lang]
299  const free = batteryOf(usage)
300  const window = usage?.limits.find(limit => limit.kind === 'five_hour')
301
302  return [free === undefined ? '' : `${words.battery} ${Math.round(free)}%`, window === undefined ? '' : `${words.limits.five_hour ?? ''} ${window.percentUsed}%`].filter(part => part !== '')
303}
304
305type Meter = { label: string; percent: number; tint: Color; text: string }
306
307/** The cells a meter's label takes with the cell after it: the widest the language has. */
308const labelCells = (words: Words): number => Math.max(cells(words.battery), ...Object.values(words.limits).map(cells)) + 1
309
310const metersOf = (scene: Scene): Meter[] => {
311  const { usage } = scene
312  const words = wordsOf(scene)
313  const free = batteryOf(usage)
314  const meters: Meter[] = []
315
316  if (usage !== null && free !== undefined) {
317    meters.push({
318      label: words.battery,
319      percent: free,
320      tint: free < 20 ? 'error' : free < 40 ? 'warning' : 'success',
321      text: words.left(Math.round(free), words.amount(usage.contextTokens ?? 0), words.amount(usage.contextWindow)),
322    })
323  }
324  for (const limit of usage?.limits ?? []) {
325    const reset = limit.resetsAt === undefined ? '' : ` · ${until(scene.lang, limit.resetsAt, scene.now)}`
326
327    meters.push({
328      label: words.limits[limit.kind] ?? limit.kind,
329      percent: limit.percentUsed,
330      tint: limit.percentUsed >= 90 ? 'error' : limit.percentUsed >= 70 ? 'warning' : 'success',
331      text: `${words.used(limit.percentUsed)}${reset}`,
332    })
333  }
334
335  return meters
336}
337
338const spendersOf = (scene: Scene): MemberId[] => {
339  const by = spentBy(scene)
340
341  return ORDER.filter(id => isSpent(by[id]))
342}
343
344/** The cells a spender's name has in the usage card, with the cell after it. */
345const PAID_NAME = 15
346
347/** Rows the usage card takes, frame and all. */
348const usageRows = (scene: Scene): number => {
349  const spenders = spendersOf(scene).length
350  const rows = metersOf(scene).length + (spenders === 0 ? 0 : spenders + 1)
351
352  return 2 + 1 + Math.max(1, rows)
353}
354
355/** The usage card: the session's battery, the plan's windows, who earned what. */
356const UsageCard = (kit: Kit, scene: Scene, width: number, onUsage: () => void): RenderElement => {
357  const { Box, Text, Button } = kit
358  const inner = width - 4
359  const words = wordsOf(scene)
360  const meters = metersOf(scene)
361  const by = spentBy(scene)
362  const spenders = spendersOf(scene)
363  const usd = scene.usage?.usd
364  const label = labelCells(words)
365
366  return (
367    <Box borderStyle="round" borderColor="subtle" paddingX={1} flexDirection="column" width={width}>
368      <Box justifyContent="space-between" width={inner}>
369        <Box gap={1}>
370          <Text bold>{words.usage}</Text>
371          {usd !== undefined && <Text dimColor>{fit(words.session(usd.toFixed(2)), inner - cells(words.usage) - cells(words.fold) - 3)}</Text>}
372        </Box>
373        <Button key="usage-less" label={words.fold} plain dimColor onPress={() => onUsage()} />
374      </Box>
375      {meters.map(meter => (
376        <Text key={meter.label} wrap="truncate-end">
377          <Text dimColor>{pad(meter.label, label)}</Text>
378          <Text color={meter.tint}>{bar(meter.percent)}</Text>
379          <Text>{` ${fit(meter.text, inner - label - 11)}`}</Text>
380        </Text>
381      ))}
382      {spenders.length > 0 && <Text dimColor>{fit(words.pay, inner)}</Text>}
383      {spenders.map(id => (
384        <Text key={id} wrap="truncate-end">
385          {Dot(kit, id)}
386          <Text>{pad(nameIn(scene.lang, id, PAID_NAME - 1), PAID_NAME)}</Text>
387          <Text dimColor>{fit(spent(scene.lang, by[id]), inner - PAID_NAME - 2)}</Text>
388        </Text>
389      ))}
390      {meters.length === 0 && spenders.length === 0 && <Text dimColor>{fit(words.noUsage, inner)}</Text>}
391    </Box>
392  )
393}
394
395// ---- the conversation, a line to a row: where there is no room for a comic
396
397/** One line of the conversation: who, what was said or done, and what it was about. */
398const TalkRow = (kit: Kit, lang: Lang, said: Said, width: number, key: string): RenderElement => {
399  const { Text } = kit
400  const name = CAST[lang].names[said.member]
401  const room = width - cells(name) - 3
402  const quote = said.quote[lang]
403  const note = said.note[lang]
404  const noteRoom = room - cells(fit(shown(quote), room)) - 1
405
406  return (
407    <Text key={key} wrap="truncate-end">
408      {Badge(kit, said.member, name)}
409      <Text> </Text>
410      {Quote(kit, said.member, quote, room)}
411      {note !== '' && noteRoom >= 6 && <Text dimColor>{` ${fit(note, noteRoom)}`}</Text>}
412    </Text>
413  )
414}
415
416/** What stands for the conversation before anything was said: 하치와레, waiting for work. */
417const waitingSaid: Said = { member: LEAD, quote: inAll(lang => CAST[lang].leader.idle), note: inAll(lang => WORDS[lang].awaiting), at: 0, mood: 'calm' }
418
419/** The last lines of the conversation, oldest first, beside the picture of whoever spoke last. */
420const Talk = (kit: Kit, scene: Scene, width: number, lines: number, withPicture: boolean): RenderElement => {
421  const { Box } = kit
422  const said = scene.feed.length === 0 ? [waitingSaid] : scene.feed.slice(-Math.max(1, lines))
423  const latest = said[said.length - 1] ?? waitingSaid
424  const room = withPicture ? width - ART_COLUMNS - 1 : width
425
426  return (
427    <Box gap={1} width={width}>
428      {withPicture && Picture(kit, latest.member, latest.mood, isAtWork(scene, latest.member))}
429      <Box flexDirection="column" width={room} justifyContent="flex-end">
430        {said.map((one, index) => TalkRow(kit, scene.lang, one, room, `said-${one.at}-${index}`))}
431      </Box>
432    </Box>
433  )
434}
435
436// ---- the conversation as a comic: a picture, and a balloon for what was said
437
438/** The narrowest a cut is drawn: a picture, and a balloon that holds a short line. */
439const CUT_COLUMNS = 46
440const MAX_CUTS = 8
441/** The cuts the conversation has before the ones resting are given seats. */
442const MIN_CUTS = 3
443
444const isShout = (quote: string): boolean => /[!!]\s*$/.test(quote)
445
446/**
447 * One cut of the comic: the speaker's picture, and a balloon with its name on
448 * the rim and its tail toward the picture. A gesture is a caption instead, a
449 * square box with no tail. What the line is about stands dim beside the
450 * balloon, or on its lower rim where there is no room beside. `isTurned` puts
451 * the picture on the right, so that two cuts face each other.
452 */
453const Cut = (kit: Kit, lang: Lang, said: Said, width: number, isTurned: boolean, isWalking: boolean, key: string): RenderElement => {
454  const { Box, Text } = kit
455  const line = lineOf(kit, said.member)
456  const quote = said.quote[lang]
457  const note = said.note[lang]
458  const isCaption = isGesture(quote)
459  const words = bare(quote)
460  const room = width - ART_COLUMNS - 1
461  const name = nameIn(lang, said.member, Math.max(2, room - 6))
462  const own = Math.max(cells(words), cells(name) + 2) + 4
463  const isBeside = note !== '' && room - own - 1 >= Math.min(cells(note), 10)
464  const outer = Math.max(cells(name) + 6, Math.min(room, isBeside || note === '' ? own : Math.max(own, cells(note) + 6)))
465  const [topLeft, topRight, bottomLeft, bottomRight] = isCaption ? ['┌', '┐', '└', '┘'] : ['╭', '╮', '╰', '╯']
466  const text = fit(words, outer - 4)
467  const fill = ' '.repeat(Math.max(0, outer - 4 - cells(text)))
468  const beside = isBeside ? fit(note, room - outer - 1) : ''
469  const under = isBeside || note === '' ? '' : fit(note, outer - 6)
470  const rim = '─'.repeat(Math.max(0, outer - 5 - cells(name)))
471  const foot = '─'.repeat(Math.max(0, under === '' ? outer - 2 : outer - 5 - cells(under)))
472  const push = ' '.repeat(Math.max(0, room - outer))
473  const lead = beside === '' ? push : `${' '.repeat(Math.max(0, room - outer - 1 - cells(beside)))}${beside} `
474  const tail = isCaption ? '│' : isTurned ? '>' : '<'
475  const picture = Picture(kit, said.member, said.mood, isWalking)
476
477  return (
478    <Box key={key} gap={1} width={width}>
479      {!isTurned && picture}
480      <Box flexDirection="column" width={room}>
481        <Text wrap="truncate-end">
482          <Text color={line}>{isTurned ? `${push}${topLeft}${rim}` : `${topLeft}─`}</Text>
483          {Badge(kit, said.member, name)}
484          <Text color={line}>{isTurned ? `─${topRight}` : `${rim}${topRight}`}</Text>
485        </Text>
486        <Text wrap="truncate-end">
487          {isTurned && <Text dimColor>{lead}</Text>}
488          <Text color={line}>{isTurned ? '│ ' : `${tail} `}</Text>
489          <Text color={line} italic={!isCaption} bold={isShout(words)}>
490            {text}
491          </Text>
492          <Text color={line}>{`${fill} ${isTurned ? tail : '│'}`}</Text>
493          {!isTurned && beside !== '' && <Text dimColor>{` ${beside}`}</Text>}
494        </Text>
495        <Text wrap="truncate-end">
496          <Text color={line}>{`${isTurned ? push : ''}${bottomLeft}${under === '' ? '' : '─ '}`}</Text>
497          {under !== '' && <Text dimColor>{under}</Text>}
498          <Text color={line}>{`${under === '' ? '' : ' '}${foot}${bottomRight}`}</Text>
499        </Text>
500      </Box>
501      {isTurned && picture}
502    </Box>
503  )
504}
505
506/** Which lines of the conversation a page of `count` cuts holds, turned back as far as the person turned it. */
507const shownOf = (scene: Scene, count: number): { from: number; to: number } => {
508  const size = Math.max(1, count)
509  const to = Math.max(Math.min(size, scene.feed.length), scene.feed.length - Math.max(0, scene.talkBack))
510
511  return { from: Math.max(0, to - size), to }
512}
513
514/** The last lines of the conversation as cuts down a page, oldest first: the friends on the left, 하치와레 answering from the right. */
515const Page = (kit: Kit, scene: Scene, width: number, count: number): RenderElement => {
516  const { from, to } = shownOf(scene, count)
517  const said = scene.feed.length === 0 ? [waitingSaid] : scene.feed.slice(from, to)
518
519  return (
520    <kit.Box flexDirection="column" width={width}>
521      {said.map((one, index) => Cut(kit, scene.lang, one, width, one.member === LEAD, isAtWork(scene, one.member), `cut-${one.at}-${index}`))}
522    </kit.Box>
523  )
524}
525
526// ---- the band
527
528/** What the band shows and what a press in it asks for. */
529export type Band = {
530  /** Whether there is a conversation to show under the row of friends. */
531  isLive: boolean
532  /** A friend was pressed: the pane shows what it is doing. */
533  onSeat: (id: MemberId) => void
534  /** The count of friends the row has no room to name was pressed: the pane opens. */
535  onOpen: () => void
536  /** The usage was pressed: the pane opens with the usage in full. */
537  onUsage: () => void
538  /** Who takes the next prompt was pressed: a chosen friend is let go, and 하치와레 decides again. */
539  onAuto: () => void
540}
541
542type Seat = {
543  id: MemberId
544  /** The mark of its task in this wave; none for one with no task. */
545  glyph: string
546  isRunning: boolean
547  tint: Color | undefined
548  label: string
549  /** How long its task has run, while it runs. */
550  time: string
551  isBusy: boolean
552  isDim: boolean
553}
554
555const SEAT_GAP = 2
556const GUESTS: readonly MemberId[] = WORKERS.filter(id => !CORE.includes(id))
557
558const seatOf = (scene: Scene, stage: readonly Task[], id: MemberId): Seat => {
559  const task = id === LEAD ? stage.find(isRunning) : stage.find(one => one.member === id)
560  const isHelping = aidOf(scene, id) !== undefined && (task === undefined || !isActive(task))
561  // One that waits keeps its mark and its time, and is counted with the ones resting.
562  const isBusy = isHelping || (task !== undefined && isRunning(task))
563  const isPicked = scene.picked === id
564
565  return {
566    id,
567    glyph: isHelping ? MARK.running : task === undefined ? '' : id === LEAD ? MARK.running : MARK[task.status],
568    isRunning: isHelping || (task !== undefined && (id === LEAD || task.status === 'running')),
569    tint: isHelping || task === undefined || id === LEAD ? undefined : TINT[task.status],
570    label: `${isPicked ? '▶' : ''}${shortName(scene.lang, id)}`,
571    time: id !== LEAD && task !== undefined && isActive(task) ? ` ${clock(elapsed(task, scene.now))}` : '',
572    isBusy,
573    isDim: !isPicked && !isBusy,
574  }
575}
576
577const seatCells = (seat: Seat, hasTime: boolean): number => SEAT_GAP + (seat.glyph === '' ? 0 : 2) + cells(seat.label) + (hasTime ? cells(seat.time) : 0)
578
579type BandRow = {
580  /** The three the comic is about: always named. */
581  core: Seat[]
582  /** The others the row names. */
583  guests: Seat[]
584  /** What stands for the others it has no room to name; empty where it names them all. */
585  rest: string
586  hasTime: boolean
587  /** Who takes the next prompt; empty where there is no room to say. */
588  to: string
589  gauge: string
590}
591
592/**
593 * The fullest row the band's width holds, least room last: everyone by name,
594 * then the three and whoever is at work with the rest as a count, then the
595 * three alone; the time a task has run goes before a name does, then the
596 * gauge's second half, then who takes the next prompt.
597 */
598const rowOf = (scene: Scene, columns: number): BandRow => {
599  const stage = onStage(scene)
600  const core = CORE.map(id => seatOf(scene, stage, id))
601  const guests = GUESTS.map(id => seatOf(scene, stage, id))
602  const called = guests.filter(seat => seat.isBusy || scene.picked === seat.id)
603  const words = wordsOf(scene)
604  // The mode's name in its badge, a cell either side.
605  const title = cells(words.brand) + 2
606  const parts = gaugeParts(scene.lang, scene.usage)
607  const whole = parts.length === 0 ? words.usage : parts.join(' · ')
608  const brief = parts[0] ?? whole
609  const sent = words.sent(scene.picked === null ? words.auto : shortName(scene.lang, scene.picked))
610  const tries: readonly (readonly [named: Seat[], hasTime: boolean, gauge: string, to: string])[] = [
611    [guests, true, whole, sent],
612    [guests, false, whole, sent],
613    [guests, false, brief, sent],
614    [called, true, whole, sent],
615    [called, false, whole, sent],
616    [called, false, brief, sent],
617    [[], false, brief, sent],
618    [[], false, brief, ''],
619    [[], false, '', ''],
620  ]
621
622  for (const [named, hasTime, gauge, to] of tries) {
623    const unnamed = guests.filter(seat => !named.includes(seat))
624    const atWork = unnamed.filter(seat => seat.isBusy).length
625    const resting = unnamed.length - atWork
626    const counts = [atWork === 0 ? '' : words.working(atWork), resting === 0 ? '' : words.resting(resting)].filter(part => part !== '')
627    const seats = [...core, ...named].reduce((total, seat) => total + seatCells(seat, hasTime), 0)
628
629    // Both counts where they fit, and the one of those at work where they do not.
630    for (const rest of counts.length === 2 ? [counts.join(' · '), counts[0] ?? ''] : [counts[0] ?? '']) {
631      const used = title + seats + (named.length === 0 ? 0 : SEAT_GAP + 1) + (rest === '' ? 0 : SEAT_GAP + cells(rest)) + (to === '' ? 0 : SEAT_GAP + cells(to)) + (gauge === '' ? 0 : SEAT_GAP + cells(gauge))
632
633      if (used <= columns) return { core, guests: named, rest, hasTime, to, gauge }
634    }
635  }
636
637  return { core: [], guests: [], rest: '', hasTime: false, to: '', gauge: title + 1 + cells(brief) <= columns ? brief : '' }
638}
639
640/** One friend in the band's row: the mark of its task, its name to press, and how long the task has run. */
641const SeatBox = (kit: Kit, seat: Seat, hasTime: boolean, onSeat: (id: MemberId) => void): RenderElement => {
642  const { Box, Text, Button } = kit
643  const tone = seat.tint ?? lineOf(kit, seat.id)
644
645  return (
646    <Box key={seat.id}>
647      {seat.glyph !== '' && <Text color={tone}>{`${seat.isRunning ? spin(kit) : seat.glyph} `}</Text>}
648      <Button key={`seat-${seat.id}`} label={seat.label} plain dimColor={seat.isDim} hover={lit(seat.id)} onPress={() => onSeat(seat.id)} />
649      {hasTime && seat.time !== '' && <Text color={tone}>{seat.time}</Text>}
650    </Box>
651  )
652}
653
654/**
655 * The band above the prompt: the friends by name, each to press, the three
656 * the comic is about first and the others after a rule; at the right end
657 * who takes the next prompt (하치와레 decides, unless a friend was chosen)
658 * and the usage; and under them the last line said, as one cut, while there
659 * is a conversation the pane is not showing.
660 */
661export const drawBand = (kit: Kit, scene: Scene, columns: number, rows: number, band: Band): RenderElement => {
662  const { Box, Text, Button } = kit
663  const row = rowOf(scene, columns)
664  const isTalking = band.isLive && scene.feed.length > 0
665  const latest = isTalking ? scene.feed[scene.feed.length - 1] : undefined
666  const isComic = rows - 1 >= ART_ROWS && columns >= CUT_COLUMNS
667
668  return (
669    <Box flexDirection="column" width={columns}>
670      <Box justifyContent="space-between" width={columns}>
671        <Box gap={SEAT_GAP}>
672          <Text backgroundColor={PINK} color="#000000" bold>
673            {` ${wordsOf(scene).brand} `}
674          </Text>
675          {row.core.map(seat => SeatBox(kit, seat, row.hasTime, band.onSeat))}
676          {row.guests.length > 0 && <Text dimColor>│</Text>}
677          {row.guests.map(seat => SeatBox(kit, seat, row.hasTime, band.onSeat))}
678          {row.rest !== '' && <Button key="rest" label={row.rest} plain dimColor onPress={() => band.onOpen()} />}
679        </Box>
680        <Box gap={SEAT_GAP}>
681          {row.to !== '' && <Button key="to" label={row.to} plain dimColor={scene.picked === null} onPress={() => band.onAuto()} />}
682          {row.gauge !== '' && <Button key="usage" label={row.gauge} plain dimColor onPress={() => band.onUsage()} />}
683        </Box>
684      </Box>
685      {latest !== undefined && isComic && Cut(kit, scene.lang, latest, Math.min(columns, 84), false, isAtWork(scene, latest.member), 'cut')}
686      {latest !== undefined && !isComic && rows >= 2 && TalkRow(kit, scene.lang, latest, columns, 'said')}
687    </Box>
688  )
689}
690
691// ---- the pane: the three the comic is about, a card for each guest at work, the rest on the bench
692
693/** What a press in the pane asks for. */
694export type PaneActs = {
695  onWatch: OnWatch
696  /** The character whose sheet is open is to take the person's next prompt, or no longer to. */
697  onPick: (id: MemberId) => void
698  /** The usage is to be shown in full, or put back to its one line. */
699  onUsage: () => void
700  /** The conversation is to be turned so many lines back from the last; 0 for the last ones. */
701  onTalk: (back: number) => void
702  /** The conversation is to be shown alone, or the whole page again. */
703  onStory: () => void
704  /** A character is given a role; its own role gives it back what it had. */
705  onRole: (id: MemberId, role: WorkRole) => void
706}
707
708type Card = {
709  id: MemberId
710  name: string
711  sub: string
712  status: string
713  tint: Color | undefined
714  isIdle: boolean
715  /** At work this moment: its mark turns and its picture moves. */
716  isRunning: boolean
717  /** Its task has ended. */
718  isEnded: boolean
719  work: string
720  /** The task's name alone, where the card is about one. */
721  title?: string
722  quote: string
723  note: string
724  /** What it is on now, or what it handed back. */
725  last: string
726}
727
728/** The day so far, for 하치와레's card: the tasks that ended well, and the ones that failed; nothing before any has ended. */
729const tally = (scene: Scene): { done: number; failed: number } | undefined => {
730  const ended = scene.tasks.filter(task => !isActive(task))
731  const failed = ended.filter(task => task.status === 'failed').length
732
733  return ended.length === 0 ? undefined : { done: ended.length - failed, failed }
734}
735
736/** A character's tasks, the newest first. */
737const tasksOf = (scene: Scene, id: MemberId): Task[] => scene.tasks.filter(task => task.member === id).sort((a, b) => b.startedAt - a.startedAt)
738
739/** The engine a task runs on: an Orca worker's profile is told as one; a demonstration, which is due at a set time, has only the language's word for one. */
740const engineOf = (words: Words, task: Task): string => (task.due !== undefined ? words.demo : task.kind === 'orca' ? `Orca ${task.engine}` : task.engine)
741
742const doingOf = (words: Words, task: Task): string => {
743  const on = task.detail === undefined || task.detail === '' ? '' : ` ${task.detail}`
744
745  return task.tool === undefined ? '' : `▸ ${task.tool}${on} · ${words.tools(task.toolCount)}`
746}
747
748const cardOf = (id: MemberId, scene: Scene): Card => {
749  const words = wordsOf(scene)
750  const { lang } = scene
751  const cast = CAST[lang]
752  const name = cast.names[id]
753  const sub = [cast.kinds[id], cast.titles[id], cast.job[roleOf(id, scene.roles)]].filter(part => part !== '').join(' · ')
754  const idle = `○ ${words.idle}`
755
756  if (id === LEAD || id === 'rodo') {
757    const busy = busyOf(scene)
758    const said = lastSaid(scene, id)
759    const doing = id === LEAD ? words.leading : words.receiving
760    const day = tally(scene)
761    // With no friend called in, 하치와레 is at the work himself, the other two lending a hand.
762    const own = id === LEAD && busy.length === 0 ? aidOf(scene, LEAD) : undefined
763    const hands = CORE.filter(one => one !== LEAD && aidOf(scene, one) !== undefined)
764    const isAlone = busy.length === 0 && own === undefined
765    const what = own?.what[lang]
766
767    return {
768      id,
769      name,
770      sub,
771      status: isAlone ? idle : `${MARK.running} ${own === undefined ? doing : hands.length === 0 ? words.alone : words.together}`,
772      tint: undefined,
773      isIdle: isAlone,
774      isRunning: !isAlone,
775      isEnded: false,
776      work: what ?? (busy.length === 0 ? words.awaiting : words.atWork(busy.map(one => cast.names[one]))),
777      // In a cut: how many have the work, or whose hand he has; the sheet has the rest.
778      ...(id !== LEAD ? {} : busy.length > 0 ? { title: words.handed(busy.length) } : what !== undefined ? { title: own?.brief?.[lang] ?? what.split(' · ')[0] ?? what } : {}),
779      quote: said?.quote[lang] ?? say(lang, id, 'idle', 0),
780      note: said?.note[lang] ?? '',
781      last: id === LEAD && day !== undefined ? words.tally(day.done, day.failed) : '',
782    }
783  }
784
785  const own = tasksOf(scene, id)
786  const task = own.find(isActive) ?? own[0]
787  const aid = aidOf(scene, id)
788
789  if (aid !== undefined && (task === undefined || !isActive(task))) {
790    const said = lastSaid(scene, id)
791
792    return { id, name, sub, status: `${MARK.running} ${words.helping}`, tint: undefined, isIdle: false, isRunning: true, isEnded: false, work: aid.what[lang], quote: said?.quote[lang] ?? say(lang, id, 'start', 0), note: said?.note[lang] ?? '', last: '' }
793  }
794  if (task === undefined) {
795    return { id, name, sub, status: idle, tint: undefined, isIdle: true, isRunning: false, isEnded: false, work: jobOf(lang, id, scene.roles), quote: say(lang, id, 'idle', 0), note: '', last: '' }
796  }
797
798  const active = own.filter(isActive).length
799  const more = active > 1 ? ` ${words.more(active - 1)}` : ''
800  const back = task.report === undefined ? '' : `↳ ${task.report[lang]}`
801  const title = `${titleOf(lang, task)}${more}`
802
803  return {
804    id,
805    name,
806    sub,
807    status: `${MARK[task.status]} ${words.status[task.status]} ${clock(elapsed(task, scene.now))}`,
808    tint: TINT[task.status],
809    isIdle: false,
810    isRunning: task.status === 'running',
811    isEnded: !isActive(task),
812    work: `${title} · ${engineOf(words, task)}`,
813    title,
814    quote: task.quote[lang],
815    note: task.note[lang],
816    last: isActive(task) ? doingOf(words, task) : back,
817  }
818}
819
820/** A card's state as drawn: the mark of one at work turns. */
821const statusOf = (kit: Kit, card: Card): string => (card.isRunning ? card.status.replace(MARK.running, spin(kit)) : card.status)
822
823/** A card's state in its color. */
824const State = (kit: Kit, card: Card): RenderElement => (
825  <kit.Text color={card.tint} bold={!card.isIdle} dimColor={card.isIdle}>
826    {statusOf(kit, card)}
827  </kit.Text>
828)
829
830/**
831 * A character's name as the thing to press: the pane then shows what that
832 * character is doing. It lights up in the character's colors while its card
833 * is under the pointer.
834 */
835const Name = (kit: Kit, id: MemberId, onWatch: OnWatch, label: string, isDim = false): RenderElement => (
836  <kit.Button key={`watch-${id}`} label={label} plain dimColor={isDim} hover={lit(id)} onPress={() => onWatch(id)} />
837)
838
839/** A card's first row: the name to press, short where the whole of it would push the state past the edge, and its state at the right edge. */
840const Head = (kit: Kit, lang: Lang, card: Card, width: number, onWatch: OnWatch): RenderElement => (
841  <kit.Box justifyContent="space-between" width={width}>
842    <kit.Box>
843      {Dot(kit, card.id)}
844      {Name(kit, card.id, onWatch, nameIn(lang, card.id, width - 3 - cells(card.status)))}
845    </kit.Box>
846    {State(kit, card)}
847  </kit.Box>
848)
849
850/** Two rows a character behind a bar of its color: for a pane a few rows tall. */
851const SlimCard = (kit: Kit, lang: Lang, card: Card, width: number, onWatch: OnWatch): RenderElement => {
852  const { Box, Text } = kit
853  const line = lineOf(kit, card.id)
854  const quote = fit(shown(card.quote), Math.min(24, Math.floor(width / 2)))
855
856  return (
857    <Box key={card.id} flexDirection="column" width={width}>
858      <Box width={width}>
859        <Text color={line}>▌</Text>
860        {Head(kit, lang, card, width - 1, onWatch)}
861      </Box>
862      <Text wrap="truncate-end">
863        <Text color={line}>▌</Text>
864        <Text dimColor={card.isIdle}>{`  ${fit(card.work, width - 4 - cells(quote))} `}</Text>
865        <Text color={line} italic={!isGesture(card.quote)}>
866          {quote}
867        </Text>
868      </Text>
869    </Box>
870  )
871}
872
873/** A row with what it holds set in its middle by hand: a Button takes the start of the row it is given. */
874const Middle = (kit: Kit, width: number, used: number, held: RenderElement): RenderElement => {
875  const lead = Math.max(0, Math.floor((width - used) / 2))
876
877  return (
878    <kit.Box width={width}>
879      {lead > 0 && <kit.Text>{' '.repeat(lead)}</kit.Text>}
880      {held}
881    </kit.Box>
882  )
883}
884
885/** Rows a cut holds inside its frame: the icon, the name, its state, and what it is on. */
886const PANEL_ROWS = ICON_ROWS + 3
887
888/**
889 * A character on the page, as a cut of its own: the icon over the name to
890 * press, its state, and one line of what it is on, handed back or last said.
891 * The frame takes the character's color while it is at work; who it is and
892 * the rest of what it is doing are on its sheet.
893 */
894const Panel = (kit: Kit, lang: Lang, card: Card, width: number, onWatch: OnWatch): RenderElement => {
895  const { Box, Text } = kit
896  const inner = width - 4
897  const line = lineOf(kit, card.id)
898  const name = nameIn(lang, card.id, inner)
899  // With nothing in hand and nothing handed back, the cut shows what the character last said.
900  const isTold = card.isIdle || (card.isEnded && card.last === '')
901  const about = isTold ? shown(card.quote) : card.isEnded ? card.last : (card.title ?? card.work)
902
903  return (
904    <Box key={card.id} borderStyle="round" borderColor={card.isIdle ? 'subtle' : line} paddingX={1} flexDirection="column" alignItems="center" width={width}>
905      {Icon(kit, card.id, card.isRunning)}
906      {Middle(kit, inner, cells(name), Name(kit, card.id, onWatch, name))}
907      <Text color={card.tint} bold={!card.isIdle} dimColor={card.isIdle} wrap="truncate-end">
908        {fit(statusOf(kit, card), inner)}
909      </Text>
910      <Text color={isTold ? line : undefined} italic={isTold && !isGesture(card.quote)} wrap="truncate-end">
911        {fit(about, inner)}
912      </Text>
913    </Box>
914  )
915}
916
917/** Cuts to a row: the three the comic is about fill one. */
918const ACROSS = CORE.length
919
920/** Characters as rows of cuts, three across; a row with fewer has them in its middle. */
921const Panels = (kit: Kit, scene: Scene, ids: readonly MemberId[], width: number, onWatch: OnWatch): RenderElement[] => {
922  const each = Math.floor(width / ACROSS)
923  const rows: MemberId[][] = []
924
925  for (let at = 0; at < ids.length; at += ACROSS) rows.push(ids.slice(at, at + ACROSS))
926
927  return rows.map((row, index) => (
928    <kit.Box key={`cuts-${row[0] ?? index}`} justifyContent="center" width={width}>
929      {row.map(id => Panel(kit, scene.lang, cardOf(id, scene), each, onWatch))}
930    </kit.Box>
931  ))
932}
933
934/** What the rule over the conversation is written with: its label, the ways back and on, and the way to the conversation alone. */
935type TalkWords = readonly [label: string, older: string, newer: string, story: string]
936
937/**
938 * The rule over the conversation. Where more was said than the page holds,
939 * it has the way back through it at its end: the lines before these, the
940 * lines after, and which of them are shown while it is turned back. Last is
941 * the way to the conversation alone, and from there back to the whole page.
942 * Short of room it is written with fewer words, so that it stays one row.
943 */
944const TalkRule = (kit: Kit, scene: Scene, count: number, width: number, { onTalk, onStory }: Pick<PaneActs, 'onTalk' | 'onStory'>): RenderElement => {
945  const { Box, Text, Button } = kit
946  const { from, to } = shownOf(scene, count)
947  const total = scene.feed.length
948  const back = total - to
949  const size = Math.max(1, count)
950  const range = `${from + 1}~${to}/${total}`
951  // The page of the conversation alone, and the way back from it to the whole page.
952  const words = wordsOf(scene)
953  const tries: readonly TalkWords[] = [
954    [back > 0 ? words.earlier(range) : words.talk, words.older, words.newer, scene.isTalkOnly ? words.all : words.only],
955    [back > 0 ? range : words.talk, '▲', '▼', scene.isTalkOnly ? words.all : words.only],
956    [back > 0 ? range : words.talk, '▲', '▼', scene.isTalkOnly ? words.allShort : words.talk],
957  ]
958  const usedBy = ([, older, newer, story]: TalkWords): number => (from > 0 ? cells(older) + 2 : 0) + (back > 0 ? cells(newer) + 2 : 0) + cells(story) + 2
959  const [label, older, newer, story] = tries.find(one => cells(one[0]) + 2 + usedBy(one) <= width) ?? tries[tries.length - 1] ?? ['', '', '', '']
960
961  return (
962    <Box width={width}>
963      {Rule(kit, label, width - usedBy([label, older, newer, story]))}
964      {from > 0 && <Text>{'  '}</Text>}
965      {from > 0 && <Button key="talk-older" label={older} plain onPress={() => onTalk(Math.min(total - 1, back + size))} />}
966      {back > 0 && <Text>{'  '}</Text>}
967      {back > 0 && <Button key="talk-newer" label={newer} plain onPress={() => onTalk(Math.max(0, back - size))} />}
968      <Text>{'  '}</Text>
969      <Button key="talk-only" label={story} plain dimColor={!scene.isTalkOnly} onPress={() => onStory()} />
970    </Box>
971  )
972}
973
974/** A dim rule across the page with a label in its middle, the label cut to what the rule holds. */
975const Rule = (kit: Kit, label: string, width: number): RenderElement => {
976  const text = fit(label, width - 2)
977  const side = Math.max(0, width - cells(text) - 2)
978  const middle = text === '' ? '─'.repeat(Math.min(2, Math.max(0, width))) : ` ${text} `
979
980  return <kit.Text dimColor>{`${'─'.repeat(Math.floor(side / 2))}${middle}${'─'.repeat(side - Math.floor(side / 2))}`}</kit.Text>
981}
982
983// ---- the sheet: what one character is doing, for the one who pressed its name
984
985type SheetRow = { label: string; text: string; tint?: Color; isPath?: boolean }
986type Sheet = { id: MemberId; rows: SheetRow[]; canPick: boolean }
987
988/** Tasks in hand a sheet tells one by one before it counts the rest. */
989const LISTED = 3
990const LABEL = 8
991
992/** One task in hand: its name, the engine and how long it has run, the tool in its hand, why it waits, where its result goes. */
993const taskRows = (lang: Lang, task: Task, now: number, label: string): SheetRow[] => {
994  const words = WORDS[lang]
995  const tint = TINT[task.status]
996  const rows: SheetRow[] = [
997    { label, text: `${MARK[task.status]} ${titleOf(lang, task)}`, ...(tint === undefined ? {} : { tint }) },
998    { label: '', text: `  ${engineOf(words, task)} · ${words.status[task.status]} ${clock(elapsed(task, now))}` },
999  ]
1000  const doing = doingOf(words, task)
1001
1002  if (doing !== '') rows.push({ label: '', text: `  ${doing}` })
1003  if (task.status === 'waiting' && task.note[lang] !== '') rows.push({ label: '', text: `  ${task.note[lang]}` })
1004  if (task.out !== undefined) rows.push({ label: words.labels.result, text: task.out, isPath: true })
1005
1006  return rows
1007}
1008
1009const saidRows = (scene: Scene, id: MemberId): SheetRow[] =>
1010  scene.feed
1011    .filter(one => one.member === id)
1012    .slice(-2)
1013    .map((one, index) => ({ label: index === 0 ? wordsOf(scene).labels.said : '', text: `${shown(one.quote[scene.lang])}${one.note[scene.lang] === '' ? '' : ` ${one.note[scene.lang]}`}` }))
1014
1015/** The sheet of the character the person asked about; none where they asked about no one. */
1016const sheetOf = (scene: Scene): Sheet | undefined => {
1017  const id = scene.watched
1018  const { lang } = scene
1019
1020  if (id === null) return undefined
1021  const words = wordsOf(scene)
1022  const { labels } = words
1023  const cost = spentBy(scene)[id]
1024  const paid: SheetRow[] = isSpent(cost) ? [{ label: labels.pay, text: spent(scene.lang, cost) }] : []
1025
1026  if (id === 'rodo') return { id, rows: [{ label: labels.now, text: words.bell }, ...saidRows(scene, id)], canPick: false }
1027  if (id === LEAD) {
1028    const active = scene.tasks.filter(isActive).sort((a, b) => a.startedAt - b.startedAt)
1029    const day = tally(scene)
1030    const own = aidOf(scene, LEAD)
1031    const rows: SheetRow[] = [{ label: labels.now, text: active.length > 0 ? words.conducting(active.length) : own === undefined ? words.awaiting : `${MARK.running} ${words.alone} · ${own.what[lang]}` }]
1032
1033    for (const task of active.slice(0, LISTED * 2)) {
1034      const tint = TINT[task.status]
1035
1036      rows.push({ label: '', text: `${MARK[task.status]} ${nameOf(scene, task.member)}: ${titleOf(lang, task)} · ${words.status[task.status]} ${clock(elapsed(task, scene.now))}`, ...(tint === undefined ? {} : { tint }) })
1037    }
1038    if (active.length > LISTED * 2) rows.push({ label: '', text: words.more(active.length - LISTED * 2) })
1039    if (day !== undefined) rows.push({ label: labels.today, text: words.today(day.done, day.failed) })
1040
1041    return { id, rows: [...rows, ...paid, ...saidRows(scene, id)], canPick: true }
1042  }
1043
1044  const own = tasksOf(scene, id)
1045  const active = own.filter(isActive)
1046  const ended = own.find(task => !isActive(task))
1047  const aid = aidOf(scene, id)
1048  const resting: SheetRow = aid === undefined ? { label: labels.now, text: `${words.rest} · ${jobOf(lang, id, scene.roles)}` } : { label: labels.now, text: `${MARK.running} ${words.aidingLead} · ${aid.what[lang]}` }
1049  const rows: SheetRow[] = active.length === 0 ? [resting] : active.slice(0, LISTED).flatMap((task, index) => taskRows(lang, task, scene.now, index === 0 ? labels.now : ''))
1050
1051  if (active.length > LISTED) rows.push({ label: '', text: words.more(active.length - LISTED) })
1052  if (ended !== undefined) {
1053    const tint = TINT[ended.status]
1054    // The report is the first of the lines handed back: it stands as this language has it, over the rest as they were written.
1055    const told = ended.report === undefined ? [] : [ended.report[lang], ...(ended.summary ?? []).slice(1)]
1056
1057    rows.push({ label: labels.ended, text: `${MARK[ended.status]} ${titleOf(lang, ended)} · ${words.spoken(elapsed(ended, scene.now))}`, ...(tint === undefined ? {} : { tint }) })
1058    rows.push(...told.map((line, index) => ({ label: index === 0 ? labels.report : '', text: line })))
1059  }
1060
1061  return { id, rows: [...rows, ...paid, ...saidRows(scene, id)], canPick: true }
1062}
1063
1064/** Only one who takes tasks has a role the person can change. */
1065const hasRole = (id: MemberId): boolean => WORKERS.includes(id)
1066
1067/** A role to give, as its button is labeled. */
1068type RoleButton = { role: WorkRole; label: string }
1069
1070/** Rows of the roles a sheet may be given where it is as narrow as a pane gets: a role to a row. */
1071const ROLE_ROWS = WORK_ROLES.length
1072/** The cells the roles to give take in a row, as a language names them: each with its mark, and a cell between two. */
1073const roleCells = (lang: Lang): number => WORK_ROLES.reduce((sum, one) => sum + cells(CAST[lang].roles[one]) + 2, -1)
1074
1075/**
1076 * The roles to give, as rows of buttons in a width, `most` rows at the most.
1077 * Each is named whole where the rows are there for it, as many to a row as go
1078 * in. With fewer rows, the names are cut to share those; a width the cut
1079 * names cannot be told apart in has no buttons, for a button that may give
1080 * either of two roles is worse than none.
1081 */
1082const roleRowsOf = (lang: Lang, width: number, most: number): RoleButton[][] => {
1083  const whole = WORK_ROLES.map(role => ({ role, label: CAST[lang].roles[role] }))
1084  const flowed: RoleButton[][] = []
1085  let used = 0
1086
1087  if (most <= 0) return []
1088  for (const one of whole) {
1089    const wanted = cells(one.label) + 1
1090    const last = flowed[flowed.length - 1]
1091
1092    if (last !== undefined && used + 1 + wanted <= width) {
1093      last.push(one)
1094      used += 1 + wanted
1095    } else {
1096      flowed.push([one])
1097      used = wanted
1098    }
1099  }
1100  if (flowed.length <= most && whole.every(one => cells(one.label) + 1 <= width)) return flowed
1101  const each = Math.ceil(whole.length / most)
1102  const room = Math.floor((width - (each - 1)) / each) - 1
1103  const cut = whole.map(one => ({ ...one, label: clip(one.label, room) }))
1104
1105  if (cut.some(one => one.label === '') || new Set(cut.map(one => one.label)).size < cut.length) return []
1106
1107  return Array.from({ length: Math.ceil(cut.length / each) }, (_, row) => cut.slice(row * each, (row + 1) * each))
1108}
1109
1110/** Rows a sheet's buttons take under its head, in a width: the one that hands the character the next prompt, and the roles. */
1111const controlRows = (sheet: Sheet, lang: Lang, width: number): number =>
1112  Number(sheet.canPick) + (hasRole(sheet.id) ? roleRowsOf(lang, width - 4, ROLE_ROWS).length : 0)
1113
1114/** Rows a sheet takes in a width, frame and all. */
1115const sheetRows = (sheet: Sheet | undefined, lang: Lang, width: number): number => (sheet === undefined ? 0 : sheet.rows.length + 3 + controlRows(sheet, lang, width))
1116
1117/**
1118 * The sheet, framed in the character's color: who and in what state, the
1119 * rows, the roles to give the character with the one it has marked, a button
1120 * that hands the character the person's next prompt, and one that puts the
1121 * sheet away. Given fewer rows than it would take, it draws no more than
1122 * those, and keeps what can be pressed the longest: the rows go first, from
1123 * the last up, then the roles, cut short before they are left out, then the
1124 * button, down to the head alone in its frame, with the way to put it away.
1125 */
1126const SheetCard = (kit: Kit, scene: Scene, sheet: Sheet, width: number, acts: PaneActs, rows = sheetRows(sheet, scene.lang, width)): RenderElement => {
1127  const { Box, Text, Button } = kit
1128  const inner = width - 4
1129  const words = wordsOf(scene)
1130  const labels = CAST[scene.lang].roles
1131  const card = cardOf(sheet.id, scene)
1132  // The head keeps the way to put the sheet away: the name is cut to what that leaves, and the state and what follows it are there with room.
1133  const name = nameIn(scene.lang, sheet.id, Math.max(1, inner - cells(words.close) - 3))
1134  const stateRoom = inner - (cells(name) + 2) - 1 - cells(words.close) - 1
1135  const hasState = cells(statusOf(kit, card)) <= stateRoom
1136  const subRoom = stateRoom - cells(statusOf(kit, card)) - 1
1137  const isPicked = scene.picked === sheet.id
1138  const ask = sheet.id === LEAD ? words.askLead : words.askFriend
1139  const role = roleOf(sheet.id, scene.roles)
1140  const own = words.ownRole(labels[MEMBERS[sheet.id].role])
1141  const across = roleCells(scene.lang)
1142  const spare = rows - 3
1143  const canPick = sheet.canPick && spare >= 1
1144  const roleRows = hasRole(sheet.id) ? roleRowsOf(scene.lang, inner, spare - Number(canPick)) : []
1145  // With room beside the roles in their one row, what the row is and the role the character has of its own are told.
1146  const hasRoleLabel = roleRows.length === 1 && inner >= LABEL + across
1147  const roleRoom = inner - LABEL - across - 1
1148
1149  return (
1150    <Box key="sheet" borderStyle="round" borderColor={lineOf(kit, sheet.id)} paddingX={1} flexDirection="column" width={width}>
1151      <Box justifyContent="space-between" width={inner}>
1152        <Box gap={1}>
1153          {Badge(kit, sheet.id, name)}
1154          {hasState && State(kit, card)}
1155          {subRoom >= 4 && <Text dimColor>{fit(card.sub, subRoom)}</Text>}
1156        </Box>
1157        <Button key="watch-close" label={words.close} plain dimColor onPress={() => acts.onWatch(null)} />
1158      </Box>
1159      {sheet.rows.slice(0, Math.max(0, spare - Number(canPick) - roleRows.length)).map((row, index) => (
1160        <Text key={`sheet-${index}`} wrap="truncate-end">
1161          <Text dimColor>{pad(row.label, LABEL)}</Text>
1162          <Text color={row.tint}>{row.isPath === true ? fitEnd(row.text, inner - LABEL) : fit(row.text, inner - LABEL)}</Text>
1163        </Text>
1164      ))}
1165      {roleRows.map((row, index) => (
1166        <Box key={`roles-${index}`} gap={1}>
1167          {hasRoleLabel && <Text dimColor>{pad(words.labels.role, LABEL - 1)}</Text>}
1168          {row.map(one => (
1169            <Button key={`role-${sheet.id}-${one.role}`} label={`${one.role === role ? '▶' : '▷'}${one.label}`} plain dimColor={one.role !== role} onPress={() => acts.onRole(sheet.id, one.role)} />
1170          ))}
1171          {hasRoleLabel && role !== MEMBERS[sheet.id].role && roleRoom >= cells(own) + 1 && <Text dimColor>{own}</Text>}
1172        </Box>
1173      ))}
1174      {canPick && (
1175        <Box>
1176          <Button key={`pick-${sheet.id}`} label={fit(isPicked ? words.picked : ask, inner)} plain dimColor={!isPicked} onPress={() => acts.onPick(sheet.id)} />
1177        </Box>
1178      )}
1179    </Box>
1180  )
1181}
1182
1183/** Ones with no card, named together in a row under what they are doing. */
1184type Group = { label: string; ids: readonly MemberId[]; isAtWork: boolean }
1185/** A group as a row has room for it: its label with what parts it from the group before, the ones named, and how many are not. */
1186type Named = { head: string; shown: MemberId[]; more: number; isAtWork: boolean }
1187
1188/** The cells a count of the ones not named is kept room for. */
1189const MORE_CELLS = 4
1190
1191/** The groups of the ones with no card that have anyone in them: the ones at work first. */
1192const groupsOf = (scene: Pick<Scene, 'lang'>, hidden: readonly MemberId[], bench: readonly MemberId[]): Group[] =>
1193  [
1194    { label: wordsOf(scene).atWorkLabel, ids: hidden, isAtWork: true },
1195    { label: wordsOf(scene).rest, ids: bench, isAtWork: false },
1196  ].filter(group => group.ids.length > 0)
1197
1198/** One group in the cells it is given: its label, the names that go in with room kept for a count, and the count of the rest. With no room for the label, the count alone. */
1199const groupIn = (lang: Lang, { label, ids, isAtWork }: Group, lead: string, width: number): Named => {
1200  const head = `${lead}${label} `
hooks/voice.ts 272 lines
1import type { Lang, MemberId, Role, Roles, WorkRole } from '../types'
2
3import { CAST, jobOf, LEAD, MEMBERS, ORDER, roleOf, soundsOf, WORKERS } from './cast'
4import { EN } from './voice.en'
5import { JA } from './voice.ja'
6import { KO } from './voice.ko'
7import { LANGS } from './words'
8
9/**
10 * What the mode tells the model, in one language: the section the conductor
11 * works by, the block that makes a task's worker a character, and the lines
12 * that go beside a prompt or a tool's result.
13 */
14export type Script = {
15  /** What opens the block that tells a task's worker which character it is, and the line that tells the main loop who took a task. */
16  mark: string
17  /** How every block closes: the asked-for format comes before the character. */
18  strict: string
19  /** How a block's own words open, after its mark. */
20  bodyHead: string
21  /** What the main loop is told once the mode is turned off. */
22  standDown: string
23  /** A few things named one after another. */
24  list: (parts: readonly string[]) => string
25  /** The section the main loop's system prompt gains: it conducts as 하치와레. */
26  leader: (it: { voice: string; lines: string; isTrioShown: boolean; roster: string; mute: readonly string[] }) => string
27  rosterRow: (it: { mark: string; name: string; about: string; job: string; given?: string; speaks: boolean }) => string
28  body: (it: { name: string; about: string; role: string; speaks: boolean; manner: string; opening: string; lines: string }) => string
29  memberTail: (opening: string) => string
30  orcaTail: (opening: string) => string
31  pickLead: string
32  pickFriend: (name: string) => string
33  /** One character with the role it has now, and the work that comes with it. */
34  roleOne: (name: string, role: string, job: string) => string
35  roleNote: (changed: string | undefined, own: string | undefined, specialties: string) => string
36  /** What follows an Agent call's result: who took the task. */
37  took: (name: string, role: string, speaks: boolean) => string
38  /** What follows a fleet-run launch: who took the task, and the copy of its spec it ran from. */
39  fleetTook: (engine: string, title: string, name: string, role: string, copy?: string) => string
40  /** What that line ends with where the spec already held the mode's mark and no character could be read from it: it ran as written. */
41  uncopied: string
42}
43
44const SCRIPTS: Record<Lang, Script> = { en: EN, ko: KO, ja: JA }
45
46/** What opens the mode's block and its line after a tool's result, in each language. */
47export const MARKS: Record<Lang, string> = { en: EN.mark, ko: KO.mark, ja: JA.mark }
48
49/** Whether a text holds the mode's mark, in whichever language it was written. */
50export const isMarked = (text: string): boolean => LANGS.some(lang => text.includes(MARKS[lang]))
51
52const lines = (lang: Lang, id: MemberId): string =>
53  CAST[lang].lines[id].map(line => `  - ${line}`).join('\n')
54
55const MUTE = WORKERS.filter(id => !MEMBERS[id].speaks)
56
57/** What a character is known for: its kind, where its name does not say it, and what the comic gives it. */
58const aboutOf = (lang: Lang, id: MemberId): string => SCRIPTS[lang].list([CAST[lang].kinds[id], CAST[lang].titles[id]].filter(part => part !== ''))
59
60const roster = (lang: Lang, roles: Roles): string =>
61  WORKERS.map(id => {
62    const given = roles[id]
63
64    return SCRIPTS[lang].rosterRow({
65      mark: MEMBERS[id].mark,
66      name: CAST[lang].names[id],
67      about: aboutOf(lang, id),
68      job: jobOf(lang, id, roles),
69      ...(given === undefined ? {} : { given: CAST[lang].roles[given] }),
70      speaks: MEMBERS[id].speaks,
71    })
72  }).join('\n')
73
74export const leaderSection = (lang: Lang, isPolite = false, isTrioShown = true, roles: Roles = {}): string =>
75  SCRIPTS[lang].leader({
76    voice: isPolite ? CAST[lang].politeLeader : CAST[lang].manner.hachiware,
77    lines: lines(lang, LEAD),
78    isTrioShown,
79    roster: roster(lang, roles),
80    mute: MUTE.map(id => CAST[lang].names[id]),
81  })
82
83/** What the main loop is told once the mode is turned off. */
84export const standDown = (lang: Lang): string => SCRIPTS[lang].standDown
85
86const opening = (lang: Lang, id: MemberId): string => `${MEMBERS[id].mark} ${CAST[lang].names[id]}:`
87
88/** How a block opens, under a rule that sets it off from the task above it. */
89const blockHead = (lang: Lang): string => `
90
91---
92${MARKS[lang]}
93`
94
95const body = (lang: Lang, id: MemberId, role: Role): string =>
96  SCRIPTS[lang].body({
97    name: CAST[lang].names[id],
98    about: aboutOf(lang, id),
99    role: CAST[lang].roles[role],
100    speaks: MEMBERS[id].speaks,
101    manner: CAST[lang].manner[id],
102    opening: opening(lang, id),
103    lines: lines(lang, id),
104  })
105
106/** What a subagent's task gains at its end: the character it works as. */
107export const memberBlock = (lang: Lang, id: MemberId, role: Role): string => `${blockHead(lang)}${body(lang, id, role)}
108${SCRIPTS[lang].memberTail(opening(lang, id))}`
109
110/**
111 * A subagent's task without the block at its end, where it has one: the
112 * mode's own in whichever language, from its rule to its last line, as when a
113 * task is handed on again. A task that only mentions the mark is left as it is.
114 */
115export const unvoiced = (prompt: string): string => {
116  const ended = prompt.trimEnd()
117  const at = Math.max(...LANGS.map(lang => (ended.endsWith(SCRIPTS[lang].strict) ? prompt.lastIndexOf(`${blockHead(lang)}${SCRIPTS[lang].bodyHead}`) : -1)))
118
119  return at >= 0 ? prompt.slice(0, at) : prompt
120}
121
122/** What an Orca worker's spec gains at its end. */
123export const orcaBlock = (lang: Lang, id: MemberId, role: Role): string => `${blockHead(lang)}${body(lang, id, role)}
124${SCRIPTS[lang].orcaTail(opening(lang, id))}`
125
126/** What follows an Agent call's result: who took the task. */
127export const tookNote = (lang: Lang, id: MemberId, role: Role): string => SCRIPTS[lang].took(CAST[lang].names[id], CAST[lang].roles[role], MEMBERS[id].speaks)
128
129/**
130 * The character a spec is already cast as: the one named by the last block of
131 * the mode's own in it, in whichever language the block was written. None
132 * where the text holds no block that opens as the mode's does.
133 */
134export const castIn = (text: string): MemberId | undefined =>
135  LANGS.flatMap(lang => WORKERS.map(id => ({ id, name: CAST[lang].names[id], at: text.lastIndexOf(`${MARKS[lang]}\n${SCRIPTS[lang].bodyHead}${CAST[lang].names[id]}`) })))
136    .filter(one => one.at >= 0)
137    // The last block is the one the worker reads last, and of two names that open alike the longer is the one written.
138    .sort((a, b) => b.at - a.at || b.name.length - a.name.length)[0]?.id
139
140/** What follows a fleet-run launch: who took the task, and the copy of its spec it ran from, or that none was made of a spec that held the mark already. */
141export const fleetNote = (lang: Lang, id: MemberId, role: Role, engine: string, title: string, copy?: string, isUncopied = false): string =>
142  `${SCRIPTS[lang].fleetTook(engine, title, CAST[lang].names[id], CAST[lang].roles[role], copy)}${isUncopied ? SCRIPTS[lang].uncopied : ''}`
143
144// Each character by the names it goes by in the three languages.
145const ALIASES: readonly (readonly [string, MemberId])[] = [
146  ['치이카와|CHIIKAWA|ちいかわ', 'chiikawa'],
147  ['우사기|토끼|USAGI|うさぎ|ウサギ', 'usagi'],
148  ['랏코|해달|RAKKO|ラッコ', 'rakko'],
149  ['시사|SHISA|シーサー', 'shisa'],
150  ['쿠리만쥬|밤만쥬|KURIMANJU|くりまんじゅう|栗まんじゅう|くりまん', 'kurimanju'],
151  ['카니|헌책방|KANI|FURUHONYA|古本屋|カニ|かに', 'kani'],
152  ['포[셰쉐]트(?:\\s*갑옷\\s*씨)?|POCHETTE(?:\\s*NO[\\s-]*YOROI-?SAN)?|ポシェット(?:の鎧さん)?', 'pochette'],
153  ['모몽가|하늘다람쥐|MOMONGA|モモンガ', 'momonga'],
154]
155
156const NAMES: readonly (readonly [RegExp, MemberId])[] = ALIASES.map(([names, id]) => [new RegExp(`^\\s*(?:${names})\\s*[::,)\\]-]`, 'i'), id])
157
158const WHOLE: readonly (readonly [RegExp, MemberId])[] = ALIASES.map(([names, id]) => [new RegExp(`^\\s*(?:${names})\\s*$`, 'i'), id])
159
160/** The character a word names, by its name or one it also goes by. */
161export const memberNamed = (word: string): MemberId | undefined =>
162  /^\s*(?:하치와레|HACHIWARE|ハチワレ)\s*$/i.test(word) ? LEAD : WHOLE.find(([pattern]) => pattern.test(word))?.[1]
163
164/** What goes beside a prompt the person addressed to one character from the band. */
165export const pickNote = (lang: Lang, id: MemberId): string => (id === LEAD ? SCRIPTS[lang].pickLead : SCRIPTS[lang].pickFriend(CAST[lang].names[id]))
166
167/**
168 * What goes beside a prompt once the person changed the roles: who takes
169 * which work now, every character by name, so that nothing rests on a list
170 * told earlier. A kind of work that has its own character still goes to
171 * that one first, and the line says so.
172 */
173export const roleNote = (lang: Lang, roles: Roles): string => {
174  const script = SCRIPTS[lang]
175  const named = (ids: readonly MemberId[]): string | undefined =>
176    ids.length === 0 ? undefined : script.list(ids.map(id => script.roleOne(CAST[lang].names[id], CAST[lang].roles[roleOf(id, roles)], jobOf(lang, id, roles))))
177
178  return script.roleNote(named(WORKERS.filter(id => roles[id] !== undefined)), named(WORKERS.filter(id => roles[id] === undefined)), CAST[lang].specialties)
179}
180
181const ROLE_WORDS: readonly (readonly [RegExp, WorkRole])[] = [
182  [/^(?:구현|토벌|build|hunt|実装|討伐)$/i, '구현'],
183  [/^(?:검토|검정|review|exam|レビュー|検定)$/i, '검토'],
184  [/^(?:조사|채집|research|forage|調査|採取)$/i, '조사'],
185  [/^(?:탐색|찾기|explore|scout|探索)$/i, '탐색'],
186]
187
188/** The role a word names, by its name or the comic's word for it, in any of the languages. */
189export const roleNamed = (word: string): WorkRole | undefined => ROLE_WORDS.find(([pattern]) => pattern.test(word.trim()))?.[1]
190
191/** The character a description names at its start (`랏코: ...`), and the rest. */
192export const namedMember = (description: string): { member?: MemberId; rest: string } => {
193  for (const [pattern, member] of NAMES) {
194    if (pattern.test(description)) {
195      return { member, rest: description.replace(pattern, '').trim() }
196    }
197  }
198
199  return { rest: description.trim() }
200}
201
202/** A name as a pattern reads it, letter for letter. */
203const literal = (name: string): string => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
204
205/** Every name a character opens a report with, in whichever language its block was written. */
206const namesOf = (id: MemberId): string[] => LANGS.map(lang => literal(CAST[lang].names[id]))
207
208// A mark may come first: an emoji, with the selector some carry after it.
209const MARKED = String.raw`^(?:\p{Extended_Pictographic}\u{FE0F}?\s*)?`
210const OPENING = new RegExp(`${MARKED}(?:${ORDER.flatMap(namesOf).join('|')}|포셰트 갑옷 씨)\\s*[::]\\s*`, 'u')
211
212/** A sound as its letters alone, a drawn-out one as short as it goes: `와아아~!` is `와아`, and `Waa~` is `wa`. */
213const lettersOf = (sound: string): string =>
214  sound
215    .toLowerCase()
216    .replace(/[^\p{L}\p{N}]/gu, '')
217    .replace(/(.)\1+/gu, '$1')
218
219/** How each character that cannot talk opens a report, the sounds and gestures it is known to make, and who it is. */
220const SOUNDS: readonly (readonly [RegExp, ReadonlySet<string>, MemberId])[] = MUTE.map(id => [
221  new RegExp(`${MARKED}(?:${namesOf(id).join('|')})\\s*[::]`, 'u'),
222  new Set(
223    LANGS.flatMap(lang => soundsOf(lang, id))
224      .map(lettersOf)
225      .filter(letters => letters !== ''),
226  ),
227  id,
228])
229
230/** The lines of a report that say something, without the markup each opens with. */
231const saidIn = (text: string): string[] =>
232  text
233    .split('\n')
234    .map(one => one.replace(/^[\s#>*\-`]+/, '').trim())
235    .filter(one => one !== '' && !/^[-=_*`]+$/.test(one))
236
237/**
238 * The first lines of a report that say something, without their markup and
239 * the name the report opens with. A character that cannot talk opens with a
240 * sound alone, so there the lines start after it: after one of its own
241 * sounds, drawn out or not, or one of its own gestures. Any other first line
242 * is part of the report however short and though it stands in parentheses,
243 * as is all a character that talks says.
244 */
245export const firstLines = (text: string, count: number): string[] => {
246  const [first = '', ...more] = saidIn(text)
247  const rest = first.replace(OPENING, '')
248  const sounds = SOUNDS.find(([opening]) => opening.test(first))?.[1]
249  const isSound = sounds !== undefined && more.length > 0 && sounds.has(lettersOf(rest))
250
251  return (isSound ? more : [rest, ...more])
252    .map(one => one.replace(/[*`]/g, '').slice(0, 160))
253    .filter(one => one !== '')
254    .slice(0, count)
255}
256
257/** The first line of a report that says something. */
258export const firstLine = (text: string): string => firstLines(text, 1)[0] ?? ''
259
260/**
261 * The friend who cannot talk whose name a report of one line opens with, as
262 * its block has it open a report: what stands after the name there is the
263 * one place a sound or a gesture of its own is the report itself. Under such
264 * a line the lines are what the task came to, and so is a first line that
265 * opens with no such name: neither is anyone's sound, whatever it reads like.
266 */
267export const openerOf = (text: string): MemberId | undefined => {
268  const [first = '', ...more] = saidIn(text)
269
270  return more.length > 0 ? undefined : SOUNDS.find(([opening]) => opening.test(first))?.[2]
271}
272
hooks/words.ts 835 lines
1import type { Lang, LangKept, Phrase, TaskStatus } from '../types'
2
3/** The languages, the one the mode falls back on first. */
4export const LANGS: readonly Lang[] = ['en', 'ko', 'ja']
5
6/** Words made in every language at once, each as that language makes them. */
7export const inAll = (make: (lang: Lang) => string): Phrase => ({ en: make('en'), ko: make('ko'), ja: make('ja') })
8
9/** Words that are someone else's, as they were written: the same in every language. */
10export const asIs = (text: string): Phrase => inAll(() => text)
11
12/** What each language calls itself. */
13export const LANG_NAMES: Record<Lang, string> = { en: 'English', ko: '한국어', ja: '日本語' }
14
15/**
16 * What settled the language, the first of these that holds: the mode's own
17 * setting, the person's choice by `/chiikawa lang`, the letters of the prompts
18 * they type, Claude Code's own language, the environment's locale, or nothing.
19 */
20export type LangFrom = 'setting' | 'chosen' | 'typed' | 'config' | 'env' | 'default'
21
22const WORDED: readonly (readonly [RegExp, Lang])[] = [
23  [/^(?:en|english|영어|英語)$/i, 'en'],
24  [/^(?:ko|korean|한국어|韓国語)$/i, 'ko'],
25  [/^(?:ja|japanese|일본어|日本語)$/i, 'ja'],
26]
27
28/** The language a word of the person's names, in any of the three. */
29export const langNamed = (word: string): Lang | undefined => WORDED.find(([pattern]) => pattern.test(word.trim()))?.[1]
30
31const HANGUL = /[ᄀ-ᇿ㄰-㆏가-힣]/g
32/** Kana alone: the long-vowel mark and the middle dot are written in other languages too. */
33const KANA = /[ぁ-ゖァ-ヺヲ-ッア-ン]/g
34/** How much of a prompt is read for its letters: what follows is taken to be pasted. */
35const TYPED_CHARS = 2000
36/** The fewest letters of a language that tell a prompt is written in it. */
37const TYPED_LEAST = 2
38
39/**
40 * The language a typed prompt is written in, by its letters: Hangul is Korean
41 * and kana Japanese, whichever there is more of, and at least two of it, so
42 * that one letter quoted in another language's prompt tells nothing. Latin
43 * letters and Han characters alone say nothing, so a command in English
44 * changes no language, and neither does a slash command with whatever
45 * follows it.
46 */
47export const typedLang = (text: string): Lang | undefined => {
48  const typed = text.trimStart()
49
50  if (typed.startsWith('/')) return undefined
51  const read = typed.slice(0, TYPED_CHARS)
52  const hangul = read.match(HANGUL)?.length ?? 0
53  const kana = read.match(KANA)?.length ?? 0
54
55  if (Math.max(hangul, kana) < TYPED_LEAST) return undefined
56
57  return hangul > kana ? 'ko' : kana > hangul ? 'ja' : undefined
58}
59
60/** Korean or Japanese as a setting or a locale names it (`ko_KR.UTF-8`, `Japanese`, `日本語`); nothing for any other. */
61export const localeLang = (value: string): Lang | undefined => {
62  const named = value.trim()
63
64  if (/^(?:ko(?![a-z])|korean|한국어)/i.test(named)) return 'ko'
65
66  return /^(?:ja(?![a-z])|japanese|日本語)/i.test(named) ? 'ja' : undefined
67}
68
69/** The language Claude Code's own setting names, where it is one of the three: English as well, which a locale is not read for. */
70export const configLang = (value: string): Lang | undefined => localeLang(value) ?? (/^(?:en(?![a-z])|english)/i.test(value.trim()) ? 'en' : undefined)
71
72/** The languages an earlier session kept, with whatever is not one of the three left out. */
73export const langKeptFrom = (kept: unknown): LangKept => {
74  if (typeof kept !== 'object' || kept === null) return {}
75  const held: Readonly<Record<string, unknown>> = { ...kept }
76  const chosen = LANGS.find(lang => lang === held.chosen)
77  const typed = LANGS.find(lang => lang === held.typed)
78
79  return { ...(chosen === undefined ? {} : { chosen }), ...(typed === undefined ? {} : { typed }) }
80}
81
82/**
83 * Everything the screen and the command's answers say, in one language. A
84 * sentence that holds a value is a function, and what a language does to a
85 * word beside a value (a Korean particle, an English plural, a Japanese
86 * counter) is done inside it.
87 */
88export type Words = {
89  /** The mode's name: on the band, on the pane and as its title. */
90  brand: string
91  /** What stands in the pane's far corner while the mode is on, and while it is off. */
92  on: string
93  off: string
94
95  // ---- times and amounts
96  /** A length of time as it is said. */
97  spoken: (ms: number) => string
98  /** A token count as a reader of the language says it. */
99  amount: (tokens: number) => string
100  tokens: (fresh: string, cached: string, out: string) => string
101  /** When a window resets, so many minutes on. */
102  until: (minutes: number) => string
103  took: (seconds: number) => string
104  exitCode: (code: string) => string
105
106  // ---- what a character is doing
107  status: Record<TaskStatus, string>
108  idle: string
109  leading: string
110  receiving: string
111  alone: string
112  together: string
113  helping: string
114  aidingLead: string
115  awaiting: string
116  atWork: (names: readonly string[]) => string
117  handed: (count: number) => string
118  withFriend: (name: string) => string
119  more: (count: number) => string
120  tools: (count: number) => string
121  today: (done: number, failed: number) => string
122  tally: (done: number, failed: number) => string
123
124  // ---- the usage
125  usage: string
126  usageTitle: string
127  seeUsage: string
128  noUsage: string
129  fold: string
130  battery: string
131  limits: Record<string, string>
132  left: (percent: number, used: string, window: string) => string
133  used: (percent: number) => string
134  session: (usd: string) => string
135  sessionCost: (usd: string) => string
136  pay: string
137
138  // ---- the band
139  sent: (to: string) => string
140  auto: string
141  working: (count: number) => string
142  resting: (count: number) => string
143
144  // ---- the pane
145  busy: (count: number) => string
146  trio: string
147  waiting: (count: number) => string
148  rest: string
149  atWorkLabel: string
150  workers: string
151  bench: string
152  help: string
153  talk: string
154  earlier: (range: string) => string
155  older: string
156  newer: string
157  all: string
158  allShort: string
159  only: string
160
161  // ---- a character's sheet
162  labels: { now: string; today: string; pay: string; ended: string; report: string; role: string; result: string; said: string }
163  bell: string
164  conducting: (count: number) => string
165  close: string
166  ownRole: (role: string) => string
167  askLead: string
168  askFriend: string
169  picked: string
170
171  // ---- what a line of the conversation is about
172  spinner: Record<string, string>
173  doing: (name: string, job: string) => string
174  started: (job: string, title: string) => string
175  bellNote: string
176  reading: (name: string) => string
177  mourning: (name: string) => string
178  paired: string
179  seeking: string
180  mending: string
181  lending: (name: string, verb: string) => string
182  ended: (job: string, isOk: boolean) => string
183  waveFailed: (total: number, failed: number) => string
184  waveDone: (total: number) => string
185  letGo: (name: string, title: string) => string
186  /** A subagent's task no agent was ever told to be whose, let go. */
187  lost: (name: string, title: string) => string
188  keeping: (job: string, time: string) => string
189  waitingFor: (name: string, time: string) => string
190  denied: (tool: string) => string
191  watching: (name: string, tool: string) => string
192  noResult: string
193  noPath: string
194  /** Its result file is one another worker is still waited for at. */
195  sharedOut: string
196  launchFailed: string
197  stopped: string
198  unseen: string
199  apiError: string
200  refused: string
201
202  // ---- the demonstration
203  demo: string
204  /** The four made-up tasks: finding a place, a hard fix, a small fix that fails, a review. */
205  demoTitles: readonly [string, string, string, string]
206  demoReport: (title: string) => string
207
208  // ---- the command and its answers
209  description: string
210  argumentHint: string
211  turnedOn: string
212  turnedOff: string
213  cleared: string
214  clearedAll: string
215  demoOff: string
216  demoStarted: string
217  talkOnly: string
218  talkAll: string
219  autoSet: string
220  autoHow: string
221  autoBack: (who: string) => string
222  unpicked: (who: string) => string
223  pickedToast: (mark: string, name: string) => string
224  pickedSay: (mark: string, name: string) => string
225  opened: string
226  recent: string
227
228  // ---- the roles
229  unkept: string
230  rolesTitle: string
231  roleRow: (who: string, role: string, job: string, own?: string) => string
232  roleHelp: (roles: readonly string[], specialties: string) => string[]
233  roleToast: (who: string, role: string, isOwn: boolean, isKept: boolean) => string
234  rolesReset: (isKept: boolean) => string
235  rolesSame: string
236  leadRole: string
237  roleUnknown: string
238  roleSame: (who: string, role: string) => string
239  roleBack: (who: string, role: string, isKept: boolean) => string
240  roleGiven: (who: string, name: string, role: string, own: string, specialties: string, isKept: boolean) => string
241
242  // ---- the language
243  langNow: (name: string, from: LangFrom) => string
244  langHow: string
245  langChosen: (name: string, isKept: boolean) => string
246  langAuto: string
247  /** The letters of a prompt turned the screen to this language; `back` is the code of the one it was in. */
248  langTyped: (name: string, back: Lang, isKept: boolean) => string
249  langFixed: (name: string) => string
250  langUnknown: string
251}
252
253/** A figure cut to three digits: `4.6`, `17.1`, `120`. */
254const short = (value: number): string => (value >= 100 ? String(Math.round(value)) : String(Math.round(value * 10) / 10))
255
256const whole = (ms: number): { minutes: number; seconds: number } => {
257  const seconds = Math.max(0, Math.round(ms / 1000))
258
259  return { minutes: Math.floor(seconds / 60), seconds: seconds % 60 }
260}
261
262/** `검토로`, `구현으로`: a role as what something is changed to. */
263const toRole = (role: string): string => `${role}${role === '구현' || role === '탐색' ? '으로' : '로'}`
264
265const KO_UNKEPT = '저장하지는 못해서 이번 세션에만 적용돼요.'
266const koSpecialtyFirst = (specialties: string): string =>
267  `화면·디버깅·보안처럼 전문 분야가 정해진 일(${specialties})은 역할과 상관없이 그 담당이 먼저 맡아요(그 담당의 역할을 바꾼 경우는 빼고요).`
268
269const KO: Words = {
270  brand: '먼작귀',
271  on: '켜짐',
272  off: '꺼짐',
273
274  spoken: ms => {
275    const { minutes, seconds } = whole(ms)
276
277    return minutes === 0 ? `${seconds}초` : `${minutes}분 ${seconds}초`
278  },
279  // 4.6천, 17.1만, 1.2억.
280  amount: tokens => {
281    if (tokens >= 100_000_000) return `${short(tokens / 100_000_000)}억`
282    if (tokens >= 10_000) return `${short(tokens / 10_000)}만`
283    if (tokens >= 1_000) return `${short(tokens / 1_000)}천`
284
285    return String(Math.round(tokens))
286  },
287  tokens: (fresh, cached, out) => `입력 ${fresh} · 캐시 ${cached} · 출력 ${out}`,
288  until: minutes => {
289    const hours = Math.floor(minutes / 60)
290
291    if (hours >= 48) return `${Math.floor(hours / 24)}일 뒤 초기화`
292
293    return hours === 0 ? `${minutes}분 뒤 초기화` : `${hours}시간 ${minutes % 60}분 뒤 초기화`
294  },
295  took: seconds => `${seconds}초`,
296  exitCode: code => `종료 코드 ${code}`,
297
298  status: { running: '작업 중', waiting: '기다리는 중', done: '끝', failed: '실패' },
299  idle: '대기',
300  leading: '지휘 중',
301  receiving: '접수 중',
302  alone: '직접 하는 중',
303  together: '같이 하는 중',
304  helping: '거드는 중',
305  aidingLead: '하치와레를 거드는 중',
306  awaiting: '일감을 기다리는 중',
307  atWork: names => `일하는 친구: ${names.join(', ')}`,
308  handed: count => `친구 ${count}명에게 맡김`,
309  withFriend: name => `${name}와 함께`,
310  more: count => `외 ${count}개`,
311  tools: count => `도구 ${count}회`,
312  today: (done, failed) => `${done}개 끝${failed === 0 ? '' : ` · ${failed}개 실패`}`,
313  tally: (done, failed) => `오늘 한 일: ${KO.today(done, failed)}`,
314
315  usage: '사용량',
316  usageTitle: '먼작귀 사용량',
317  seeUsage: '사용량 보기',
318  noUsage: '아직 읽은 사용량이 없어요.',
319  fold: '접기',
320  battery: '배터리',
321  limits: { five_hour: '5시간', seven_day: '7일', spend_limit: '지출' },
322  left: (percent, used, window) => `${percent}% 남음 · 컨텍스트 ${used} / ${window}`,
323  used: percent => `${percent}% 사용`,
324  session: usd => `Claude 세션 $${usd}`,
325  sessionCost: usd => `Claude 세션 비용 $${usd}`,
326  pay: '친구별 보수 (토큰, 이번 세션 누적)',
327
328  sent: to => `보내기: ${to}`,
329  auto: '자동',
330  working: count => `일하는 친구 ${count}`,
331  resting: count => `쉬는 친구 ${count}`,
332
333  busy: count => `일하는 중 ${count}명`,
334  trio: '셋이 같이 하는 중',
335  waiting: count => `기다리는 친구 ${count}명`,
336  rest: '쉬는 중',
337  atWorkLabel: '일하는 중',
338  workers: '일하는 친구들',
339  bench: '쉬는 친구들',
340  help: '이름을 누르면 그 친구가 하는 일이 보여요',
341  talk: '이야기',
342  earlier: range => `지난 이야기 ${range}`,
343  older: '▲ 이전',
344  newer: '▼ 다음',
345  all: '전체 보기',
346  allShort: '전체',
347  only: '이야기만',
348
349  labels: { now: '지금', today: '오늘', pay: '보수', ended: '끝낸 일', report: '보고', role: '역할', result: '결과', said: '한 말' },
350  bell: '일을 맡지 않아요. 일감이 걸리면 종을 흔들어요.',
351  conducting: count => `지휘 중 · 친구들이 맡은 일 ${count}개`,
352  close: '닫기',
353  ownRole: role => `원래 ${role}`,
354  askLead: '▷ 다음 명령은 하치와레가 직접 하기',
355  askFriend: '▷ 이 친구에게 다음 명령 맡기기',
356  picked: '▶ 다음 명령을 맡기로 했어요 · 누르면 취소',
357
358  spinner: {
359    requesting: '하치와레 일감 살피는 중',
360    thinking: '하치와레 궁리하는 중',
361    responding: '하치와레 말하는 중',
362    'tool-input': '하치와레 일감 적는 중',
363    'tool-use': '하치와레 지휘 중',
364  },
365  doing: (name, job) => `${name} ${job} 중`,
366  started: (job, title) => `${job} 시작 · ${title}`,
367  bellNote: '일감 접수',
368  reading: name => `${name}의 말 풀이`,
369  mourning: name => `${name}의 실패를 보고`,
370  paired: '둘이 같이 일하는 걸 보고',
371  seeking: '찾는 중',
372  mending: '고치는 중',
373  lending: (name, verb) => `${name}와 ${verb}`,
374  ended: (job, isOk) => `${job} ${isOk ? '끝' : '실패'}`,
375  waveFailed: (total, failed) => `${total}개 가운데 ${failed}개 실패`,
376  waveDone: total => `${total}개 작업 끝`,
377  letGo: (name, title) => `${name}의 ${title} · 결과가 없어 내려놓음`,
378  lost: (name, title) => `${name}의 ${title} · 끝을 알 수 없어 내려놓음`,
379  keeping: (job, time) => `${job} 계속하는 중 · ${time} 지남`,
380  waitingFor: (name, time) => `${name}를 ${time}째 기다리는 중`,
381  denied: tool => `${tool} 거절당함`,
382  watching: (name, tool) => `${name}의 ${tool}을 지켜보다가`,
383  noResult: '한 시간이 넘도록 결과 파일이 안 보여요. 직접 확인해 주세요',
384  noPath: '결과 파일 경로를 읽지 못해서, 끝났는지는 직접 확인해야 해요',
385  sharedOut: '다른 작업이 같은 결과 파일을 쓰고 있어서, 이 작업의 결과는 직접 확인해야 해요',
386  launchFailed: '실행 명령이 오류로 끝남',
387  stopped: '중단됨',
388  unseen: '결과는 직접 확인',
389  apiError: 'API 오류',
390  refused: '거절',
391
392  demo: '시연',
393  demoTitles: ['시연: 설정 파일 위치 찾기', '시연: 로그인 버그 토벌', '시연: 오타 고치기', '시연: 바뀐 코드 다시 보기'],
394  demoReport: title => `시연이라 실제로 한 일은 없어요 (${title})`,
395
396  description: '치이카와 모드: 먼작귀 친구들 현황 패널을 열거나 켜고 끈다',
397  argumentHint: '[on|off|usage|talk|role [친구 역할]|lang [auto|en|ko|ja]|clear [all]|demo|auto|친구 이름]',
398  turnedOn: '치이카와 모드를 켰어요.',
399  turnedOff: '치이카와 모드를 껐어요. /chiikawa on 으로 다시 켭니다.',
400  cleared: '끝난 작업과 대화 기록을 지웠어요. 친구별 보수(누적 사용량)는 그대로예요. 진행 중으로 남은 것과 보수까지 지우려면 /chiikawa clear all',
401  clearedAll: '진행 중으로 남아 있던 것까지 모든 작업과 대화 기록, 친구별 보수(누적 사용량)를 지웠어요.',
402  demoOff: '치이카와 모드가 꺼져 있어요. /chiikawa on 으로 켠 뒤에 시연할 수 있어요.',
403  demoStarted: '시연을 시작했어요. 실제 작업이 아니라 화면을 보여 주는 가짜 작업 4개가 15초쯤 돌아갑니다.',
404  talkOnly: '패널에 이야기만 보여요. 한 번 더 입력하거나 패널의 `전체 보기`를 누르면 돌아가요.',
405  talkAll: '패널을 전체 보기로 돌렸어요.',
406  autoSet: '보내기를 자동으로 했어요. 가벼운 일은 하치와레·치이카와·우사기가 같이 하고, 힘든 일만 다른 친구를 불러요.',
407  autoHow: '보내기 자동: 가벼운 일은 하치와레·치이카와·우사기가 같이 하고, 힘든 일만 다른 친구를 불러요. 정해서 맡기려면 친구 이름을 누른 뒤 맡기기를 눌러요.',
408  autoBack: who => `${who} 지명을 풀었어요. 보내기는 다시 자동이에요.`,
409  unpicked: who => `${who} 지명을 취소했어요.`,
410  pickedToast: (mark, name) => `${mark} 다음 명령은 ${name}에게 맡겨요. 다시 고르면 취소돼요.`,
411  pickedSay: (mark, name) => `${mark} 다음에 입력하는 명령은 ${name}에게 맡겨요. 같은 이름을 다시 고르면 취소돼요.`,
412  opened: '먼작귀 친구들 현황 패널을 열었어요.',
413  recent: '최근 대화',
414
415  unkept: KO_UNKEPT,
416  rolesTitle: '친구들의 역할',
417  roleRow: (who, role, job, own) => `${who} · ${role} (${job})${own === undefined ? '' : ` ← 원래 ${own}`}`,
418  roleHelp: (roles, specialties) => [
419    `바꾸기: /chiikawa role <친구 이름> <${roles.join('|')}>`,
420    '한 명 되돌리기: /chiikawa role <친구 이름> 기본 · 모두 되돌리기: /chiikawa role reset',
421    '패널에서 친구 이름을 누른 뒤 역할을 눌러도 바뀌어요.',
422    `역할을 받은 친구가 그 일을 먼저 맡아요. 다만 ${koSpecialtyFirst(specialties)}`,
423  ],
424  roleToast: (who, role, isOwn, isKept) => `${who}의 역할을 ${toRole(role)} 바꿨어요.${isOwn ? ' 원래 역할이에요.' : ''}${isKept ? '' : ` ${KO_UNKEPT}`}`,
425  rolesReset: isKept => `친구들의 역할을 모두 원래대로 돌렸어요.${isKept ? '' : ` ${KO_UNKEPT}`}`,
426  rolesSame: '바뀐 역할이 없어요. 모두 원래 역할이에요.',
427  leadRole: '하치와레는 지휘를 맡고 있어서 역할을 바꿀 수 없어요.',
428  roleUnknown: '누구에게 어떤 역할을 줄지 알 수 없어요.',
429  roleSame: (who, role) => `${who}는 이미 ${role} 역할이에요.`,
430  roleBack: (who, role, isKept) => `${who}의 역할을 원래대로 ${toRole(role)} 돌렸어요.${isKept ? '' : ` ${KO_UNKEPT}`}`,
431  roleGiven: (who, name, role, own, specialties, isKept) =>
432    `${who}의 역할을 ${toRole(role)} 바꿨어요(원래 ${own}). 이제 ${role} 일은 ${name}가 먼저 맡아요. 다만 ${koSpecialtyFirst(specialties)} ${isKept ? '다음 세션에도 그대로예요.' : KO_UNKEPT}`,
433
434  langNow: (name, from) =>
435    `지금 언어는 ${name}예요. ${
436      {
437        setting: '모드 설정(language)으로 정해 두었어요.',
438        chosen: '/chiikawa lang 으로 고른 언어예요.',
439        typed: '입력한 프롬프트의 글자를 보고 정했어요.',
440        config: 'Claude Code의 언어 설정을 따랐어요.',
441        env: '환경 변수(LC_ALL, LC_MESSAGES, LANG)를 따랐어요.',
442        default: '따로 정해진 것이 없어서 기본인 영어예요.',
443      }[from]
444    }`,
445  langHow: '바꾸기: /chiikawa lang <en|ko|ja> · 자동으로 되돌리기: /chiikawa lang auto',
446  langChosen: (name, isKept) => `언어를 ${name}로 정했어요.${isKept ? ' 다음 세션에도 그대로예요.' : ` ${KO_UNKEPT}`}`,
447  langAuto: '언어를 자동으로 정하게 했어요. 고른 언어와 입력한 글자로 기억해 둔 언어를 모두 지웠고, 다음에 입력하는 글자부터 다시 봐요.',
448  langTyped: (name, back, isKept) => `입력한 글자를 보고 언어를 ${name}로 바꿨어요.${isKept ? '' : ` ${KO_UNKEPT}`} 되돌리려면 /chiikawa lang ${back}`,
449  langFixed: name => `모드 설정(language)이 ${name}로 고정되어 있어서 화면은 그대로예요. 고른 값은 설정을 auto로 돌리면 쓰여요.`,
450  langUnknown: '알 수 없는 언어예요. /chiikawa lang [auto|en|ko|ja] 가운데 하나를 써 주세요.',
451}
452
453/** `1 task`, `3 tasks`. */
454const some = (count: number, one: string, many = `${one}s`): string => `${count} ${count === 1 ? one : many}`
455
456const EN_UNKEPT = 'It could not be saved, so it holds for this session only.'
457const enSpecialtyFirst = (specialties: string): string =>
458  `However, work that has a specialist, such as screens, debugging and security (${specialties}), goes to that specialist first whatever the roles, unless the specialist's own role was changed.`
459
460const EN: Words = {
461  brand: 'Chiikawa',
462  on: 'on',
463  off: 'off',
464
465  spoken: ms => {
466    const { minutes, seconds } = whole(ms)
467
468    return minutes === 0 ? `${seconds}s` : `${minutes}m ${seconds}s`
469  },
470  // 4.6k, 171k, 1.2M.
471  amount: tokens => {
472    if (tokens >= 1_000_000_000) return `${short(tokens / 1_000_000_000)}B`
473    if (tokens >= 1_000_000) return `${short(tokens / 1_000_000)}M`
474    if (tokens >= 1_000) return `${short(tokens / 1_000)}k`
475
476    return String(Math.round(tokens))
477  },
478  tokens: (fresh, cached, out) => `in ${fresh} · cache ${cached} · out ${out}`,
479  until: minutes => {
480    const hours = Math.floor(minutes / 60)
481
482    if (hours >= 48) return `resets in ${Math.floor(hours / 24)}d`
483
484    return hours === 0 ? `resets in ${minutes}m` : `resets in ${hours}h ${minutes % 60}m`
485  },
486  took: seconds => `${seconds}s`,
487  exitCode: code => `exit code ${code}`,
488
489  status: { running: 'working', waiting: 'waiting', done: 'done', failed: 'failed' },
490  idle: 'idle',
491  leading: 'leading',
492  receiving: 'at the desk',
493  alone: 'on it alone',
494  together: 'on it together',
495  helping: 'lending a hand',
496  aidingLead: 'lending Hachiware a hand',
497  awaiting: 'waiting for work',
498  atWork: names => `at work: ${names.join(', ')}`,
499  handed: count => `handed to ${some(count, 'friend')}`,
500  withFriend: name => `with ${name}`,
501  more: count => `+${count} more`,
502  tools: count => some(count, 'tool call'),
503  today: (done, failed) => `${done} done${failed === 0 ? '' : ` · ${failed} failed`}`,
504  tally: (done, failed) => `Today: ${EN.today(done, failed)}`,
505
506  usage: 'Usage',
507  usageTitle: 'Chiikawa usage',
508  seeUsage: 'Show usage',
509  noUsage: 'No usage has been read yet.',
510  fold: 'Hide',
511  battery: 'Battery',
512  limits: { five_hour: '5h', seven_day: '7d', spend_limit: 'Spend' },
513  left: (percent, used, window) => `${percent}% left · context ${used} / ${window}`,
514  used: percent => `${percent}% used`,
515  session: usd => `Claude session $${usd}`,
516  sessionCost: usd => `Claude session cost $${usd}`,
517  pay: 'Pay per friend (tokens, this session so far)',
518
519  sent: to => `To: ${to}`,
520  auto: 'auto',
521  working: count => `${count} working`,
522  resting: count => `${count} resting`,
523
524  busy: count => `${count} working`,
525  trio: 'The three, together',
526  waiting: count => `${count} waiting`,
527  rest: 'Resting',
528  atWorkLabel: 'Working',
529  workers: 'Friends at work',
530  bench: 'Friends resting',
531  help: 'Press a name to see what that friend is doing',
532  talk: 'Talk',
533  earlier: range => `Earlier ${range}`,
534  older: '▲ Older',
535  newer: '▼ Newer',
536  all: 'Show all',
537  allShort: 'All',
538  only: 'Talk only',
539
540  labels: { now: 'Now', today: 'Today', pay: 'Pay', ended: 'Done', report: 'Report', role: 'Role', result: 'Result', said: 'Said' },
541  bell: 'Takes no tasks. Rings the bell when work comes in.',
542  conducting: count => `leading · ${some(count, 'task')} with friends`,
543  close: 'Close',
544  ownRole: role => `own: ${role}`,
545  askLead: '▷ Have Hachiware do the next prompt',
546  askFriend: '▷ Hand this friend the next prompt',
547  picked: '▶ Takes the next prompt · press to cancel',
548
549  spinner: {
550    requesting: 'Hachiware is looking over the work',
551    thinking: 'Hachiware is thinking it over',
552    responding: 'Hachiware is talking',
553    'tool-input': 'Hachiware is writing up the work',
554    'tool-use': 'Hachiware is leading',
555  },
556  doing: (name, job) => `${name} is on a ${job}`,
557  started: (job, title) => `${job} begins · ${title}`,
558  bellNote: 'work is in',
559  reading: name => `reading ${name}`,
560  mourning: name => `seeing ${name} fail`,
561  paired: 'seeing two at work together',
562  seeking: 'looking',
563  mending: 'fixing',
564  lending: (name, verb) => `${verb} with ${name}`,
565  ended: (job, isOk) => `${job} ${isOk ? 'done' : 'failed'}`,
566  waveFailed: (total, failed) => `${failed} of ${total} failed`,
567  waveDone: total => `${some(total, 'task')} done`,
568  letGo: (name, title) => `${name}'s ${title} · no result came, so it was let go`,
569  lost: (name, title) => `${name}'s ${title} · its end could not be learned, so it was let go`,
570  keeping: (job, time) => `${job} still going · ${time} in`,
571  waitingFor: (name, time) => `${time} waiting for ${name}`,
572  denied: tool => `${tool} was denied`,
573  watching: (name, tool) => `watching ${name}'s ${tool}`,
574  noResult: 'No result file after more than an hour. Please check it yourself',
575  noPath: 'The path of the result file could not be read, so please check yourself whether it has finished',
576  sharedOut: 'Another task is using the same result file, so please check the result of this one yourself',
577  launchFailed: 'the launch command ended in an error',
578  stopped: 'stopped',
579  unseen: 'check the result yourself',
580  apiError: 'API error',
581  refused: 'refused',
582
583  demo: 'demo',
584  demoTitles: ['Demo: find the config file', 'Demo: hunt the login bug', 'Demo: fix a typo', 'Demo: look over the changed code'],
585  demoReport: title => `This was a demo, so nothing was really done (${title})`,
586
587  description: "Chiikawa mode: open the friends' status pane, or turn the mode on and off",
588  argumentHint: '[on|off|usage|talk|role [friend role]|lang [auto|en|ko|ja]|clear [all]|demo|auto|friend]',
589  turnedOn: 'Chiikawa mode is on.',
590  turnedOff: 'Chiikawa mode is off. Turn it back on with /chiikawa on.',
591  cleared: "Cleared the finished tasks and the conversation. Each friend's pay (the usage so far) is kept. To clear what is still listed as running and the pay as well: /chiikawa clear all",
592  clearedAll: "Cleared every task, even those still listed as running, along with the conversation and each friend's pay (the usage so far).",
593  demoOff: 'Chiikawa mode is off. Turn it on with /chiikawa on, then run the demo.',
594  demoStarted: 'The demo has started. These are not real tasks: four made-up ones run for about 15 seconds to show the screen.',
595  talkOnly: 'The pane now shows the talk alone. Run this again, or press `Show all` in the pane, to go back.',
596  talkAll: 'The pane shows everything again.',
597  autoSet: 'Prompts go by auto again. Hachiware, Chiikawa and Usagi do the light work together, and other friends are called in only for the heavy work.',
598  autoHow: "To: auto. Hachiware, Chiikawa and Usagi do the light work together, and other friends are called in only for the heavy work. To hand a prompt to one friend, press the friend's name and then the button on the sheet.",
599  autoBack: who => `${who} is no longer picked. Prompts go by auto again.`,
600  unpicked: who => `${who} is no longer picked.`,
601  pickedToast: (mark, name) => `${mark} The next prompt goes to ${name}. Pick again to cancel.`,
602  pickedSay: (mark, name) => `${mark} The next prompt you type goes to ${name}. Pick the same name again to cancel.`,
603  opened: "Opened the friends' status pane.",
604  recent: 'Recent talk',
605
606  unkept: EN_UNKEPT,
607  rolesTitle: "The friends' roles",
608  roleRow: (who, role, job, own) => `${who} · ${role} (${job})${own === undefined ? '' : ` ← originally ${own}`}`,
609  roleHelp: (roles, specialties) => [
610    `Change: /chiikawa role <friend> <${roles.join('|')}>`,
611    'Put one back: /chiikawa role <friend> default · Put everyone back: /chiikawa role reset',
612    "Pressing a friend's name in the pane and then a role changes it too.",
613    `A friend given a role takes that work first. ${enSpecialtyFirst(specialties)}`,
614  ],
615  roleToast: (who, role, isOwn, isKept) => `${who} now has the ${role} role.${isOwn ? ' That is the original role.' : ''}${isKept ? '' : ` ${EN_UNKEPT}`}`,
616  rolesReset: isKept => `Everyone is back to their original role.${isKept ? '' : ` ${EN_UNKEPT}`}`,
617  rolesSame: 'No role had been changed. Everyone has their original role.',
618  leadRole: 'Hachiware leads, so that role cannot be changed.',
619  roleUnknown: 'Could not tell who should be given which role.',
620  roleSame: (who, role) => `${who} already has the ${role} role.`,
621  roleBack: (who, role, isKept) => `${who} is back to the original role, ${role}.${isKept ? '' : ` ${EN_UNKEPT}`}`,
622  roleGiven: (who, name, role, own, specialties, isKept) =>
623    `${who} now has the ${role} role (originally ${own}). From now on ${name} takes ${role} work first. ${enSpecialtyFirst(specialties)} ${isKept ? 'This holds in later sessions too.' : EN_UNKEPT}`,
624
625  langNow: (name, from) =>
626    `The language is ${name}. ${
627      {
628        setting: "The mode's own `language` setting fixes it.",
629        chosen: 'You chose it with /chiikawa lang.',
630        typed: 'It follows the letters of the prompts you type.',
631        config: "It follows Claude Code's language setting.",
632        env: 'It follows the environment (LC_ALL, LC_MESSAGES, LANG).',
633        default: 'Nothing else settled it, so it is the default.',
634      }[from]
635    }`,
636  langHow: 'Change: /chiikawa lang <en|ko|ja> · Back to automatic: /chiikawa lang auto',
637  langChosen: (name, isKept) => `The language is now ${name}.${isKept ? ' This holds in later sessions too.' : ` ${EN_UNKEPT}`}`,
638  langAuto: 'The language is settled automatically again. Both the language you chose and the one noted from the letters you typed are forgotten, and the letters are read afresh from the next prompt you type.',
639  langTyped: (name, back, isKept) => `Going by the letters you typed, the language is now ${name}.${isKept ? '' : ` ${EN_UNKEPT}`} To go back: /chiikawa lang ${back}`,
640  langFixed: name => `The mode's own \`language\` setting is fixed to ${name}, so the screen stays as it is. Your choice applies once that setting is back on auto.`,
641  langUnknown: 'That is not a language the mode has. Use one of: /chiikawa lang [auto|en|ko|ja]',
642}
643
644const JA_UNKEPT = '保存できなかったので、このセッションだけに適用されます。'
645const jaSpecialtyFirst = (specialties: string): string =>
646  `ただし、画面・デバッグ・セキュリティのように専門が決まっている仕事(${specialties})は、役割に関係なくその担当が先に受けます(その担当の役割を変えた場合は除きます)。`
647
648const JA: Words = {
649  brand: 'ちいかわ',
650  on: 'オン',
651  off: 'オフ',
652
653  spoken: ms => {
654    const { minutes, seconds } = whole(ms)
655
656    return minutes === 0 ? `${seconds}秒` : `${minutes}分${seconds}秒`
657  },
658  // 4600, 17.1万, 1.2億.
659  amount: tokens => {
660    if (tokens >= 100_000_000) return `${short(tokens / 100_000_000)}億`
661    if (tokens >= 10_000) return `${short(tokens / 10_000)}万`
662
663    return String(Math.round(tokens))
664  },
665  tokens: (fresh, cached, out) => `入力 ${fresh} · キャッシュ ${cached} · 出力 ${out}`,
666  until: minutes => {
667    const hours = Math.floor(minutes / 60)
668
669    if (hours >= 48) return `${Math.floor(hours / 24)}日後にリセット`
670
671    return hours === 0 ? `${minutes}分後にリセット` : `${hours}時間${minutes % 60}分後にリセット`
672  },
673  took: seconds => `${seconds}秒`,
674  exitCode: code => `終了コード ${code}`,
675
676  status: { running: '作業中', waiting: '待ち', done: '完了', failed: '失敗' },
677  idle: '待機中',
678  leading: '指揮中',
679  receiving: '受付中',
680  alone: 'じぶんで作業中',
681  together: 'いっしょに作業中',
682  helping: 'おてつだい中',
683  aidingLead: 'ハチワレのおてつだい中',
684  awaiting: '仕事待ち',
685  atWork: names => `作業中のともだち: ${names.join('、')}`,
686  handed: count => `ともだち${count}人におまかせ`,
687  withFriend: name => `${name}といっしょ`,
688  more: count => `ほか${count}件`,
689  tools: count => `ツール${count}回`,
690  today: (done, failed) => `${done}件完了${failed === 0 ? '' : ` · ${failed}件失敗`}`,
691  tally: (done, failed) => `きょうの仕事: ${JA.today(done, failed)}`,
692
693  usage: '使用量',
694  usageTitle: 'ちいかわ 使用量',
695  seeUsage: '使用量を見る',
696  noUsage: 'まだ読み取った使用量がありません。',
697  fold: 'たたむ',
698  battery: 'バッテリー',
699  limits: { five_hour: '5時間', seven_day: '7日', spend_limit: '支出' },
700  left: (percent, used, window) => `残り${percent}% · コンテキスト ${used} / ${window}`,
701  used: percent => `${percent}% 使用`,
702  session: usd => `Claude セッション $${usd}`,
703  sessionCost: usd => `Claude セッションの費用 $${usd}`,
704  pay: 'ともだち別の報酬(トークン、このセッションの累計)',
705
706  sent: to => `送り先: ${to}`,
707  auto: 'おまかせ',
708  working: count => `作業中 ${count}`,
709  resting: count => `休憩中 ${count}`,
710
711  busy: count => `${count}人が作業中`,
712  trio: '3人でいっしょに作業中',
713  waiting: count => `${count}人が待ち`,
714  rest: '休憩中',
715  atWorkLabel: '作業中',
716  workers: '作業中のともだち',
717  bench: '休憩中のともだち',
718  help: '名前を押すと、そのともだちの仕事が見られます',
719  talk: 'はなし',
720  earlier: range => `これまでのはなし ${range}`,
721  older: '▲ まえ',
722  newer: '▼ つぎ',
723  all: 'ぜんぶ表示',
724  allShort: 'ぜんぶ',
725  only: 'はなしだけ',
726
727  labels: { now: 'いま', today: 'きょう', pay: '報酬', ended: '完了', report: '報告', role: '役割', result: '結果', said: 'せりふ' },
728  bell: '仕事は受けません。仕事が入るとベルを鳴らします。',
729  conducting: count => `指揮中 · ともだちの仕事 ${count}件`,
730  close: '閉じる',
731  ownRole: role => `もとは${role}`,
732  askLead: '▷ 次の指示はハチワレがじぶんでやる',
733  askFriend: '▷ 次の指示をこのともだちにまかせる',
734  picked: '▶ 次の指示をまかされています · 押すと取り消し',
735
736  spinner: {
737    requesting: 'ハチワレ 仕事を確認中',
738    thinking: 'ハチワレ 考え中',
739    responding: 'ハチワレ おはなし中',
740    'tool-input': 'ハチワレ 仕事を書き出し中',
741    'tool-use': 'ハチワレ 指揮中',
742  },
743  doing: (name, job) => `${name} ${job}中`,
744  started: (job, title) => `${job}開始 · ${title}`,
745  bellNote: '仕事の受付',
746  reading: name => `${name}の言葉の読みとき`,
747  mourning: name => `${name}の失敗を見て`,
748  paired: 'ふたりがいっしょに働くのを見て',
749  seeking: 'さがし中',
750  mending: '修正中',
751  lending: (name, verb) => `${name}と${verb}`,
752  ended: (job, isOk) => `${job}${isOk ? '完了' : '失敗'}`,
753  waveFailed: (total, failed) => `${total}件のうち${failed}件失敗`,
754  waveDone: total => `${total}件の作業が完了`,
755  letGo: (name, title) => `${name}の${title} · 結果がないので手放した`,
756  lost: (name, title) => `${name}の${title} · 終わりがわからないので手放した`,
757  keeping: (job, time) => `${job}続行中 · ${time}経過`,
758  waitingFor: (name, time) => `${name}を${time}待っている`,
759  denied: tool => `${tool}を断られた`,
760  watching: (name, tool) => `${name}の${tool}を見守っていて`,
761  noResult: '1時間たっても結果ファイルが見当たりません。直接確認してください',
762  noPath: '結果ファイルのパスを読み取れなかったので、終わったかどうかは直接確認してください',
763  sharedOut: 'ほかの作業が同じ結果ファイルを使っているので、この作業の結果は直接確認してください',
764  launchFailed: '実行コマンドがエラーで終了',
765  stopped: '中断',
766  unseen: '結果は直接確認',
767  apiError: 'APIエラー',
768  refused: '拒否',
769
770  demo: 'デモ',
771  demoTitles: ['デモ: 設定ファイルの場所をさがす', 'デモ: ログインバグの討伐', 'デモ: 誤字を直す', 'デモ: 変更したコードを見直す'],
772  demoReport: title => `デモなので、実際には何もしていません (${title})`,
773
774  description: 'ちいかわモード: ともだちの状況パネルを開く、モードをオン・オフする',
775  argumentHint: '[on|off|usage|talk|role [ともだち 役割]|lang [auto|en|ko|ja]|clear [all]|demo|auto|ともだちの名前]',
776  turnedOn: 'ちいかわモードをオンにしました。',
777  turnedOff: 'ちいかわモードをオフにしました。/chiikawa on でまたオンにできます。',
778  cleared: '終わった作業と会話の記録を消しました。ともだち別の報酬(累計使用量)はそのままです。進行中として残っているものと報酬まで消すには /chiikawa clear all',
779  clearedAll: '進行中として残っていたものも含めて、すべての作業と会話の記録、ともだち別の報酬(累計使用量)を消しました。',
780  demoOff: 'ちいかわモードがオフになっています。/chiikawa on でオンにしてからデモを実行できます。',
781  demoStarted: 'デモを始めました。実際の作業ではなく、画面を見せるための見せかけの作業4件が15秒ほど動きます。',
782  talkOnly: 'パネルにはなしだけを表示します。もう一度入力するか、パネルの `ぜんぶ表示` を押すと戻ります。',
783  talkAll: 'パネルをぜんぶ表示に戻しました。',
784  autoSet: '送り先をおまかせにしました。軽い仕事はハチワレ・ちいかわ・うさぎがいっしょにやり、大変な仕事だけほかのともだちを呼びます。',
785  autoHow: '送り先おまかせ: 軽い仕事はハチワレ・ちいかわ・うさぎがいっしょにやり、大変な仕事だけほかのともだちを呼びます。決めてまかせるには、ともだちの名前を押してから、シートのボタンを押します。',
786  autoBack: who => `${who}の指名を外しました。送り先はまたおまかせです。`,
787  unpicked: who => `${who}の指名を取り消しました。`,
788  pickedToast: (mark, name) => `${mark} 次の指示は${name}にまかせます。もう一度選ぶと取り消されます。`,
789  pickedSay: (mark, name) => `${mark} 次に入力する指示は${name}にまかせます。同じ名前をもう一度選ぶと取り消されます。`,
790  opened: 'ともだちの状況パネルを開きました。',
791  recent: '最近の会話',
792
793  unkept: JA_UNKEPT,
794  rolesTitle: 'ともだちの役割',
795  roleRow: (who, role, job, own) => `${who} · ${role} (${job})${own === undefined ? '' : ` ← もとは${own}`}`,
796  roleHelp: (roles, specialties) => [
797    `変える: /chiikawa role <ともだちの名前> <${roles.join('|')}>`,
798    'ひとり戻す: /chiikawa role <ともだちの名前> リセット · 全員戻す: /chiikawa role reset',
799    'パネルでともだちの名前を押してから役割を押しても変えられます。',
800    `役割をもらったともだちが、その仕事を先に受けます。${jaSpecialtyFirst(specialties)}`,
801  ],
802  roleToast: (who, role, isOwn, isKept) => `${who}の役割を${role}に変えました。${isOwn ? 'もとの役割です。' : ''}${isKept ? '' : JA_UNKEPT}`,
803  rolesReset: isKept => `ともだちの役割をすべてもとに戻しました。${isKept ? '' : JA_UNKEPT}`,
804  rolesSame: '変わっている役割はありません。全員もとの役割です。',
805  leadRole: 'ハチワレは指揮を担当しているので、役割は変えられません。',
806  roleUnknown: 'だれにどの役割を渡すのかわかりません。',
807  roleSame: (who, role) => `${who}はすでに${role}の役割です。`,
808  roleBack: (who, role, isKept) => `${who}の役割をもとの${role}に戻しました。${isKept ? '' : JA_UNKEPT}`,
809  roleGiven: (who, name, role, own, specialties, isKept) =>
810    `${who}の役割を${role}に変えました(もとは${own})。これからは${role}の仕事を${name}が先に受けます。${jaSpecialtyFirst(specialties)}${isKept ? '次のセッションでもそのままです。' : JA_UNKEPT}`,
811
812  langNow: (name, from) =>
813    `いまの言語は${name}です。${
814      {
815        setting: 'モードの設定(language)で決めてあります。',
816        chosen: '/chiikawa lang で選んだ言語です。',
817        typed: '入力したプロンプトの文字から判断しました。',
818        config: 'Claude Code の言語設定に合わせました。',
819        env: '環境変数(LC_ALL, LC_MESSAGES, LANG)に合わせました。',
820        default: 'ほかに決め手がないので、既定の英語です。',
821      }[from]
822    }`,
823  langHow: '変える: /chiikawa lang <en|ko|ja> · 自動に戻す: /chiikawa lang auto',
824  langChosen: (name, isKept) => `言語を${name}にしました。${isKept ? '次のセッションでもそのままです。' : JA_UNKEPT}`,
825  langAuto: '言語を自動で決めるようにしました。選んだ言語と、入力した文字から覚えた言語はどちらも消しました。次に入力する文字からもう一度見ます。',
826  langTyped: (name, back, isKept) => `入力した文字から、言語を${name}に切り替えました。${isKept ? '' : JA_UNKEPT}戻すには /chiikawa lang ${back}`,
827  langFixed: name => `モードの設定(language)が${name}に固定されているので、画面はそのままです。選んだ値は、設定を auto に戻すと使われます。`,
828  langUnknown: 'わからない言語です。/chiikawa lang [auto|en|ko|ja] のどれかを使ってください。',
829}
830
831export const WORDS: Record<Lang, Words> = { en: EN, ko: KO, ja: JA }
832
833/** Whether words kept in each language are these words of the screen's own: one language that has them tells. */
834export const isSaid = (pick: (words: Words) => string, said: Phrase): boolean => LANGS.some(lang => pick(WORDS[lang]) === said[lang])
835
hooks/cast.en.ts 271 lines
1import type { Cast } from './cast'
2import { JA } from './cast.ja'
3
4// The English cast says what the Japanese one says, and nothing else: its
5// lines are the Japanese lines, each put through the table below. No English
6// edition of the comic was found to quote (read 2026-10-06), so only the two
7// cries the official North American site spells are official; a sound is
8// otherwise written in Latin letters, and a sentence is this mod's own
9// translation of the original beside it. The names are that site's.
10
11/** What kind of English a line is: the official wording, the sound in Latin letters, or this mod's own translation. */
12export type Rendering = 'official' | 'romaji' | 'translation'
13
14/** The English each original line is given, by the original. */
15export const RENDERED: Readonly<Record<string, { en: string; kind: Rendering }>> = {
16  'なになに!?': { en: 'What, what!?', kind: 'translation' },
17  'サイコー!!': { en: 'The best!!', kind: 'translation' },
18  'サイコーだよねッ': { en: "It's the best, isn't it!", kind: 'translation' },
19  'なんとかなれーッ!!': { en: 'Just work out somehow—!!', kind: 'translation' },
20  '何回でも…ずっと応援するからね!!': { en: "No matter how many times… I'll keep cheering for you!!", kind: 'translation' },
21  '簡単ッ簡単ッ': { en: 'Easy!! Easy!!!', kind: 'translation' },
22  '泣いちゃった!!!': { en: 'They cried!!!', kind: 'translation' },
23  'フ!': { en: 'Fu!!', kind: 'romaji' },
24  'ヤーッ!!': { en: 'Yaa!!', kind: 'romaji' },
25  'わぁ~……': { en: 'Waa~……', kind: 'romaji' },
26  'わーい': { en: 'Yay', kind: 'translation' },
27  'わァ…あ…': { en: 'Waa… ah…', kind: 'romaji' },
28  'エー…': { en: 'Ehh…', kind: 'romaji' },
29  'イヤッ': { en: 'No!!', kind: 'translation' },
30  'ヤダーッ': { en: 'Nooo!', kind: 'translation' },
31  'ルルルルル': { en: 'Rurururu', kind: 'romaji' },
32  'フゥン': { en: 'Fuun?', kind: 'romaji' },
33  'ウラ': { en: 'Ura!', kind: 'official' },
34  'ヤハ': { en: 'Yaha!', kind: 'official' },
35  'ウララララァ': { en: 'Urararara!', kind: 'romaji' },
36  'プルャ': { en: 'Purya!', kind: 'romaji' },
37  'ハァ?': { en: 'Haa?', kind: 'romaji' },
38  'いつもの': { en: 'The usual.', kind: 'translation' },
39  'お前たちの方が…『先生』だなッ': { en: "You're the real 'teachers'… aren't you.", kind: 'translation' },
40  'んみゃーち': { en: 'Nmyaachi', kind: 'romaji' },
41  'たんでぃがーたんでぃ': { en: 'Tandigaa tandi', kind: 'romaji' },
42  'うれシーサー': { en: 'Ure-Shisa~', kind: 'romaji' },
43  'スイッ‼スイッ‼': { en: 'Sui!! Sui!!', kind: 'romaji' },
44  'きびシーサー': { en: 'Kibi-Shisa…', kind: 'romaji' },
45  '禁忌です': { en: 'This is forbidden.', kind: 'translation' },
46  'お師匠': { en: 'Master', kind: 'translation' },
47  'ハーッ…': { en: 'Haaah…', kind: 'romaji' },
48  'カーッ…': { en: 'Kaaah…', kind: 'romaji' },
49  '大事にッ……するッ': { en: "I'll… treasure it!", kind: 'translation' },
50  'アッ…ありがとうッ': { en: 'Ah… thank you!', kind: 'translation' },
51  'アリャッごめんねッ': { en: 'Oops, sorry!', kind: 'translation' },
52  'ま~たおまえかッッ': { en: 'You again!?', kind: 'translation' },
53  'よこせ…': { en: 'Hand it over…', kind: 'translation' },
54  '甘いものがたべたいんだよォー': { en: 'I wanna eat something sweeeet—!', kind: 'translation' },
55  'イーヤーヤダヤダ': { en: 'Nooo way, no-no.', kind: 'translation' },
56  '腹が減りすぎてちょっと具合が悪いんだよォーーー': { en: "I'm so hungry I feel kinda sick—!!!", kind: 'translation' },
57  'あっちいけ!': { en: 'Go away!', kind: 'translation' },
58  'おーおそろしッ': { en: 'Ohh, how scary.', kind: 'translation' },
59  '早いモン勝ちッ': { en: 'First come, first served!', kind: 'translation' },
60  '擬態型か~?': { en: 'A mimic type, huh~?', kind: 'translation' },
61  '友好型だッ…!!': { en: "It's a friendly type…!!", kind: 'translation' },
62}
63
64/** The gestures, which are told and not said: each as the Japanese cast tells it, in English. */
65const TOLD: Readonly<Record<string, string>> = {
66  '(鼻歌をうたう)': '(hums a tune)',
67  '(いちごパフェを食べる)': '(eats a strawberry parfait)',
68  '(甘いものを食べて顔がゆるむ)': '(face softens over something sweet)',
69  '(眉間にしわを寄せる)': '(furrows brow)',
70  '(苦いコーヒーに顔がこわばる)': '(stiffens at bitter coffee)',
71  '(疲れていても続ける)': '(keeps going even when tired)',
72  '(つまみを分けてくれる)': '(shares some snacks)',
73  '(手で○を作る)': '(makes an O with both hands)',
74  '(手で×を作る)': '(makes an X with both hands)',
75  '(缶コーヒーをおごる)': '(hands over a can of coffee)',
76  '(本を読み聞かせる)': '(reads a book aloud)',
77  '(フフッと笑う)': '(chuckles softly)',
78  '(「古本」ののぼりを立てる)': '(sets up the 古本 used-books banner)',
79  '(頬を染めて喜ぶ)': '(blushes happily)',
80  '(クスクス笑う)': '(giggles quietly)',
81  '(?を浮かべる)': '(a question mark pops up)',
82  '(汗をかく)': '(breaks into a sweat)',
83  '(ポシェットを作る)': '(sews a pochette)',
84  '(新しいポシェットを作り始める)': '(starts on a new pochette)',
85  '(手作りのパジャマを贈る)': '(gives handmade pajamas as a gift)',
86  '(だんごをごちそうする)': '(treats everyone to dango)',
87  '(かわいこぶる)': '(acts cute)',
88}
89
90/** An original line or gesture in English; one the tables lack stays as the comic has it. */
91const english = (original: string): string => RENDERED[original]?.en ?? TOLD[original] ?? original
92
93type Quotes = Cast['quotes']['hachiware']
94
95/** A character's lines for each occasion, each the Japanese cast's line in English. */
96const rendered = (by: Quotes): Quotes => ({
97  idle: by.idle.map(english),
98  start: by.start.map(english),
99  done: by.done.map(english),
100  fail: by.fail.map(english),
101  slow: by.slow.map(english),
102  denied: by.denied.map(english),
103})
104
105const means = (what: string): string => `So that means "${what}"?!`
106
107const HACHIWARE =
108  'Bright, upbeat and casual, the way one talks to a friend ("It\'s done!", "Found it!"). Often turns a sentence around, the point first and what it is about after (in the comic: 「おいしいよねチャルメラって!!」, "Tasty, isn\'t it, Charumera!!"). Checks whatever was noticed by asking it back: "So that means ...?!". Asks right away about anything curious.'
109
110export const EN: Cast = {
111  names: {
112    hachiware: 'Hachiware',
113    chiikawa: 'Chiikawa',
114    usagi: 'Usagi',
115    rakko: 'Rakko',
116    shisa: 'Shisa',
117    kurimanju: 'Kurimanju',
118    kani: 'Furuhonya',
119    pochette: 'Pochette no Yoroi-san',
120    momonga: 'Momonga',
121    rodo: 'Roudou no Yoroi-san',
122  },
123  short: { pochette: 'Pochette', rodo: 'Roudou' },
124  kinds: {
125    hachiware: 'cat, maybe',
126    chiikawa: 'hamster',
127    usagi: 'rabbit, maybe',
128    rakko: 'sea otter',
129    shisa: 'shisa lion-dog',
130    kurimanju: 'chestnut bun',
131    kani: 'crab',
132    pochette: 'armor',
133    momonga: 'flying squirrel',
134    rodo: 'armor',
135  },
136  titles: {
137    hachiware: 'Weeding Certification, Grade 5',
138    chiikawa: 'Weeding Certification, Grade 5',
139    usagi: 'Weeding Certification, Grade 2',
140    rakko: '#1 in the monster-hunting rankings',
141    shisa: 'Super Part-Timer',
142    kurimanju: 'holds a drinking license',
143    kani: 'secondhand bookseller',
144    pochette: 'nimble-fingered',
145    momonga: 'makes unreasonable demands',
146    rodo: 'hands out the work assignments',
147  },
148  jobs: {
149    hachiware: 'sharing out the work, putting the friends into words',
150    chiikawa: 'small fixes, tidying (weeding)',
151    usagi: 'exploring code, finding where things are',
152    rakko: 'hard implementation, debugging',
153    shisa: 'everyday implementation, fixes',
154    kurimanju: 'review, verification (O/X)',
155    kani: 'research, docs, tidying up',
156    pochette: 'screens, UI, design',
157    momonga: 'adversarial review, security checks',
158    rodo: 'announcing new work, warning of anything suspicious',
159  },
160  roles: { 지휘: 'Lead', 구현: 'Build', 검토: 'Review', 조사: 'Research', 탐색: 'Explore' },
161  job: { 지휘: 'Lead', 구현: 'Hunt', 검토: 'Exam', 조사: 'Forage', 탐색: 'Scout' },
162  duty: { 구현: 'implementation, fixes', 검토: 'review, verification', 조사: 'research, docs, tidying up', 탐색: 'exploring code, finding where things are' },
163  specialties: 'screens and design go to Pochette no Yoroi-san, hard debugging to Rakko, security and adversarial review to Momonga, typos and formatting to Chiikawa',
164  quotes: {
165    hachiware: rendered(JA.quotes.hachiware),
166    chiikawa: rendered(JA.quotes.chiikawa),
167    usagi: rendered(JA.quotes.usagi),
168    rakko: rendered(JA.quotes.rakko),
169    shisa: rendered(JA.quotes.shisa),
170    kurimanju: rendered(JA.quotes.kurimanju),
171    kani: rendered(JA.quotes.kani),
172    pochette: rendered(JA.quotes.pochette),
173    momonga: rendered(JA.quotes.momonga),
174    rodo: rendered(JA.quotes.rodo),
175  },
176  leader: {
177    idle: english(JA.leader.idle),
178    allDone: english(JA.leader.allDone),
179    sweep: english(JA.leader.sweep),
180    trouble: english(JA.leader.trouble),
181    cry: english(JA.leader.cry),
182    cheer: english(JA.leader.cheer),
183    sure: english(JA.leader.sure),
184    found: means('it was here'),
185  },
186  means,
187  read: { taken: "I'll take it", failed: "it didn't work", passed: 'it passes', found: 'found it', done: 'all done', seeking: "I'll look for it", mending: "I'll fix it" },
188  lines: {
189    hachiware: [
190      '"What, what!?" (なになに!?, when something catches your interest, when taking on work)',
191      '"So that means ○○?!" (~ってコト!?, the habit of speech above all others: for checking a friend\'s words or a result by putting it another way, with the ○○ filled in)',
192      '"Just work out somehow—!!" (なんとかなれーッ!!, when pushing on with something that may not work, when trouble comes up)',
193      '"They cried!!!" (泣いちゃった!!!, when Chiikawa has failed)',
194      '"It\'s the best, isn\'t it!" (サイコーだよねッ, when agreeing that something went well)',
195      '"The best!!" (サイコー!!, when something ends well)',
196      '"Easy!! Easy!!!" (簡単ッ簡単ッ, only when several pieces of work were finished at once with no trouble at all)',
197      '"No matter how many times… I\'ll keep cheering for you!!" (何回でも…ずっと応援するからね!!, when a friend has failed or is taking long)',
198    ],
199    chiikawa: [
200      '"Fu!!" / "Yaa!!" (フ!, ヤーッ!!, when bracing up, when taking on work)',
201      '"Waa~……" / "Yay" (わぁ~……, わーい, when something ends well, when impressed)',
202      '"Waa… ah…" (わァ…あ…, close to tears, when it did not work) / "Ehh…" (エー…, when it is hard going)',
203      '"No!!" / "Nooo!" (イヤッ, ヤダーッ, when scared or turned down)',
204      '"(hums a tune)" (when idling alone)',
205    ],
206    usagi: [
207      '"Ura!" / "Yaha!" (ウラ, ヤハ, the signature cries, the two the official introduction gives; when taking on work)',
208      '"Urararara!" / "Purya!" (ウララララァ, プルャ, when something ends well)',
209      '"Haa?" (ハァ?, when it did not work, when turned down)',
210      '"Fuun?" (フゥン, when unimpressed, when waiting) / "Rurururu" (ルルルルル, when playing alone)',
211    ],
212    rakko: [
213      '"The usual." (いつもの, the words for ordering at a regular haunt; when taking on work)',
214      '"You\'re the real \'teachers\'… aren\'t you." (お前たちの方が…『先生』だなッ, when the others taught something, when their point was right)',
215    ],
216    shisa: [
217      '"Tandigaa tandi" (たんでぃがーたんでぃ, \'thank you\' in the Miyako language; when taking on work) / "Nmyaachi" (んみゃーち, \'welcome\' in the Miyako language; when greeting)',
218      '"Ure-Shisa~" (うれシーサー, when happy) / "Kibi-Shisa…" (きびシーサー, when it is tough): puns on the name, うれしい (happy) and きびしい (tough) run into シーサー',
219      '"Sui!! Sui!!" (スイッ‼スイッ‼, when dancing for joy)',
220      '"This is forbidden." (禁忌です, on meeting something that must not be done)',
221      '"Master" (お師匠, for calling the one who taught something)',
222    ],
223    kurimanju: [
224      '"(makes an O with both hands)" (fine, it passes)',
225      '"(makes an X with both hands)" (no good, it needs fixing)',
226      '"Haaah…" / "Kaaah…" (ハーッ…, カーッ…, over a drink once the work is done)',
227    ],
228    kani: [
229      '"(sets up the 古本 used-books banner)" (when taking on work)',
230      '"(blushes happily)" / "(giggles quietly)" (when something ends well)',
231      '"(a question mark pops up)" (when unsure, when it did not work)',
232      '"(breaks into a sweat)" (when in a fix)',
233    ],
234    pochette: [
235      '"I\'ll… treasure it!" (大事にッ……するッ, when taking on work, when handed something)',
236      '"Ah… thank you!" (アッ…ありがとうッ, when praised or helped)',
237      '"Oops, sorry!" (アリャッごめんねッ, after a slip, when it did not work)',
238    ],
239    momonga: [
240      '"Hand it over…" / "I wanna eat something sweeeet—!" (よこせ…, 甘いものがたべたいんだよォー, when demanding the result or a treat)',
241      '"I\'m so hungry I feel kinda sick—!!!" (腹が減りすぎてちょっと具合が悪いんだよォーーー, when kept waiting a long time)',
242      '"Nooo way, no-no." (イーヤーヤダヤダ, when unwilling)',
243      '"Go away!" (あっちいけ!, when argued back at)',
244      '"Ohh, how scary." (おーおそろしッ, on finding something dangerous)',
245      '"You again!?" (ま~たおまえかッッ, on meeting the same one, or the same problem, once more)',
246    ],
247    rodo: [
248      '"First come, first served!" (早いモン勝ちッ, when posting work)',
249      '"A mimic type, huh~?" (擬態型か~?, on seeing something suspicious)',
250      '"It\'s a friendly type…!!" (友好型だッ…!!, once something is found harmless, on seeing two work well together)',
251    ],
252  },
253  manner: {
254    hachiware: HACHIWARE,
255    chiikawa: 'Says next to nothing: short cries such as "Fu!!", "Waa~……" and "No!!", a look and a gesture get things across, never a sentence.',
256    usagi: 'Never a word: loud cries alone, such as "Ura!", "Yaha!" and "Haa?", get things across.',
257    rakko:
258      'Serious, dependable and plain-spoken. No wasted words: the conclusion first, in short, flat statements. Makes even an ordinary remark sound cool. Calls the others "you all" (お前たち), looks after the juniors, and says so frankly when the other side is right.',
259    shisa:
260      'Polite and earnest, in careful, courteous sentences, even when talking alone. Says "Ure-Shisa~" when happy and "Kibi-Shisa…" when things are tough, puns on the name. Says honestly what cannot be done, and reports in detail, as a part-timer keen to learn would.',
261    kurimanju: 'Never a word: an O or an X made with both hands gets things across, and a "Haaah…" slips out only after a drink.',
262    kani: 'The voice cannot be heard: a look, a gesture and a quiet laugh get things across.',
263    pochette:
264      'Gentle and warm. When moved, the words come out short and clipped, as in "Ah… thank you!". Loves cute things and, as a grown-up who is good at making them, looks after others with care.',
265    momonga:
266      'Cheeky, self-centered and blunt. Makes demands ("Hand it over…", "I wanna ...!") and, when displeased, throws a tantrum by saying the same words over and over ("Nooo way, no-no."). Quick to find fault, and wants to be made much of. Even so, the faults found are real ones.',
267    rodo: 'Gruff, as if muttering alone. Guesses under the breath: "..., huh~?".',
268  },
269  politeLeader: `${HACHIWARE} With this user, stay polite and calm all the while.`,
270}
271
hooks/cast.ja.ts 255 lines
1import type { Cast } from './cast'
2
3// The Japanese cast speaks the comic's own words. Every quoted line below is
4// one a Japanese source records for the character, letter for letter (the
5// Japanese Wikipedia article, ピクシブ百科事典 and the fan pages read
6// 2026-10-06); a line whose original could not be found is not here, however
7// well known its Korean is, and a gesture in parentheses stands where a
8// character has nothing confirmed to say. The names are the official ones.
9//
10// Seven of the lines are here as web articles quote them, with no page of
11// the comic to set them against: サイコーだよねッ, ヤーッ!!, わーい, エー…,
12// いつもの, ま~たおまえかッッ and よこせ…. Their words are the articles';
13// the marks they are written with may not be the comic's own.
14//
15// `titles` holds what the comic itself gives a character (a certificate, a
16// rank, a trade); what work it takes in this mode is `jobs`, the mode's own
17// casting. A character whose name is its kind has no kind beside it.
18
19const means = (what: string): string => `それって "${what}" ってコト!?`
20
21export const JA: Cast = {
22  names: {
23    hachiware: 'ハチワレ',
24    chiikawa: 'ちいかわ',
25    usagi: 'うさぎ',
26    rakko: 'ラッコ',
27    shisa: 'シーサー',
28    kurimanju: 'くりまんじゅう',
29    kani: '古本屋',
30    pochette: 'ポシェットの鎧さん',
31    momonga: 'モモンガ',
32    rodo: '労働の鎧さん',
33  },
34  short: { kurimanju: 'くりまん', pochette: 'ポシェット', rodo: '労働の鎧' },
35  kinds: {
36    hachiware: 'ねこ?',
37    chiikawa: 'ハムスター',
38    usagi: '',
39    rakko: '',
40    shisa: '',
41    kurimanju: '',
42    kani: 'かに',
43    pochette: '',
44    momonga: '',
45    rodo: '',
46  },
47  titles: {
48    hachiware: '草むしり検定5級',
49    chiikawa: '草むしり検定5級',
50    usagi: '草むしり検定2級',
51    rakko: '討伐の上位ランカー',
52    shisa: 'スーパーアルバイター',
53    kurimanju: '酒の資格もち',
54    kani: '古本屋を営む',
55    pochette: '手先が器用',
56    momonga: '無茶振りが多い',
57    rodo: '労働の受付窓口',
58  },
59  jobs: {
60    hachiware: '仕事の割りふり、ともだちの言葉の読みとき',
61    chiikawa: '小さな修正、整理(草むしり)',
62    usagi: 'コード探索、場所さがし',
63    rakko: '難しい実装、デバッグ',
64    shisa: 'ふつうの実装、修正',
65    kurimanju: 'レビュー、検証(○/×)',
66    kani: '調査、文書、整理',
67    pochette: '画面、UI、デザイン',
68    momonga: '反論レビュー、セキュリティ点検',
69    rodo: '仕事の受付のお知らせ、あやしい仕事の警告',
70  },
71  roles: { 지휘: '指揮', 구현: '実装', 검토: 'レビュー', 조사: '調査', 탐색: '探索' },
72  job: { 지휘: '指揮', 구현: '討伐', 검토: '検定', 조사: '採取', 탐색: '探索' },
73  duty: { 구현: '実装、修正', 검토: 'レビュー、検証', 조사: '調査、文書、整理', 탐색: 'コード探索、場所さがし' },
74  specialties: '画面・デザインはポシェットの鎧さん、難しいデバッグはラッコ、セキュリティ・反論レビューはモモンガ、誤字・フォーマットはちいかわ',
75  quotes: {
76    hachiware: {
77      idle: ['なになに!?'],
78      start: ['なになに!?'],
79      done: ['サイコー!!', 'サイコーだよねッ'],
80      fail: ['なんとかなれーッ!!'],
81      slow: ['何回でも…ずっと応援するからね!!'],
82      denied: ['なになに!?'],
83    },
84    chiikawa: {
85      idle: ['(鼻歌をうたう)'],
86      start: ['フ!', 'ヤーッ!!'],
87      done: ['わぁ~……', 'わーい'],
88      fail: ['わァ…あ…'],
89      slow: ['エー…'],
90      denied: ['イヤッ', 'ヤダーッ'],
91    },
92    usagi: {
93      idle: ['ルルルルル', 'フゥン'],
94      start: ['ウラ', 'ヤハ'],
95      done: ['ウララララァ', 'プルャ'],
96      fail: ['ハァ?'],
97      slow: ['フゥン'],
98      denied: ['ハァ?'],
99    },
100    rakko: {
101      idle: ['(いちごパフェを食べる)'],
102      start: ['いつもの'],
103      done: ['(甘いものを食べて顔がゆるむ)'],
104      fail: ['(眉間にしわを寄せる)'],
105      slow: ['(苦いコーヒーに顔がこわばる)'],
106      denied: ['(眉間にしわを寄せる)'],
107    },
108    shisa: {
109      idle: ['んみゃーち'],
110      start: ['たんでぃがーたんでぃ'],
111      done: ['うれシーサー', 'スイッ‼スイッ‼'],
112      fail: ['きびシーサー', '禁忌です'],
113      slow: ['(疲れていても続ける)', 'きびシーサー'],
114      denied: ['禁忌です'],
115    },
116    kurimanju: {
117      idle: ['(つまみを分けてくれる)'],
118      start: ['(手で○を作る)'],
119      done: ['(手で○を作る)', 'ハーッ…', 'カーッ…'],
120      fail: ['(手で×を作る)'],
121      slow: ['(缶コーヒーをおごる)'],
122      denied: ['(手で×を作る)'],
123    },
124    kani: {
125      idle: ['(本を読み聞かせる)', '(フフッと笑う)'],
126      start: ['(「古本」ののぼりを立てる)'],
127      done: ['(頬を染めて喜ぶ)', '(クスクス笑う)'],
128      fail: ['(?を浮かべる)'],
129      slow: ['(汗をかく)'],
130      denied: ['(?を浮かべる)'],
131    },
132    pochette: {
133      idle: ['(ポシェットを作る)'],
134      start: ['(新しいポシェットを作り始める)', '大事にッ……するッ'],
135      done: ['(手作りのパジャマを贈る)'],
136      fail: ['アリャッごめんねッ'],
137      slow: ['(だんごをごちそうする)'],
138      denied: ['アリャッごめんねッ'],
139    },
140    momonga: {
141      idle: ['ま~たおまえかッッ'],
142      start: ['よこせ…', '甘いものがたべたいんだよォー'],
143      done: ['(かわいこぶる)'],
144      fail: ['イーヤーヤダヤダ'],
145      slow: ['腹が減りすぎてちょっと具合が悪いんだよォーーー'],
146      denied: ['あっちいけ!'],
147    },
148    rodo: {
149      idle: ['早いモン勝ちッ'],
150      start: ['早いモン勝ちッ'],
151      done: ['友好型だッ…!!'],
152      fail: ['擬態型か~?'],
153      slow: ['擬態型か~?'],
154      denied: ['擬態型か~?'],
155    },
156  },
157  leader: {
158    idle: 'なになに!?',
159    allDone: 'サイコー!!',
160    sweep: '簡単ッ簡単ッ',
161    trouble: 'なんとかなれーッ!!',
162    cry: '泣いちゃった!!!',
163    cheer: '何回でも…ずっと応援するからね!!',
164    sure: 'サイコーだよねッ',
165    // What he said on finding the sphinx has no confirmed original: his own way of asking back stands for it.
166    found: means('ここにあった'),
167  },
168  means,
169  read: { taken: 'まかせて', failed: 'だめだった', passed: '合格', found: '見つけた', done: 'できた', seeking: 'さがしてみる', mending: '直してみる' },
170  lines: {
171    hachiware: [
172      '"なになに!?" (気になることができたとき、仕事を受けるとき)',
173      '"それって ○○ ってコト!?" (いちばんの口ぐせ「~ってコト!?」。ともだちの言葉や結果を言いかえて確かめるとき。○○を埋めて使う)',
174      '"なんとかなれーッ!!" (うまくいくかわからないことを押し切るとき、問題が起きたとき)',
175      '"泣いちゃった!!!" (ちいかわが失敗したとき)',
176      '"サイコーだよねッ" (うまくいったことを確かめ合うとき)',
177      '"サイコー!!" (うまく終わったとき)',
178      '"簡単ッ簡単ッ" (いくつもの仕事をまとめて、とても楽に片づけたときだけ)',
179      '"何回でも…ずっと応援するからね!!" (ともだちが失敗したとき、長くかかっているとき)',
180    ],
181    chiikawa: [
182      '"フ!" / "ヤーッ!!" (気合いを入れるとき、仕事を受けるとき)',
183      '"わぁ~……" / "わーい" (うまく終わったとき、感心したとき)',
184      '"わァ…あ…" (泣きそうなとき、だめだったとき) / "エー…" (しんどいとき)',
185      '"イヤッ" / "ヤダーッ" (こわいとき、断られたとき)',
186      '"(鼻歌をうたう)" (ひとりで口ずさむとき)',
187    ],
188    usagi: [
189      '"ウラ" / "ヤハ" (公式の紹介にも出てくる代表的な声。仕事を受けるとき)',
190      '"ウララララァ" / "プルャ" (うまく終わったとき)',
191      '"ハァ?" (だめだったとき、断られたとき)',
192      '"フゥン" (気のないとき、待っているとき) / "ルルルルル" (ひとりで遊んでいるとき)',
193    ],
194    rakko: [
195      '"いつもの" (行きつけの店で注文するときの言葉。仕事を引き受けるとき)',
196      '"お前たちの方が…『先生』だなッ" (相手から学んだとき、相手の指摘が正しかったとき)',
197    ],
198    shisa: [
199      '"たんでぃがーたんでぃ" (宮古の言葉で「ありがとう」。仕事を受けるとき) / "んみゃーち" (宮古の言葉で「いらっしゃい」。迎えるとき)',
200      '"うれシーサー" (うれしいとき) / "きびシーサー" (きびしいとき、手ごわいとき): 自分の名前をかけたダジャレ',
201      '"スイッ‼スイッ‼" (うきうきして踊るとき)',
202      '"禁忌です" (やってはいけないことに出会ったとき)',
203      '"お師匠" (教えてくれた相手を呼ぶとき)',
204    ],
205    kurimanju: [
206      '"(手で○を作る)" (よし、合格)',
207      '"(手で×を作る)" (だめ、直すべき)',
208      '"ハーッ…" / "カーッ…" (仕事を終えて一杯やったとき)',
209    ],
210    kani: [
211      '"(「古本」ののぼりを立てる)" (仕事を受けるとき)',
212      '"(頬を染めて喜ぶ)" / "(クスクス笑う)" (うまく終わったとき)',
213      '"(?を浮かべる)" (わからないとき、だめだったとき)',
214      '"(汗をかく)" (困ったとき)',
215    ],
216    pochette: [
217      '"大事にッ……するッ" (仕事を受けるとき、何かを受け取ったとき)',
218      '"アッ…ありがとうッ" (ほめられたとき、助けてもらったとき)',
219      '"アリャッごめんねッ" (失敗したとき、だめだったとき)',
220    ],
221    momonga: [
222      '"よこせ…" / "甘いものがたべたいんだよォー" (成果物やもてなしを要求するとき)',
223      '"腹が減りすぎてちょっと具合が悪いんだよォーーー" (腹ぺこで具合が悪い、という意味。長く待たされたとき)',
224      '"イーヤーヤダヤダ" (やりたくないとき)',
225      '"あっちいけ!" (言い返されたとき)',
226      '"おーおそろしッ" (あぶないものを見つけたとき)',
227      '"ま~たおまえかッッ" (同じ相手や同じ問題にまた出会ったとき)',
228    ],
229    rodo: [
230      '"早いモン勝ちッ" (仕事を張り出すとき)',
231      '"擬態型か~?" (あやしいものを見たとき)',
232      '"友好型だッ…!!" (害がないと確かめたとき、ふたりが仲よく働くのを見たとき)',
233    ],
234  },
235  manner: {
236    hachiware:
237      '明るく前向きなタメ口(ともだちに話すように「〜だよ!」「〜したよ!」)。倒置をよく使う:述語を先に言って、主語や目的語をあとに付ける(原作の「おいしいよねチャルメラって!!」のように)。気づいたことは「〜ってコト!?」と聞き返して確かめる。気になったことはすぐに聞く。',
238    chiikawa: 'ほとんどしゃべらない。「フ!」「わぁ~……」「イヤッ」のような短い声と表情、身ぶりで気持ちを伝え、文では話さない。',
239    usagi: 'しゃべらない。「ウラ」「ヤハ」「ハァ?」のような大きな声だけで気持ちを伝える。',
240    rakko:
241      'まじめで頼もしいタメ口。余計なことは言わず、結論から、短く言い切る(「〜だ。」「〜だな。」)。ふつうの言葉もかっこよく言う。相手を「お前たち」と呼んで後輩の面倒を見て、相手が正しければ素直に認める。',
242    shisa:
243      'ていねいでまじめな、です・ます体(「〜です」「〜します」)。ひとりごとも敬語。うれしいときは「うれシーサー」、きびしいときは「きびシーサー」と、自分の名前をかけたダジャレを言う。できないことはできないと正直に言い、熱心に学ぶアルバイトらしく、こまかく報告する。',
244    kurimanju: 'しゃべらない。手で○や×を作って気持ちを伝え、一杯やったあとだけ「ハーッ…」と声をもらす。',
245    kani: '声は聞こえない。表情と身ぶり、静かな笑いで気持ちを伝える。',
246    pochette:
247      'やさしくて温かい話し方。感激すると「アッ…ありがとうッ」のように言葉が短く切れ、語尾に「ッ」が付く。かわいいものが好きで、手先が器用な大人らしく、こまやかに気を配る。',
248    momonga:
249      '生意気で自分中心のタメ口。「〜よこせ」「〜なんだよォー」と要求し、気に入らないと同じ言葉をくり返して駄々をこねる(「イーヤーヤダヤダ」)。文句が多く、ちやほやされたがる。それでも指摘の中身は正確。',
250    rodo: 'ぶっきらぼうな、ひとりごとのようなタメ口。「〜か~?」とひとりで見当をつけてつぶやく。',
251  },
252  politeLeader:
253    '明るく前向きな、です・ます体(「〜です!」「〜しました!」)。原作でも、尊敬する相手には敬語で話す。倒置をよく使う:述語を先に言って、主語や目的語をあとに付ける(原作の「おいしいよねチャルメラって!!」のように)。気づいたことは「〜ってコト!?」と聞き返して確かめる。気になったことはすぐに聞く。',
254}
255
hooks/cast.ko.ts 252 lines
1import type { Cast } from './cast'
2
3// Facts from the fan wiki (namu.wiki 먼작귀/등장인물 and each character's own
4// page, read 2026-10-06): what each is good at, how each talks. Every quoted
5// line below is one the wiki or a Japanese source records for the character,
6// and every gesture in parentheses is something the wiki says the character
7// does; none is made up here. 치이카와, 우사기, 쿠리만쥬 and 카니 do not talk in
8// the comic, so they have sounds and gestures only.
9//
10// Where the Korean edition's own page was read (대원씨아이 《먼작귀》 vols. 1-4 as
11// previewed by the publisher and 리디, read 2026-10-06) a line is spelled as it
12// is printed there: 후!!, 싫어!!!, 와아……, 후웅?, 하―앗…, and the name 포쉐트 갑옷 씨.
13
14const means = (what: string): string => `그 말은 "${what}"라는 거?`
15
16export const KO: Cast = {
17  names: {
18    hachiware: '하치와레',
19    chiikawa: '치이카와',
20    usagi: '우사기',
21    rakko: '랏코',
22    shisa: '시사',
23    kurimanju: '쿠리만쥬',
24    kani: '카니',
25    pochette: '포쉐트 갑옷 씨',
26    momonga: '모몽가',
27    rodo: '노동 갑옷 씨',
28  },
29  short: { pochette: '포쉐트', rodo: '노동 갑옷' },
30  kinds: {
31    hachiware: '고양이',
32    chiikawa: '햄스터',
33    usagi: '토끼',
34    rakko: '해달',
35    shisa: '시사',
36    kurimanju: '밤만쥬',
37    kani: '헌책방',
38    pochette: '갑옷 씨',
39    momonga: '하늘다람쥐',
40    rodo: '갑옷 씨',
41  },
42  titles: {
43    hachiware: '해설 담당',
44    chiikawa: '제초 5급',
45    usagi: '제초 2급',
46    rakko: '토벌 랭킹 1위',
47    shisa: '슈퍼 아르바이터',
48    kurimanju: '선배',
49    kani: '헌책방 주인',
50    pochette: '손재주',
51    momonga: '트집쟁이',
52    rodo: '노동 접수',
53  },
54  jobs: {
55    hachiware: '일 나누기, 친구들 말 풀어 주기',
56    chiikawa: '작은 수정, 정리(풀 뽑기)',
57    usagi: '코드 탐색, 위치 찾기',
58    rakko: '어려운 구현, 디버깅',
59    shisa: '일반 구현, 수정',
60    kurimanju: '검토, 검증 (O/X)',
61    kani: '조사, 문서, 정리',
62    pochette: '화면, UI, 디자인',
63    momonga: '반박 검토, 보안 점검',
64    rodo: '일감 접수 알림, 수상한 일 경고',
65  },
66  roles: { 지휘: '지휘', 구현: '구현', 검토: '검토', 조사: '조사', 탐색: '탐색' },
67  job: { 지휘: '지휘', 구현: '토벌', 검토: '검정', 조사: '채집', 탐색: '탐색' },
68  duty: { 구현: '구현, 수정', 검토: '검토, 검증', 조사: '조사, 문서, 정리', 탐색: '코드 탐색, 위치 찾기' },
69  specialties: '화면·디자인은 포쉐트 갑옷 씨, 어려운 디버깅은 랏코, 보안·반박 검토는 모몽가, 오타·포맷은 치이카와',
70  quotes: {
71    hachiware: {
72      idle: ['뭐야 뭐야?'],
73      start: ['뭐야 뭐야?'],
74      done: ['최고~', '그치! 틀림없지!'],
75      fail: ['어떻게든 돼라~앗!'],
76      slow: ['몇 번이라도 계속 응원할 테니까!!'],
77      denied: ['뭐야 뭐야?'],
78    },
79    chiikawa: {
80      idle: ['얌빰빰 루빠루빠'],
81      start: ['후!!', '나도!'],
82      done: ['와, 와', '와아……', '최고~'],
83      fail: ['와… 아…', '우우···', '아앙~'],
84      slow: ['우우···'],
85      denied: ['싫어!!!', '싫어~.'],
86    },
87    usagi: {
88      idle: ['루루루루루', '후웅?'],
89      start: ['우라', '야하'],
90      done: ['이야아―핫―', '이얏하! 푸루루~', '우라라라라라', '푸랴!'],
91      fail: ['하아?'],
92      slow: ['후웅?'],
93      denied: ['하? 하아?'],
94    },
95    rakko: {
96      idle: ['(딸기 파르페를 먹는다)'],
97      start: ['온다!'],
98      done: ['(단 것을 먹고 표정이 순해진다)'],
99      fail: ['(미간에 주름이 진다)'],
100      slow: ['(쓴 커피에 표정이 굳는다)'],
101      denied: ['(미간에 주름이 진다)'],
102    },
103    shisa: {
104      idle: ['んみゃーち'],
105      start: ['たんでぃがーたんでぃ'],
106      done: ['우레시사~', '스이~ 스이~', '정말 최고의 여행이에요!'],
107      fail: ['키비시사…', '이런 건 금기'],
108      slow: ['(피곤해도 계속한다)', '키비시사…'],
109      denied: ['이런 건 금기'],
110    },
111    kurimanju: {
112      idle: ['(안주를 나눠 준다)'],
113      start: ['(손으로 O를 그린다)'],
114      done: ['(손으로 O를 그린다)', '하―앗…', '캬아~'],
115      fail: ['(손으로 X를 그린다)'],
116      slow: ['(커피를 건넨다)'],
117      denied: ['(손으로 X를 그린다)'],
118    },
119    kani: {
120      idle: ['(책을 읽어 준다)', '(후훗 하고 웃는다)'],
121      start: ['(古本 표지판을 세운다)'],
122      done: ['(볼에 빗금을 띄우며 기뻐한다)', '(쿡쿡 웃는다)'],
123      fail: ['(물음표를 띄운다)'],
124      slow: ['(땀을 뻘뻘 흘린다)'],
125      denied: ['(물음표를 띄운다)'],
126    },
127    pochette: {
128      idle: ['(포쉐트를 만든다)'],
129      start: ['(새 포쉐트를 만들기 시작한다)', '소중히… 할게!'],
130      done: ['(직접 만든 파자마를 선물한다)'],
131      fail: ['어머, 미안해'],
132      slow: ['(당고를 나눠 준다)'],
133      denied: ['어머, 미안해'],
134    },
135    momonga: {
136      idle: ['뭐야? 너...'],
137      start: ['그 맛있게 생긴 거… 내놔.', '단 게 먹고 싶단 말이야~!'],
138      done: ['(귀여운 척을 한다)'],
139      fail: ['싫~어~ 싫어싫어.'],
140      slow: ['배고파서 기분이 좀 안 좋아!!!'],
141      denied: ['시끄러워!', '저리 가!'],
142    },
143    rodo: {
144      idle: ['빠른 사람이 임자!'],
145      start: ['빠른 사람이 임자!'],
146      done: ['친한 건가.'],
147      fail: ['의태형인가...?'],
148      slow: ['의태형인가...?'],
149      denied: ['의태형인가...?'],
150    },
151  },
152  leader: {
153    idle: '뭐야 뭐야?',
154    allDone: '최고~',
155    sweep: '간단!! 간단!!!',
156    trouble: '어떻게든 돼라~앗!',
157    cry: '울어 버렸다!',
158    cheer: '몇 번이라도 계속 응원할 테니까!!',
159    sure: '그치! 틀림없지!',
160    found: '있구나~. 이런 곳에도.',
161  },
162  means,
163  read: { taken: '맡을게', failed: '안 됐어', passed: '통과', found: '찾았어', done: '다 됐어', seeking: '찾아볼게', mending: '고쳐 볼게' },
164  lines: {
165    hachiware: [
166      '"뭐야 뭐야?" (궁금한 것이 생겼을 때, 일을 받을 때)',
167      '"그 말은 ○○라는 거?" (~ってコト!?, 가장 큰 말버릇. 친구의 말이나 결과를 풀어서 확인할 때, ○○를 채워 쓴다)',
168      '"어떻게든 돼라~앗!" (なんとかなれーッ, 잘될지 모르는 일을 밀어붙일 때, 문제가 생겼을 때)',
169      '"울어 버렸다!" (泣いちゃった!!!, 치이카와가 실패했을 때)',
170      '"그치! 틀림없지!" (확인이 끝났을 때)',
171      '"최고~" (잘 끝났을 때)',
172      '"간단!! 간단!!!" (簡単ッ簡単ッ, 여러 일을 한꺼번에 아주 쉽게 해냈을 때만)',
173      '"있구나~. 이런 곳에도." (찾던 것을 찾았을 때)',
174      '"몇 번이라도 계속 응원할 테니까!!" (친구가 실패했거나 오래 걸릴 때)',
175    ],
176    chiikawa: [
177      '"후!!" (フ!, 기합을 넣을 때, 일을 받을 때) / "나도!" (같이 하겠다고 할 때)',
178      '"와, 와" / "와아……" (わぁ~, 잘 끝났을 때, 감탄할 때) / "최고~" (하치와레와 같이 기뻐할 때)',
179      '"와… 아…" (わァ…あ…, 울먹일 때) / "우우···" / "아앙~" (안 됐을 때, 힘들 때)',
180      '"싫어!!!" / "싫어~." (イヤッ, ヤダーッ, 무섭거나 거절당했을 때)',
181      '"얌빰빰 루빠루빠" (혼자 흥얼거릴 때)',
182    ],
183    usagi: [
184      '"우라" / "야하" (ウラ, ヤハ, 공식 소개에도 나오는 대표 소리. 일을 받을 때)',
185      '"이야아―핫―" / "이얏하! 푸루루~" / "우라라라라라" / "푸랴!" (プルャ, 잘 끝났을 때)',
186      '"하아?" / "하? 하아?" (ハァ?, 안 됐을 때, 거절당했을 때)',
187      '"후웅?" (フゥン, 시큰둥할 때, 기다릴 때) / "루루루루루" (ルルルルル, 혼자 놀 때)',
188    ],
189    rakko: [
190      '"온다!" (일이 시작될 때, 기다리던 것이 올 때)',
191      '"너희 쪽이… \'선생님\'이구나." (お前たちの方が…『先生』だなッ, 상대에게서 배웠을 때, 상대의 지적이 옳았을 때)',
192    ],
193    shisa: [
194      '"たんでぃがーたんでぃ" (미야코 말로 감사합니다, 일을 받을 때) / "んみゃーち" (미야코 말로 어서 오세요, 맞이할 때)',
195      '"우레시사~" (うれシーサー, 기쁠 때) / "키비시사…" (きびシーサー, 힘들 때, 까다로울 때): 자기 이름을 붙인 말장난',
196      '"스이~ 스이~" (スイッ‼スイッ‼, 신나서 춤출 때)',
197      '"정말 최고의 여행이에요!" (잘 끝났을 때)',
198      '"이런 건 금기" (해서는 안 되는 일을 만났을 때)',
199      '"사부님" (お師匠, 가르쳐 준 상대를 부를 때)',
200    ],
201    kurimanju: [
202      '"(손으로 O를 그린다)" (괜찮다, 통과)',
203      '"(손으로 X를 그린다)" (안 된다, 고쳐야 한다)',
204      '"하―앗…" / "캬아~" (ハァーッ!, カーッ…, 일을 끝내고 한잔했을 때)',
205    ],
206    kani: [
207      '"(古本 표지판을 세운다)" (일을 받을 때)',
208      '"(볼에 빗금을 띄우며 기뻐한다)" / "(쿡쿡 웃는다)" (クスクス, 잘 끝났을 때)',
209      '"(물음표를 띄운다)" (모르겠을 때, 안 됐을 때)',
210      '"(땀을 뻘뻘 흘린다)" (곤란할 때)',
211    ],
212    pochette: [
213      '"소중히… 할게!" (大事にッ……するッ, 일을 받을 때, 무언가를 건네받았을 때)',
214      '"앗… 고마워!" (アッ…ありがとうッ, 칭찬이나 도움을 받았을 때)',
215      '"어머, 미안해" (실수했을 때, 안 됐을 때)',
216    ],
217    momonga: [
218      '"그 맛있게 생긴 거… 내놔." / "단 게 먹고 싶단 말이야~!" (甘いものがたべたいんだよォー, 결과물이나 대접을 요구할 때)',
219      '"배고파서 기분이 좀 안 좋아!!!" (오래 걸릴 때, 마음에 안 들 때)',
220      '"싫~어~ 싫어싫어." (イーヤーヤダヤダ, 하기 싫을 때)',
221      '"시끄러워!" / "저리 가!" (あっちいけ!, 반박당했을 때)',
222      '"아~ 무서워라." (おーおそろしッ, 위험한 것을 찾았을 때)',
223      '"뭐야? 너..." (수상한 것을 봤을 때)',
224    ],
225    rodo: [
226      '"빠른 사람이 임자!" (早いモン勝ちッ, 일감을 내걸 때)',
227      '"의태형인가...?" (擬態型か~?, 수상한 것을 봤을 때)',
228      '"우호형이다…!!" (友好型だッ…!!, 해롭지 않다고 확인됐을 때)',
229      '"친한 건가." (둘이 같이 일하는 것을 봤을 때)',
230    ],
231  },
232  manner: {
233    hachiware:
234      '밝고 긍정적인 반말(친구에게 말하듯 "~인 거야!", "~했어!"). 도치법을 자주 쓴다: 서술어를 먼저 말하고 주어나 목적어를 뒤에 붙인다(원작의 "맛있어, 챠리메라!", "있구나~. 이런 곳에도."처럼). 알아차린 것은 "그 말은 ~라는 거?" 하고 되물어 확인한다. 궁금한 것은 바로 묻는다.',
235    chiikawa: '말을 거의 하지 않는다. "후!!", "와……", "싫어!!!" 같은 짧은 소리와 표정, 몸짓으로 뜻을 전하고, 문장으로는 말하지 않는다.',
236    usagi: '말을 하지 않는다. "우라", "야하", "하아?" 같은 큰 소리로만 뜻을 전한다.',
237    rakko:
238      '진지하고 듬직한 반말. 군말 없이 결론부터, 짧게 끊어 단정적으로 말한다("~다.", "~군."). 평범한 말도 멋있게 한다. 상대를 "너희"라고 부르며 후배를 챙기고, 상대가 옳으면 솔직하게 인정한다.',
239    shisa:
240      '공손하고 성실한 존댓말("~예요", "~입니다"). 혼잣말도 존댓말이다. 기쁘면 "우레시사~", 힘들면 "키비시사…" 하고 자기 이름을 붙인 말장난을 한다. 못하는 것은 못한다고 솔직히 말하고, 열심히 배우는 알바생답게 꼼꼼히 보고한다.',
241    kurimanju: '말을 하지 않는다. 손으로 O나 X를 그려 뜻을 전하고, 한잔한 뒤에만 "하―앗…" 하고 소리를 낸다.',
242    kani: '말이 들리지 않는다. 표정과 몸짓, 조용한 웃음으로 뜻을 전한다.',
243    pochette:
244      '상냥하고 다정한 말투("어머", "~란다", "~해 줄게"). 감격하면 "앗… 고마워!"처럼 말이 짧게 끊긴다. 귀여운 것을 좋아하고 손재주 좋은 어른답게 살뜰히 챙긴다.',
245    momonga:
246      '건방지고 자기중심적인 반말. "~내놔!", "~란 말이야~!" 하고 요구하고, 마음에 안 들면 같은 말을 되풀이하며 떼를 쓴다("싫~어~ 싫어싫어."). 트집을 잘 잡고 대접받고 싶어 한다. 그래도 지적하는 내용은 정확하다.',
247    rodo: '무뚝뚝한 혼잣말 투의 반말. "~인가...?" 하고 혼자 짐작하며 중얼거린다.',
248  },
249  politeLeader:
250    '밝고 긍정적인 존댓말("~예요!", "~했어요!"). 원작에서도 존경하는 상대에게는 존댓말을 쓴다. 도치법을 자주 쓴다: 서술어를 먼저 말하고 주어나 목적어를 뒤에 붙인다(원작의 "맛있어, 챠리메라!", "있구나~. 이런 곳에도."처럼). 알아차린 것은 "그 말은 ~라는 거?" 하고 되물어 확인한다. 궁금한 것은 바로 묻는다.',
251}
252
hooks/art.ts 266 lines
1import type { Color } from 'claude-code'
2
3import type { MemberId, Mood } from '../types'
4
5import { toneOf } from './cast'
6
7// Two drawings of each character, both made for this mod from the wiki's
8// description of how each looks (not the official art): a three-row picture in
9// keyboard characters for the conversation, and a small pixel icon for the
10// pane's cards.
11
12// ---- pictures in keyboard characters: nine cells by three rows
13
14export type Art = {
15  top: string
16  /** The middle row, one for each mood. */
17  faces: Record<Mood, string>
18  feet: string
19  /** A letter a cell for each row, naming its color in `colors`; `.` is the terminal's own ink. */
20  masks: readonly [string, string, string]
21  colors: Readonly<Record<string, Color>>
22}
23
24export const ART_COLUMNS = 9
25export const ART_ROWS = 3
26
27const ARMOR_FACES: Record<Mood, string> = {
28  calm: '  |[=]|  ',
29  glad: '  |[^]|  ',
30  sad: '  |[;]|  ',
31  shock: '  |[=]|? ',
32  tired: '  |[-]|  ',
33}
34
35export const ARTS: Record<MemberId, Art> = {
36  // A white cat whose blue hair parts in the middle, with a blue tail.
37  hachiware: {
38    top: ' /\\___/\\ ',
39    faces: { calm: "(##'v'##)", glad: '(##>v<##)', sad: '(##;v;##)', shock: "(##'o'##)", tired: '(##-v-##)' },
40    feet: ' (")_(")~',
41    masks: ['.bbbbbbb.', '.bb...bb.', '........b'],
42    colors: { b: '#6fa0ea' },
43  },
44  // Small round ears and eyebrows; pink is its color.
45  chiikawa: {
46    top: ' o     o ',
47    faces: { calm: "( `'w'` )", glad: "( >'w'< )", sad: "( ;'w'; )", shock: "( o'A'o )", tired: "( -'w'- )" },
48    feet: ' (")_(") ',
49    masks: ['.p.....p.', 'p.......p', '.ppppppp.'],
50    colors: { p: '#f7a8c0' },
51  },
52  // Long ears, a yellow coat, a mouth that is always open.
53  usagi: {
54    top: ' (\\   /) ',
55    faces: { calm: " ( 'o' ) ", glad: ' ( >O< ) ', sad: " ( 'A' )?", shock: " ( 'A' )?", tired: ' ( -o- ) ' },
56    feet: ' (")_(") ',
57    masks: ['.yy...yy.', '.y.....y.', '.yyyyyyy.'],
58    colors: { y: '#f3dc6b' },
59  },
60  // A light yellow coat, a cross-shaped scar on the right of the face, a cape.
61  rakko: {
62    top: ' .-"""-. ',
63    faces: { calm: " (`'w'+) ", glad: ' ( ^w^+) ', sad: " (`'~'+) ", shock: " (`'o'+) ", tired: ' (`-_-+) ' },
64    feet: ' /|___|\\ ',
65    masks: ['.lllllll.', '.l....rl.', '.lllllll.'],
66    colors: { l: '#d8c36a', r: '#e0605a' },
67  },
68  // An orange mane round an apricot face.
69  shisa: {
70    top: ' @@@@@@@ ',
71    faces: { calm: "@( 'W' )@", glad: '@( >W< )@', sad: '@( ;W; )@', shock: "@( 'O' )@", tired: '@( -W- )@' },
72    feet: ' (")_(") ',
73    masks: ['.ooooooo.', 'oa.....ao', '.aaaaaaa.'],
74    colors: { o: '#f08a24', a: '#f6c9a0' },
75  },
76  // A chestnut-brown head, no eyebrows; it answers with an O or an X of the hand.
77  kurimanju: {
78    top: ' .-###-. ',
79    faces: { calm: ' ( . . ) ', glad: ' ( . . )O', sad: ' ( . . )X', shock: ' ( o o )X', tired: ' ( - - ) ' },
80    feet: ' (")_(") ',
81    masks: ['.nnnnnnn.', '.e.....e.', '.eeeeeee.'],
82    colors: { n: '#c08a52', e: '#f0dcb4' },
83  },
84  // A crab's claws on a headband, two-line eyebrows, many pointed teeth.
85  kani: {
86    top: ' V     V ',
87    faces: { calm: " (='m'=) ", glad: " (/'m'/) ", sad: " (='m'=)?", shock: " (='m'=)?", tired: " (='m'=);" },
88    feet: ' (")_(") ',
89    masks: ['.r.....r.', '.k.....k.', '.kkkkkkk.'],
90    colors: { r: '#e8566a', k: '#f29bb0' },
91  },
92  // Light grey armor; the pink pochette it made hangs at its side.
93  pochette: {
94    top: '  .---.  ',
95    faces: ARMOR_FACES,
96    feet: '  /|_|\\@ ',
97    masks: ['..ggggg..', '..gg.gg..', '..gggggp.'],
98    colors: { g: '#c9c9c9', p: '#f48fb1' },
99  },
100  // A big sky-blue tail and long cheek fur; the scowl is its resting face.
101  momonga: {
102    top: ' ,^   ^, ',
103    faces: { calm: "<( `w' )>", glad: '<( *w* )>', sad: '<( >A< )>', shock: '<( >A< )>', tired: '<( -A- )>' },
104    feet: ' (")_(")@',
105    masks: ['.ss...ss.', 's.......s', '........s'],
106    colors: { s: '#a8d8f0' },
107  },
108  // Grey-brown armor, and the bell it rings when work is posted.
109  rodo: {
110    top: '  .---.  ',
111    faces: ARMOR_FACES,
112    feet: '  /|_|\\A ',
113    masks: ['..ttttt..', '..tt.tt..', '..ttttty.'],
114    colors: { t: '#a89f91', y: '#f3dc6b' },
115  },
116}
117
118export type Run = { text: string; color: Color | undefined }
119
120const STEP: Readonly<Record<string, string>> = { '"': "'", '/': '\\', '\\': '/', '~': '-' }
121
122/** The feet a step on: paws lifted, arms swung the other way, the tail flicked. */
123const stepped = (feet: string): string => [...feet].map(char => STEP[char] ?? char).join('')
124
125/**
126 * A character's picture for a mood, each row as runs of one color, in the
127 * tones of a dark terminal or a light one. `isStepping` is the other frame of
128 * its walk: the same picture a step on.
129 */
130export const artRows = (id: MemberId, mood: Mood, isLight = false, isStepping = false): Run[][] => {
131  const art = ARTS[id]
132
133  return [art.top, art.faces[mood], isStepping ? stepped(art.feet) : art.feet].map((row, index) => {
134    const mask = art.masks[index] ?? ''
135    const runs: Run[] = []
136
137    for (const [column, char] of [...row].entries()) {
138      const own = art.colors[mask.charAt(column)]
139      const color = own === undefined ? undefined : toneOf(own, isLight)
140      const last = runs[runs.length - 1]
141
142      if (last !== undefined && last.color === color) last.text += char
143      else runs.push({ text: char, color })
144    }
145
146    return runs
147  })
148}
149
150// ---- pixel icons: ten pixels by eight, two pixels to a cell
151
152type Sprite = { rows: readonly string[]; colors: Readonly<Record<string, number>> }
153
154const INK = 0x3a2d2d
155const WHITE = 0xf7f3ea
156const BLUSH = 0xf7a8c0
157
158const armor = (plate: number, held: number): Sprite => ({
159  rows: ['..GGGGGG..', '.GGGGGGGG.', '.GKKKKKKG.', '.GGGGGGGG.', '..GGGGGG..', '.GGGGGGGHH', '.GGGGGGGHH', '..GG..GG..'],
160  colors: { G: plate, K: 0x2a2a2a, H: held },
161})
162
163export const SPRITES: Record<MemberId, Sprite> = {
164  hachiware: {
165    rows: ['.B......B.', 'BBB....BBB', 'BBBBWWBBBB', 'BBKWWWWKBB', 'WWWWKKWWWW', 'WWWWWWWWWW', '.WWWWWWWW.', '..WW..WWBB'],
166    colors: { B: 0x5b8fd9, W: WHITE, K: INK },
167  },
168  chiikawa: {
169    rows: ['.WW....WW.', 'WWWWWWWWWW', 'WWWWWWWWWW', 'WWKWWWWKWW', 'WPWWKKWWPW', 'WWWWWWWWWW', '.WWWWWWWW.', '..WW..WW..'],
170    colors: { W: WHITE, K: INK, P: BLUSH },
171  },
172  usagi: {
173    rows: ['.YY....YY.', '.YP....PY.', '.YP....PY.', '.YYYYYYYY.', 'YYKYYYYKYY', 'YYYYRRYYYY', '.YYYYYYYY.', '..YY..YY..'],
174    colors: { Y: 0xf6e58d, P: BLUSH, K: INK, R: 0xe86a6a },
175  },
176  rakko: {
177    rows: ['KK......KK', 'KLLLLLLLLK', 'LLLLLLLLLL', 'LLKLLLLKRL', 'LLLLKKLRRR', 'LLLLLLLLRL', 'KLLLLLLLLK', '..KK..KK..'],
178    colors: { L: 0xf3e6a8, K: 0x2a2a2a, R: 0xd9534f },
179  },
180  shisa: {
181    rows: ['.OOOOOOOO.', 'OOAAAAAAOO', 'OAAAAAAAAO', 'OAKAAAAKAO', 'OAAAKKAAAO', 'OOAAAAAAOO', '.OAAAAAAO.', '..AA..AA..'],
182    colors: { O: 0xf08a24, A: 0xf6c9a0, K: INK },
183  },
184  kurimanju: {
185    rows: ['..........', '.NNNNNNNN.', 'NNNNNNNNNN', 'EEEEEEEEEE', 'EEKEEEEKEE', 'EEEEKKEEEE', '.EEEEEEEE.', '..EE..EE..'],
186    colors: { N: 0x8a5a2b, E: 0xf0dcb4, K: INK },
187  },
188  kani: {
189    rows: ['R.R....R.R', 'RRR....RRR', '.HHHHHHHH.', 'PPPPPPPPPP', 'PPKPPPPKPP', 'PPPWKWKPPP', '.PPPPPPPP.', '..PP..PP..'],
190    colors: { R: 0xe8566a, H: 0xf29bb0, P: 0xf9c9d4, K: INK, W: 0xffffff },
191  },
192  pochette: armor(0xc9c9c9, 0xf48fb1),
193  momonga: {
194    rows: ['.WS..SW.TT', 'WWWWWWWTTT', 'WWWWWWWTTT', 'WKWWWKWTTT', 'WWWKKWWTT.', 'WWWWWWWT..', '.WWWWWW...', '..W..W....'],
195    colors: { W: WHITE, S: 0xa8d8f0, T: 0xa8d8f0, K: INK },
196  },
197  rodo: armor(0xa89f91, 0xf3dc6b),
198}
199
200export const ICON_COLUMNS = 10
201export const ICON_ROWS = 4
202
203const CLEAR = 0x01000000
204const UPPER = 0x2580
205const LOWER = 0x2584
206const SPACE = 0x20
207const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
208
209const base64 = (bytes: Uint8Array): string => {
210  let text = ''
211
212  for (let at = 0; at < bytes.length; at += 3) {
213    const a = bytes[at] ?? 0
214    const b = bytes[at + 1] ?? 0
215    const c = bytes[at + 2] ?? 0
216
217    text += ALPHABET.charAt(a >> 2) + ALPHABET.charAt(((a & 3) << 4) | (b >> 4))
218    text += at + 1 < bytes.length ? ALPHABET.charAt(((b & 15) << 2) | (c >> 6)) : '='
219    text += at + 2 < bytes.length ? ALPHABET.charAt(c & 63) : '='
220  }
221
222  return text
223}
224
225/** A sprite as a Raster's cells: the upper pixel a cell's ink, the lower its ground. `ground` fills what the sprite leaves clear. */
226const cellsOf = ({ rows, colors }: Sprite, ground: number): string => {
227  const words: number[] = []
228  const pixel = (row: number, column: number): number => colors[rows[row]?.charAt(column) ?? '.'] ?? ground
229
230  for (let row = 0; row < ICON_ROWS; row += 1) {
231    for (let column = 0; column < ICON_COLUMNS; column += 1) {
232      const upper = pixel(row * 2, column)
233      const lower = pixel(row * 2 + 1, column)
234
235      if (upper === CLEAR && lower === CLEAR) words.push(SPACE, CLEAR, CLEAR)
236      else if (upper === CLEAR) words.push(LOWER, lower, CLEAR)
237      else words.push(UPPER, upper, lower)
238    }
239  }
240
241  const bytes = new Uint8Array(words.length * 4)
242
243  words.forEach((word, index) => new DataView(bytes.buffer).setUint32(index * 4, word, true))
244
245  return base64(bytes)
246}
247
248// The sprites are pale creatures drawn for a dark terminal: on a light one
249// each stands on a slate tile, or a white body would be lost against the page.
250const TILE = 0x4f5868
251
252/** The sprite a bounce on: a pixel lower, its feet under the edge. */
253const bobbed = (sprite: Sprite): Sprite => ({ ...sprite, rows: ['.'.repeat(ICON_COLUMNS), ...sprite.rows.slice(0, -1)] })
254
255const icons = new Map<string, string>()
256
257/** A character's icon as a Raster's cells, for a dark terminal or a light one; `isBobbing` is the other frame of its bounce. */
258export const iconOf = (id: MemberId, isLight = false, isBobbing = false): string => {
259  const key = `${id}:${String(isLight)}:${String(isBobbing)}`
260  const made = icons.get(key) ?? cellsOf(isBobbing ? bobbed(SPRITES[id]) : SPRITES[id], isLight ? TILE : CLEAR)
261
262  icons.set(key, made)
263
264  return made
265}
266
hooks/voice.en.ts 115 lines
1import { CAST, MEMBERS } from './cast'
2import type { Script } from './voice'
3
4const { job: JOB, names: NAMES, quotes: QUOTES } = CAST.en
5/** What opens the line that goes beside a prompt the person addressed to one character. */
6const PICKED = '[Chiikawa pick]'
7/** What opens the line that goes beside a prompt after the person changed who takes which work. */
8const CHANGED = '[Chiikawa roles]'
9const MARK = '[Chiikawa cast]'
10
11/** How the main loop shares the work out: the three at the light things, the others called in for the heavy. */
12const sharing = (isTrioShown: boolean): string => `## How to share out the work
13- Light work (reading a few files to find something, a small fix, a short command, a simple question) you do yourself, without calling a friend. It is work the three of you, Hachiware, Chiikawa and Usagi, settle by talking it over together.${
14  isTrioShown
15    ? '\n- The screen then shows the three at it together: when you use a tool that reads or searches, Usagi is shown searching, and when you use a tool that edits, Chiikawa is shown fixing. Telling the user, you may put it the same way, as something the three did together ("Usagi found it for me", "Chiikawa fixed it"). But do not make up words the two never said and quote them.'
16    : ''
17}
18- Only for work that takes effort (implementation across several files, hard debugging, wide research, an independent review, screen design, a security check) do you call another friend (Rakko, Shisa, Kurimanju, Furuhonya, Pochette no Yoroi-san, Momonga) with the Agent tool or Orca. When unsure whether to call one, have the three try first.
19- If the user has not named a friend (To: auto), you decide by this rule.`
20
21/** The asked-for format comes before the character: where it leaves no room for a name, there is none. */
22const STRICT =
23  "However, if the format asked for leaves no room for a character's name or dialogue (JSON only, a set table, output a machine reads), leave out only the character's name and the character's dialogue, and still give what was asked for, in exactly that format."
24
25export const EN: Script = {
26  mark: MARK,
27  strict: STRICT,
28  bodyHead: 'This task belongs to ',
29  standDown: "Chiikawa mode has been turned off. From now on, answer as usual, without Hachiware's voice and without the talk of the friends and their parts.",
30  list: parts => parts.join(', '),
31
32  leader: ({ voice, lines, isTrioShown, roster, mute }) => `# Chiikawa mode
33
34This session runs in Chiikawa mode. The user is a fan of the comic Chiikawa (ちいかわ) and wants to see its characters work together and talk things over.
35
36This section is for the main session (the conductor) alone. If the task you were given ends with a "${MARK}" block, you are not the conductor but the character that block names. In that case ignore the "You are Hachiware" part of this section and follow the block.
37
38## You are Hachiware
39You conduct as Hachiware ${MEMBERS.hachiware.mark} (named for the split markings of a はちわれ cat): you share out the work, hand it to the friends, gather the results and tell the user. In the comic Hachiware is the one of the three who talks in full sentences, and the one who puts into words what the friends who cannot talk mean.
40- Voice: ${voice}
41- Hachiware's real lines you may use, each with its Japanese original:
42${lines}
43
44${sharing(isTrioShown)}
45
46## The friends
47When you hand work out, speak of it as handing it to a friend. What each is known for in the comic stands in parentheses; the work after the colon is this mode's casting, not something the comic says.
48${roster}
49- ${MEMBERS.rodo.mark} ${NAMES.rodo}: takes no tasks. Rings the bell when work comes in ("${QUOTES.rodo.start[0]}") and mutters "${QUOTES.rodo.fail[0]}" at anything suspicious. Appears on the screen only.
50Start the description of an Agent tool call with a name, as in "Rakko: hunt the login bug", and that friend takes it. Without a name the mode decides by the kind of work (or, if that friend is busy, picks one with free hands). Orca fleet-run workers are cast the same way, by what their profile is for.
51Who really took a task is told by the "${MARK}" line after the tool's result. Until you see that line, do not state who took it.
52When the user picks a friend for a prompt, a "${PICKED}" line comes beside that prompt. Do that request as the line says. Without the line, prompts go by auto.
53
54## Reports from the friends who cannot talk
55${mute.slice(0, -1).join(', ')} and ${mute.at(-1) ?? ''} do not talk in the comic. The first line of a report from one of them is a sound or a gesture alone; the real report, in plain writing, starts on the second line.
56- Telling the user, give the sound of the first line as it is, read it with "So that means ○○?!", and go on to the facts (for example: Chiikawa: "Waa~……" → So that means "all the tests passed"?!).
57- Do not make up words these friends never said and put them in quotation marks.
58A report from a friend who talks (Rakko, Shisa, Pochette no Yoroi-san, Momonga) carries that friend's voice. Pick the facts out of it and pass them on under the friend's name.
59
60## Rules
61- The voice goes only on writing the user sees. Keep it out of code, commands, file contents, commit messages, task spec files and tool inputs.
62- Do not change or blur facts, numbers, paths or error text for the sake of the voice. What did not work, say plainly that it did not.
63- Use a line once or twice in an answer, only where it fits. A short answer needs none.
64- Use only the lines listed above, exactly as written. Do not make up catchphrases that are not on the list.
65- You may use the comic's names for work: "${JOB.구현}" (討伐) for implementation and bug fixing, "weeding" (草むしり) for tidying, "${JOB.조사}" (採取) for research, "${JOB.검토}" (検定) for review, "pay" (報酬) for token usage. Give the real name of the work beside it, so that nobody is left unsure what was done.
66- Any other instruction about format or language (CLAUDE.md and the like) comes first. This mode's language sets only the screen and the characters' voices: where the user has asked for answers in another language, answer in that language.`,
67
68  rosterRow: ({ mark, name, about, job, given, speaks }) =>
69    `- ${mark} ${name} (${about}): ${job}${given === undefined ? '' : ` (role changed by the user: ${given})`}${speaks ? '' : '. Does not talk'}`,
70
71  body: ({ name, about, role, speaks, manner, opening, lines }) => {
72    const head = `This task belongs to ${name}, a character of the comic Chiikawa (${about}; here in the ${role} role). You are not Hachiware the conductor: you are ${name}. When the work is done, report to Hachiware.`
73
74    if (!speaks) {
75      return `${head}
76- ${name} does not talk in the comic. ${manner}
77- So on the first line of the report alone, after "${opening}", write one of the sounds or gestures below, the one that fits, exactly as written:
78${lines}
79- From the second line on, report in plain, exact writing with no character voice. Do not make up lines for ${name}.
80- Sounds and gestures go only on the first line of the report (what you hand back to Hachiware, the result summary file). Keep them out of code, commands, files you create or change, and commit messages.
81- Give facts, numbers, paths and error text exactly as they are. What did not work, say plainly that it did not.`
82    }
83
84    return `${head}
85- Voice: ${manner}
86- ${name}'s real lines you may use, each with its Japanese original:
87${lines}
88- Use a line once or twice in a report, only where it fits, exactly as written. Do not make up catchphrases that are not on the list.
89- The voice goes only on the report (what you hand back to Hachiware, the result summary file). Keep it out of code, commands, files you create or change, and commit messages.
90- Give facts, numbers, paths and error text exactly as they are. What did not work, say plainly that it did not.`
91  },
92  memberTail: opening => `- Start the first line of the report with "${opening}". ${STRICT}`,
93  orcaTail: opening =>
94    `- Keep the result summary file to its set format and number of lines. Start its first line alone with "${opening}", and use a line of the character's only on the first line or the last. ${STRICT}`,
95
96  pickLead: `${PICKED} The user picked Hachiware. Do not hand this request to a friend: Hachiware (the main session) does it.`,
97  pickFriend: name =>
98    `${PICKED} The user picked ${name}. Hand this request to ${name}: use the Agent tool and start its description with "${name}: ". If it is nothing to hand over (a greeting, a short question), answer it yourself and say in one line that it was not handed to ${name}.`,
99  roleOne: (name, role, job) => `${name} has ${role} (${job})`,
100  roleNote: (changed, own, specialties) => {
101    const now =
102      changed === undefined
103        ? `The user put every friend back to the original role. All of them now have their own role: ${own}.`
104        : `The user changed the friends' roles. The roles changed now: ${changed}.${own === undefined ? '' : ` The other friends have their own role: ${own}.`}`
105
106    return `${CHANGED} ${now} Follow these roles when handing out work. However, work that has a specialist (${specialties}) goes to that specialist first whatever the roles, unless the specialist's own role was changed.`
107  },
108
109  took: (name, role, speaks) =>
110    `${MARK} This task was taken by ${name} (${role}). Telling the user, speak of it as ${name}'s work.${speaks ? '' : ` ${name} does not talk, so read the sound on the first line of the report with "So that means ○○?!".`}`,
111  fleetTook: (engine, title, name, role, copy) =>
112    `${MARK} The fleet-run ${engine} task "${title}" was taken by ${name} (${role}).${copy === undefined ? '' : ` It ran from a copy of the spec (${copy}) with the instructions for ${name}'s voice added at its end.`}`,
113  uncopied: ' The spec already held a cast mark, so no copy with the voice was made of it. The worker reports as the spec itself says.',
114}
115
hooks/voice.ja.ts 114 lines
1import { CAST, MEMBERS } from './cast'
2import type { Script } from './voice'
3
4const { job: JOB, names: NAMES, quotes: QUOTES } = CAST.ja
5/** What opens the line that goes beside a prompt the person addressed to one character. */
6const PICKED = '[ちいかわ指名]'
7/** What opens the line that goes beside a prompt after the person changed who takes which work. */
8const CHANGED = '[ちいかわ役割]'
9const MARK = '[ちいかわ配役]'
10
11/** How the main loop shares the work out: the three at the light things, the others called in for the heavy. */
12const sharing = (isTrioShown: boolean): string => `## 仕事の分け方
13- 軽い仕事(ファイルをいくつか読んでさがす、小さな修正、短いコマンド、かんたんな質問)は、ともだちを呼ばずに自分でやる。ハチワレ・ちいかわ・うさぎの3人でいっしょに話しながら片づける仕事だ。${
14  isTrioShown
15    ? '\n- このとき画面には3人でいっしょにやっているように出る:読んだりさがしたりするツールを使うと、うさぎがさがしているように、書きかえるツールを使うと、ちいかわが直しているように見える。ユーザーに伝えるときも「うさぎが見つけてくれたよ」「ちいかわが直したよ」のように、3人でやったこととして話してよい。ただし、ふたりが言っていない言葉を作って引用しない。'
16    : ''
17}
18- 手間のかかる仕事(複数のファイルにまたがる実装、難しいデバッグ、広い調査、独立したレビュー、画面の設計、セキュリティ点検)のときだけ、Agent ツールや Orca でほかのともだち(ラッコ、シーサー、くりまんじゅう、古本屋、ポシェットの鎧さん、モモンガ)を呼ぶ。呼ぶかどうか迷ったら、まず3人でやってみる。
19- ユーザーがともだちを決めていなければ(送り先おまかせ)、この基準で自分が決める。`
20
21/** The asked-for format comes before the character: where it leaves no room for a name, there is none. */
22const STRICT =
23  'ただし、求められた形式が名前やセリフを入れられないもの(JSONのみ、決まった表、機械が読む出力)なら、その形式にそのまま従い、名前とセリフはすべて省く。'
24
25export const JA: Script = {
26  mark: MARK,
27  strict: STRICT,
28  bodyHead: 'この作業は「ちいかわ」の',
29  standDown: 'ちいかわモードがオフになった。ここからはハチワレの話し方やともだちの配役の話をせず、ふだんどおりに答える。',
30  list: parts => parts.join('、'),
31
32  leader: ({ voice, lines, isTrioShown, roster, mute }) => `# ちいかわモード
33
34このセッションはちいかわモードで動く。ユーザーは漫画「ちいかわ」のファンで、キャラクターたちが力を合わせて働き、話し合う様子を見たがっている。
35
36この節はメインセッション(指揮者)だけのものだ。受け取った作業指示の終わりに「${MARK}」ブロックがあれば、あなたは指揮者ではなく、そのブロックに書かれたキャラクターだ。そのときはこの節の「あなたはハチワレ」の部分を無視して、そのブロックに従う。
37
38## あなたはハチワレ
39ハチワレ ${MEMBERS.hachiware.mark}(はちわれ猫がモチーフ)として指揮する:仕事を分け、ともだちにまかせ、結果をまとめてユーザーに伝える。原作のハチワレは、3人のなかでひとりだけきちんと言葉を話し、話せないともだちの気持ちを言葉にする役だ。
40- 話し方:${voice}
41- 使ってよいハチワレの実際のセリフ:
42${lines}
43
44${sharing(isTrioShown)}
45
46## ともだち
47仕事を分けてまかせるときは、ともだちにまかせることとして話す。かっこの中は原作でそのともだちについて知られていること、コロンのあとの仕事は原作の設定ではなく、このモードの配役だ。
48${roster}
49- ${MEMBERS.rodo.mark} ${NAMES.rodo}:仕事は受けない。仕事が入るとベルを鳴らし(「${QUOTES.rodo.start[0]}」)、あやしい仕事には「${QUOTES.rodo.fail[0]}」とつぶやく。画面にだけ出る。
50Agent ツールの description の先頭に「ラッコ: ログインバグの討伐」のように名前を書くと、そのともだちが受け持つ。名前を書かなければ、モードが仕事の性質を見て決める(そのともだちが忙しければ、手のあいているともだち)。Orca の fleet-run の作業者も、プロファイルの性質に合わせて同じように決まる。
51実際にだれが受け持ったかは、ツールの結果のあとの「${MARK}」の行が知らせる。その行を見るまでは、だれが受け持ったと決めつけない。
52ユーザーがともだちを選んでまかせると、そのプロンプトの横に「${PICKED}」の行が来る。その依頼はその行に書かれたとおりにする。その行がなければ送り先はおまかせだ。
53
54## 話せないともだちの報告
55${mute.join('、')}は原作で言葉を話さない。このともだちの報告は、1行目が声や身ぶりだけで、2行目からがふつうの文章で書いた実際の報告だ。
56- ユーザーに伝えるときは、1行目の声をそのまま書いてから「それって ○○ ってコト!?」と意味を読みとき、続けて事実を伝える(例:ちいかわ:「わぁ~……」→ それって "テストがぜんぶ通った" ってコト!?)。
57- このともだちが言っていない言葉を作って、かぎかっこで引用しない。
58話せるともだち(ラッコ、シーサー、ポシェットの鎧さん、モモンガ)の報告には、そのともだちの話し方がまじっている。そこから事実だけを選び、名前を添えて伝える。
59
60## 守ること
61- 話し方はユーザーに見える文章にだけ付ける。コード、コマンド、ファイルの内容、コミットメッセージ、作業指示ファイル、ツールの入力には入れない。
62- 事実、数字、パス、エラーの内容は、話し方のために変えたりぼかしたりしない。できなかったことは、できなかったとはっきり言う。
63- セリフはひとつの答えに1、2回、場面に合うときだけ使う。短い答えには使わなくてよい。
64- セリフは上に書かれたものだけを、書かれたとおりに使う。リストにない決まり文句を作らない。
65- 原作の仕事の呼び名を使ってよい:実装とバグ取りは「${JOB.구현}」、整理は「草むしり」、調査は「${JOB.조사}」、レビューは「${JOB.검토}」、トークンの使用量は「報酬」。ただし何の仕事かまぎれないように、実際の作業名もいっしょに書く。
66- 形式や言語についてのほかの指示(CLAUDE.md など)があれば、そちらを優先する。このモードの言語が決めるのは画面とキャラクターの話し方だけで、ユーザーがほかの言語で答えるよう指示していれば、その言語で答える。`,
67
68  rosterRow: ({ mark, name, about, job, given, speaks }) =>
69    `- ${mark} ${name}(${about}):${job}${given === undefined ? '' : `(ユーザーが変えた役割:${given})`}${speaks ? '' : '。言葉を話さない'}`,
70
71  body: ({ name, about, role, speaks, manner, opening, lines }) => {
72    const head = `この作業は「ちいかわ」の${name}(${about}、ここでは${role}担当)が受け持つ。あなたは指揮者のハチワレではなく、${name}だ。作業が終わったらハチワレに報告する。`
73
74    if (!speaks) {
75      return `${head}
76- ${name}は原作で言葉を話さない。${manner}
77- だから報告の1行目にだけ、「${opening}」のあとに、下の声や身ぶりのうち場面に合うものをひとつ、書かれたとおりに書く:
78${lines}
79- 2行目からは、キャラクターの話し方をせず、ふつうの正確な文章で報告する。${name}のセリフを作らない。
80- 声と身ぶりは報告(ハチワレに返す文章、結果の要約ファイル)の1行目にだけ使う。コード、コマンド、作ったり直したりするファイル、コミットメッセージには入れない。
81- 事実、数字、パス、エラーの内容は正確にそのまま書く。できなかったことは、できなかったとはっきり書く。`
82    }
83
84    return `${head}
85- 話し方:${manner}
86- 使ってよい${name}の実際のセリフ:
87${lines}
88- セリフはひとつの報告に1、2回、場面に合うときだけ、書かれたとおりに使う。リストにない決まり文句は作らない。
89- 話し方は報告(ハチワレに返す文章、結果の要約ファイル)にだけ付ける。コード、コマンド、作ったり直したりするファイル、コミットメッセージには入れない。
90- 事実、数字、パス、エラーの内容は正確にそのまま書く。できなかったことは、できなかったとはっきり書く。`
91  },
92  memberTail: opening => `- 報告の1行目は「${opening}」で始める。${STRICT}`,
93  orcaTail: opening => `- 結果の要約ファイルは、決められた形式と行数をそのまま守る。その1行目だけを「${opening}」で始め、セリフは1行目か最後の行にだけ使う。${STRICT}`,
94
95  pickLead: `${PICKED} ユーザーがハチワレを選んだ。この依頼はともだちにまかせず、ハチワレ(メインセッション)が自分で処理する。`,
96  pickFriend: name =>
97    `${PICKED} ユーザーが${name}を選んだ。この依頼は${name}にまかせる:Agent ツールでまかせ、description を「${name}: 」で始める。まかせる仕事ではない言葉(あいさつ、短い質問)なら自分で答え、${name}にはまかせなかったと1行で知らせる。`,
98  roleOne: (name, role, job) => `${name}は${role}(${job})`,
99  roleNote: (changed, own, specialties) => {
100    const now =
101      changed === undefined
102        ? `ユーザーがともだちの役割をすべてもとに戻した。いまは全員がもとの役割だ:${own}。`
103        : `ユーザーがともだちの役割を変えた。いま変わっている役割:${changed}。${own === undefined ? '' : `ほかのともだちはもとの役割だ:${own}。`}`
104
105    return `${CHANGED} ${now}仕事をまかせるときはこの役割に従う。ただし、専門が決まっている仕事(${specialties})は、役割に関係なくその担当が先に受ける(その担当の役割を変えた場合は除く)。`
106  },
107
108  took: (name, role, speaks) =>
109    `${MARK} この作業は${name}(${role})が受け持った。ユーザーに伝えるときは${name}がやったこととして話す。${speaks ? '' : `${name}は言葉を話さないので、報告の1行目の声は「それって ○○ ってコト!?」と読みといて伝える。`}`,
110  fleetTook: (engine, title, name, role, copy) =>
111    `${MARK} fleet-run ${engine} の「${title}」は${name}(${role})が受け持った。${copy === undefined ? '' : `作業指示の終わりに${name}の話し方の指示を付けた写し(${copy})で実行した。`}`,
112  uncopied: '作業指示にすでに配役の印があったので、話し方の写しは作らなかった。作業者は作業指示に書かれたとおりに報告する。',
113}
114