SLOPSHOPPER

moon-band

The moon's phase and a line of poetry above the prompt: a little decoration for the window.

newbandcommandtoasttimer
v0.1.0no licenseupdated 2026-10-15zhuobichen/zhaowen-claudemod/moon-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · moon-band
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────╮ │ moon-band │ ⏺ Read(src/auth.ts) │ moon-band: data/poems.txt 没读到,先用内置 8 句 │ ⎿ Read 6 lines ╰────────────────────────────────────────╯ ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /moon ⎿ moon-band: moon line hidden ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

moon-band

提示行上面一行月光:月相 + 月龄 + 一句古诗,整行压暗。纯装饰,/moon 可以收起来。

🌒 娥眉月 4.6天 · 「今人不见古时月,今月曾经照古人。」李白《把酒问月》

装

/plugin install moon-band --marketplace zhuobichen/zhaowen-claudemod

月相与月龄是从哪来的(这段重要)

月相名:不是用"平均朔望月"糊出来的。实测:经典的平均公式拿 2026 年 10 月的四个相时刻去对,误差最大到 −0.72 天(近 17 小时);就算把锚点换成 10-10 那次新月、再扫周期参数,最坏误差仍有 0.75 天——因为月亮走得本来就不匀,两参数模型不可能更准。而背错 Meeus 的系数表会得到一个"看着挺准其实全错"的月亮,那更糟。

所以用的是一张相时刻表 + 相邻两时刻线性插值:

  • 数据来源:StarWalk 月相日历、香港天文學會 2026 年月相表(两处一致,差 ±1 分钟),北京时间
  • 覆盖 2026-10-03 → 2026-12-31(13 个时刻,严格四相轮转,间隔 6.7–8.2 天,朔望月 29.63/29.74 天)
  • 表内:准到分钟(相位在两时刻之间按线性推进,所以离边界几小时内的相名可能有几小时的出入)
  • 表外:退回平均朔望月推算,并且行首显示 ≈,告诉你这是个估计,不装成事实

月龄:是距上一次新月的真实天数,不是"相位占比 × 平均月长"。后者会把月龄算小——这个朔望月偏长(新月→上弦 8.0 天),占比算法今天会报 4.2 天,而农历是 4.6 天。农历日期是你能自己核的,所以这个数必须对得上它。

要延长覆盖,就往 hooks/moon.ts 的 INSTANTS 里整段追加相时刻(必须保持四相轮转、不可乱序,新月靠"从第一项起每第四项"定位——有一条测试盯着这个)。表是给 2026-10-03 起的连续四相用的,中间不能跳。

那句诗

每分钟换一句,从一个 46167 句的库(随 mod 一起分发)里挑,一轮不重复。

库在 data/poems.txt:唐诗 3 万句 + 宋词 1.6 万句,每行 诗句<TAB>作者《篇名》,3.18 MiB($.fs.read 的硬上限是 4 MiB)。来源与生成方式见 data/NOTICE.md,复现脚本在仓库根的 tools/build_poems.py。

怎么做到"轮完一遍才回头":用一条满周期线性同余——index = (step * slot + offset) mod N,其中 step 与 N 互质。这是个双射,所以连续 N 个格子会把每一句都取到一次、且只取一次,走完 N 格才重复;step/offset 每个周期重新取,所以下一轮是另一种顺序,不是同一轮重播。

  • 46167 句 / 每分钟一句 → 一轮约 32 天
  • 同一分钟内不变(画面之间跳句没法读)
  • 一个周期内,相邻两分钟不会是同一句(step 与 N 互质且不等于 1)
  • 周期交界处理论上可能与前一句相同(概率 1/N,可忽略)

读数不靠状态:$.store 里只存"要不要显示"这一个开关。

想换库

data/poems.txt 每行 诗句<TAB>来源 即可,缺一半的行、重复的句子会被丢掉。注意 4 MiB 上限(超了 $.fs.read 直接拒绝,mod 会退回内置 8 句并在屏幕上弹一句提示——不会假装在用你的库)。

内置那 8 句只在库读不到时兜底:李白《把酒问月》/《月下独酌》/《静夜思》、张九龄《望月怀远》、张若虚《春江花月夜》、王维《竹里馆》、苏轼《水调歌头》、李商隐《嫦娥》。

图标

用 emoji 月相(🌑🌒🌓🌔🌕🌖🌗🌘,八相一一对应)。渲染不好就换单色字形一族(◐◑◒◓ 那套),改 hooks/moon.ts 的 GLYPHS 一行。

和别的 meter 共存

它挂在 ui.render{component:'AbovePrompt'},和 Context View、deepseek-balance 是同一个位置。所以它 await next(e) 把下层画的树包住再画自己那行——直接返回自己的树会把别人那行吞掉。

开关

/moon 收起/放回,记在 $.store 里,跨会话保留。

文件

.claude-plugin/plugin.json  名字、版本、类型契约的位置
hooks/hooks.json            模块清单
hooks/register.tsx          挂载、/moon、读语料、每 20 秒刷新一次(诗一分钟一换,刷新是为了它不迟到)
hooks/moon.ts               相时刻表、插值、八相命名、月龄、语料解析与选句(纯函数,测试盯的就是它)
data/poems.txt              语料本体(46167 行)
data/NOTICE.md              语料来源、许可、转简说明、4 MiB 上限
types/index.d.ts            $.state 里那个开关的类型契约
tests/moon.test.tsx         23 条:表是不是严格四相轮转、插值是否落在发布时刻上、月龄是否等于距新月的真实天数、
                            表外是否承认自己在估、一个周期是否正好走遍且不重、下一周期换顺序、
                            语料解析(缺一半/重复行要丢)、真的从语料里取句画出来、
                            不吞下面那行、/moon 能收起放回
Source 3 files
hooks/register.tsx 123 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import { lineOf, poemsOf, POEMS } from './moon'
5import type { Poem } from './moon'
6
7// moon-band: the moon's phase and a line of poetry as one dim row above the
8// prompt. Nothing here is load-bearing — it is decoration, and `/moon` takes it
9// away for a while when a screen of prose is wanted instead.
10//
11// It wraps what `next(e)` resolved to rather than drawing the row itself, so
12// whatever else draws at this spot (the context meter, the balance meter) is
13// kept underneath it.
14
15/**
16 * Twenty seconds against a one-minute slot: the line turns over within twenty
17 * seconds of the minute rather than up to a minute late. A redraw is cheap and
18 * the engine folds sooner ones.
19 */
20const REFRESH_MS = 20 * 1000
21const HIDDEN_KEY = 'isHidden'
22const CORPUS_PATH = 'data/poems.txt'
23
24const isHidden = atom({ plugin: 'moon-band', key: 'isHidden' } as const, false)
25
26/** Runs `work`, and answers null when it throws. */
27const quietly = async <T,>(work: () => Promise<T>): Promise<T | null> => {
28  try {
29    return await work()
30  } catch {
31    return null
32  }
33}
34
35let corpus: Poem[] | null = null
36
37/**
38 * The corpus the mod ships, read once. It is the whole point of the thing, so a
39 * read that fails — or comes back with less than the built-ins would have given —
40 * says so out loud and falls back: a quietly smaller corpus would look exactly
41 * like a working one.
42 */
43const loadCorpus = async ($: EngineInterface): Promise<Poem[]> => {
44  if (corpus !== null) return corpus
45
46  const text = await quietly(() => $.fs.read(`${$.plugin.root}/${CORPUS_PATH}`))
47  const read = text === null ? [] : poemsOf(text)
48
49  if (read.length > POEMS.length) {
50    corpus = read
51
52    return corpus
53  }
54
55  try {
56    $.ui.toast(`moon-band: ${CORPUS_PATH} 没读到,先用内置 8 句`)
57  } catch {
58    // the toast is a courtesy; the fallback still holds
59  }
60
61  return POEMS
62}
63
64export const register: Register = on => {
65  let tick: Timer | null = null
66
67  on('session.start', async ($, e, next) => {
68    const result = await next(e)
69
70    await quietly(() => $.command.register({ name: 'moon', description: 'Hide or show the moon line (moon-band)' }))
71    await loadCorpus($)
72
73    const wasHidden = await quietly(() => $.store.get(HIDDEN_KEY))
74
75    if (wasHidden === true) await update($, isHidden, () => true)
76
77    if (tick === null) {
78      try {
79        // keeps a long session's moon and line from going stale
80        tick = $.clock.every(REFRESH_MS, () => {
81          try {
82            $.ui.invalidate('ui.render')
83          } catch {
84            // the next redraw takes it
85          }
86        })
87      } catch {
88        // without the tick the line still redraws on every turn
89      }
90    }
91
92    return result
93  })
94
95  on('command.run', { command: 'moon' }, async ($, e, next) => {
96    const wasHidden = await read($, isHidden)
97
98    await update($, isHidden, () => !wasHidden)
99    await quietly(() => $.store.set(HIDDEN_KEY, !wasHidden))
100
101    return { text: wasHidden ? 'moon line shown' : 'moon line hidden' }
102  })
103
104  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
105    if (e.props.hasSurvey) return next(e)
106    if (await read($, isHidden)) return next(e)
107
108    const { Box, Text } = $.ui.resolve(e)
109    const nowMs = (await quietly(() => $.clock.now())) ?? Date.now()
110    const lines = await loadCorpus($)
111    const inner = await next(e)
112
113    return (
114      <Box flexDirection="column">
115        {inner}
116        <Text dimColor wrap="truncate-end">
117          {lineOf(nowMs, lines)}
118        </Text>
119      </Box>
120    )
121  })
122}
123
hooks/moon.ts 249 lines
1// The moon and the poem, with no engine calls in them, so a test can hold them
2// to account without a session.
3//
4// The phase name is read off a table of published phase instants rather than
5// from a mean-synodic-month formula, because that formula is not good enough to
6// say a phase name: checked against October 2026's four instants it is out by up
7// to 0.72 days, and no two-parameter fit does better than 0.75 — the Moon's
8// motion is genuinely uneven.
9//
10// The age is not `fraction × mean month` either: it is the days since the last
11// new moon the table knows, which is the number anyone can check against a
12// lunar-calendar date.
13
14/**
15 * The phases, Beijing time (UTC+8), from StarWalk's phase calendar and the Hong
16 * Kong Astronomical Society's 2026 table, which agree to a minute.
17 *
18 * **A strict quarter cycle**: consecutive entries are one quarter apart and the
19 * gaps are 6.7–8.2 days (measured, and a test holds the table to that). Never
20 * add an instant without its neighbours, and never reorder: the interpolation
21 * reads these as evenly spaced quarters, not as dates, and the new moons are
22 * found by counting every fourth entry from the first.
23 */
24const INSTANTS = [
25  1791033900000, // 2026-10-03 21:25 下弦
26  1791647400000, // 2026-10-10 23:50 新月(农历九月初一)
27  1792339920000, // 2026-10-19 00:12 上弦
28  1792987860000, // 2026-10-26 12:11 满月(猎人月)
29  1793564880000, // 2026-11-02 04:28 下弦
30  1794207720000, // 2026-11-09 15:02 新月
31  1794916080000, // 2026-11-17 19:48 上弦
32  1795531980000, // 2026-11-24 22:53 满月(超级海狸月)
33  1796134080000, // 2026-12-01 22:08 下弦
34  1796777520000, // 2026-12-09 08:52 新月
35  1797486180000, // 2026-12-17 13:43 上弦
36  1798075680000, // 2026-12-24 09:28 满月(2026 最后一次超级月亮)
37  1798657140000, // 2026-12-31 02:59 下弦
38]
39
40/** The phase the table starts on: a last quarter, which is 0.75 of the cycle. */
41const FIRST_PHASE = 0.75
42
43/** How long a quarter of the cycle takes on average: the fallback's step. */
44const QUARTER_MS = (29.530588853 / 4) * 86_400_000
45
46const MEAN_MONTH_DAYS = 29.530588853
47const DAY_MS = 86_400_000
48
49/** How long the line stays: a minute, as asked. */
50export const POEM_SLOT_MS = 60_000
51
52export type Moon = {
53  /** Where in the cycle: 0 the new moon, 0.5 the full moon, 1 the next new moon. */
54  fraction: number
55  /** Days since the last new moon — the number a lunar calendar would give. */
56  ageDays: number
57  /** The phase in Chinese. */
58  name: string
59  /** One emoji for the phase; the eight are the whole cycle in order. */
60  glyph: string
61  /** True when the phase is a mean-month estimate: outside the table. */
62  isEstimated: boolean
63}
64
65const NAMES = ['新月', '娥眉月', '上弦月', '盈凸月', '满月', '亏凸月', '下弦月', '残月']
66const GLYPHS = ['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘']
67
68/** The last new moon the table knows at or before this instant, or null. */
69const newMoonBefore = (nowMs: number): number | null => {
70  let found: number | null = null
71
72  // new moons are every fourth entry, the table opening on a last quarter
73  for (let i = 1; i < INSTANTS.length; i += 4) {
74    if (INSTANTS[i] <= nowMs) found = INSTANTS[i]
75  }
76
77  return found
78}
79
80/** The phase at an instant inside the table, or an estimate outside it. */
81export const moonAt = (nowMs: number): Moon => {
82  const first = INSTANTS[0]
83  const last = INSTANTS[INSTANTS.length - 1]
84
85  let fraction: number
86  let isEstimated = false
87
88  if (nowMs < first) {
89    fraction = FIRST_PHASE - (first - nowMs) / QUARTER_MS / 4
90    isEstimated = true
91  } else if (nowMs > last) {
92    fraction = FIRST_PHASE + (INSTANTS.length - 1) / 4 + (nowMs - last) / QUARTER_MS / 4
93    isEstimated = true
94  } else {
95    let i = 0
96
97    // stop one short: the instant *after* the last pair is the pair's own end,
98    // and asking for it would read past the table
99    while (i < INSTANTS.length - 2 && INSTANTS[i + 1] <= nowMs) i += 1
100
101    const span = INSTANTS[i + 1] - INSTANTS[i]
102    const through = (nowMs - INSTANTS[i]) / span
103
104    fraction = FIRST_PHASE + (i + through) / 4
105  }
106
107  const wrapped = ((fraction % 1) + 1) % 1
108  // the nearest of the eight names; `min` keeps the last band from rounding up
109  // past `残月` into the new moon
110  const octant = Math.min(7, Math.floor(wrapped * 8 + 0.5))
111
112  // Days since the last new moon. The age is the elapsed time, not a share of a
113  // mean month: this lunation runs long, and sharing a mean month understates it
114  // by a third of a day. Past the last new moon the table holds the next one
115  // arrives within a month, so an age older than that is counting on a new moon
116  // nobody told this table about.
117  const newMoon = newMoonBefore(nowMs)
118  const sinceNewMoon = newMoon === null ? null : (nowMs - newMoon) / DAY_MS
119  const isAgeTrue = sinceNewMoon !== null && sinceNewMoon < 30
120
121  return {
122    fraction: wrapped,
123    ageDays: isAgeTrue ? sinceNewMoon : wrapped * MEAN_MONTH_DAYS,
124    name: NAMES[octant],
125    glyph: GLYPHS[octant],
126    isEstimated,
127  }
128}
129
130/** One line and where it comes from. */
131export type Poem = { line: string; from: string }
132
133/**
134 * What the meter draws from until the corpus is read — and if it never is. Eight
135 * famous lines, each carrying its own source; several are one line of a longer
136 * poem, so the title is the poem's, not the line's.
137 */
138export const POEMS: Poem[] = [
139  { line: '今人不见古时月,今月曾经照古人。', from: '李白《把酒问月》' },
140  { line: '举杯邀明月,对影成三人。', from: '李白《月下独酌》' },
141  { line: '海上生明月,天涯共此时。', from: '张九龄《望月怀远》' },
142  { line: '江畔何人初见月?江月何年初照人?', from: '张若虚《春江花月夜》' },
143  { line: '举头望明月,低头思故乡。', from: '李白《静夜思》' },
144  { line: '深林人不知,明月来相照。', from: '王维《竹里馆》' },
145  { line: '人有悲欢离合,月有阴晴圆缺,此事古难全。', from: '苏轼《水调歌头》' },
146  { line: '嫦娥应悔偷灵药,碧海青天夜夜心。', from: '李商隐《嫦娥》' },
147]
148
149/**
150 * The corpus that ships with the mod: one `line<TAB>source` per line. A blank
151 * line, or one without both halves, is dropped rather than drawn as a puzzle.
152 * Duplicates go too — the same line twice would read as a repeat inside a cycle
153 * that promises none.
154 */
155export const poemsOf = (text: string): Poem[] => {
156  const seen = new Set<string>()
157  const poems: Poem[] = []
158
159  for (const raw of text.split('\n')) {
160    const [line, from] = raw.trim().split('\t')
161
162    if (!line || !from) continue
163    if (seen.has(line)) continue
164
165    seen.add(line)
166    poems.push({ line, from })
167  }
168
169  return poems
170}
171
172/** A small non-negative hash of an integer, for seeding a cycle. */
173const hashOf = (value: number): number => {
174  let x = Math.imul(value, 2654435761) >>> 0
175
176  x ^= x >>> 15
177  x = Math.imul(x, 2246822519) >>> 0
178  x ^= x >>> 13
179
180  // back to unsigned: `^` answers a signed 32-bit, and a negative here would
181  // index off the front of the list
182  return x >>> 0
183}
184
185const greatestCommonDivisor = (a: number, b: number): number => {
186  let x = a
187  let y = b
188
189  while (y !== 0) [x, y] = [y, x % y]
190
191  return x
192}
193
194/**
195 * A step that shares no factor with the corpus size, so that walking by it
196 * visits every index exactly once before coming back round. `1` is stepped over:
197 * a step of one is the corpus in file order, which is not a shuffle.
198 */
199const stepFor = (seed: number, count: number): number => {
200  let step = 1 + 2 * (hashOf(seed) % Math.floor(count / 2))
201
202  while (greatestCommonDivisor(step, count) !== 1 || step % count === 1) step += 2
203
204  return step
205}
206
207/**
208 * The index for a slot: **a full cycle of the whole corpus**, no line twice
209 * until every line has been drawn — a linear walk whose step and start are the
210 * only state it keeps.
211 *
212 * `step` is coprime with `count`, so `(step * i + offset) mod count` is a
213 * permutation: the first `count` slots cover every line once, in an order that
214 * looks shuffled. Each cycle re-seeds both, so the next round is a different
215 * order of the same corpus rather than the same round again.
216 */
217export const poemIndexAt = (slot: number, count: number): number => {
218  if (count <= 1) return 0
219
220  const cycle = Math.floor(slot / count)
221  const step = stepFor(cycle + 1, count)
222  const offset = hashOf(cycle) % count
223
224  return (step * ((slot % count) + offset)) % count
225}
226
227/**
228 * The line for this minute: the same one for the whole slot (a line that changed
229 * between frames would be unreadable), never the same as the slot before it
230 * inside a cycle, and one of the corpus's own orderings after that.
231 */
232export const poemAt = (nowMs: number, corpus: Poem[] = POEMS): Poem => {
233  const poems = corpus.length > 0 ? corpus : POEMS
234  const slot = Math.floor(nowMs / POEM_SLOT_MS)
235
236  return poems[poemIndexAt(slot, poems.length)]
237}
238
239/** The one line above the prompt. */
240export const lineOf = (nowMs: number, corpus: Poem[] = POEMS): string => {
241  const moon = moonAt(nowMs)
242  const poem = poemAt(nowMs, corpus)
243
244  return `${moon.isEstimated ? '≈' : ''}${moon.glyph} ${moon.name} ${moon.ageDays.toFixed(1)}天 · 「${poem.line}」${poem.from}`
245}
246
247/** The table, for the test that holds it to being a quarter cycle. */
248export const instantsOf = (): readonly number[] => INSTANTS
249
types/index.d.ts 9 lines
1declare module 'claude-code' {
2  interface PluginState {
3    'moon-band': {
4      /** Whether `/moon` has taken the line away. */
5      isHidden: boolean
6    }
7  }
8}
9