SLOPSHOPPER

token-weather

コンテキストの使用状況を天気で、このセッションが読んだ auto-memory を一覧で、右のペインに示す。カードを押すと内訳・残り時間・索引の項目が開く

newpaneguardcommandprompttimer
v0.3.0MITupdated 2026-10-09haruki-cl/claude-code-mods/token-weather
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-weather
│ ┃ Token Weather ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ weather 49% │ ⏺ Read(src/auth.ts) │ ┃ │ █████████████████████████░░░░░░░░░░░░░░░ │ ⎿ Read 6 lines │ ┃ │ ░░░░░░░░░░░░ │ ⏺ Update(src/auth.ts) │ ┃ │ 97k / 200k tokens │ ⎿ Added 2 lines, removed 1 line │ ┃ ╰──────────────────────────────────────────╯ ⏺ Bash(bun test) │ ┃ ╭──────────────────────────────────────────╮ ⎿ 3 pass, 1 fail │ ┃ │ limits │ │ ┃ │ 5-hour 31% ████████████░░░░░░░░░░░░░░… │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ resets Thu 10/9 09:53 │ │ ┃ ╰──────────────────────────────────────────╯ ✻ Worked for 42s · done 4:20 PM │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ memory │ › /weather │ ┃ │ Index not in context │ ⎿ token-weather: Token Weather opened. │ ┃ │ No memory file read yet │ │ ┃ ╰──────────────────────────────────────────╯ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Token Weather
╭──────────────────────────────────────────────────────────╮ │ weather 49% │ │ █████████████████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ │ 97k / 200k tokens │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ limits │ │ 5-hour 31% ████████████░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ │ resets Thu 10/9 09:53 │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ memory │ │ Index not in context │ │ No memory file read yet │ ╰──────────────────────────────────────────────────────────╯
README

claude-code-mods

Claude Code の mod(フックモジュール)を集めたリポジトリです。このリポジトリ自体が marketplace になっているので、1 行でインストールできます。

インストール

Claude Code のプロンプトで次を実行します。

/plugin install token-weather --marketplace haruki-cl/claude-code-mods

Add marketplace? に y、スコープは user を選ぶと、そのセッションからすぐ動き、以降のセッションでも読み込まれます。run-cat も同じ形で入ります。

/plugin install run-cat --marketplace haruki-cl/claude-code-mods

mod 一覧

mod何をするか
token-weatherコンテキストの使用量を天気で、使用量の枠(5 時間・週)、このセッションが読んだ CLAUDE.md と auto-memory を右のペインに出す。/weather で開閉。各カードの見出しを押すと詳細が開く
run-catClaude が考えている間、プロンプト下のヒント行で AA の猫が走る

開発

インストールせずにフォルダから読み込むには --plugin-dir を使います。

claude --plugin-dir ./token-weather --plugin-dir ./run-cat

検査とテストは次のとおりです。

claude plugin validate ./token-weather
claude plugin test ./token-weather

.claude-plugin/types/ はエンジンが書き出す型定義で、git には含めません。

Source 4 files
hooks/register.tsx 414 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionContextBreakdown, SessionContextUsage, SessionRateLimit, ToolCallInput, ToolCallResult } from 'claude-code'
3
4import type { Card, IndexEntry, Limit, MemoryIndex, MemoryRead, Usage } from '../types'
5import { ageDaysIn, formatAge, isMemoryIndex, loadedFilesIn, memoryFileName, memoryPathsIn, parseEntries, shorten, stem } from './memory.ts'
6import { bar, formatLeft, formatReset, formatTokens, limitColor, limitLabel, limitOrder, padCells, percentOf, sparkline, weatherFor } from './weather.ts'
7
8const PANE = 'token-weather'
9const TITLE = 'Token Weather'
10const COMMAND = 'weather'
11// 全画面表示のときにサイドバーとして欲しい幅
12const PANE_COLUMNS = 32
13// 推移グラフに残すターン数
14const HISTORY = 40
15// 読んだメモリのうち、覚えておく数と見せる数
16const READS_KEPT = 30
17const READS_SHOWN = 8
18// 起動直後は CLAUDE.md や MEMORY.md がまだ読み込まれていないことがあるので、少し待ってから内訳を取り直す
19const LATE_REFRESH_MS = [3_000, 10_000]
20
21const usage = atom({ plugin: 'token-weather', key: 'usage' } as const, null)
22const history = atom({ plugin: 'token-weather', key: 'history' } as const, [])
23const limits = atom({ plugin: 'token-weather', key: 'limits' } as const, [])
24const autoCompactAt = atom({ plugin: 'token-weather', key: 'autoCompactAt' } as const, null)
25const memory = atom({ plugin: 'token-weather', key: 'memory' } as const, { index: null, reads: [] })
26const breakdown = atom({ plugin: 'token-weather', key: 'breakdown' } as const, null)
27const open = atom({ plugin: 'token-weather', key: 'open' } as const, { weather: false, limits: false, memory: false })
28
29// 応答がまだ使用量を報告していなければ null
30function snapshot(context: SessionContextUsage): Usage | null {
31  if (context.percent === undefined || context.tokens === undefined) {
32    return null
33  }
34
35  return { percent: context.percent, tokens: context.tokens, window: context.window }
36}
37
38function toLimits(rateLimits: readonly SessionRateLimit[]): Limit[] {
39  return rateLimits
40    .map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, ...(resetsAt ? { resetsAt } : {}) }))
41    .sort((a, b) => limitOrder(a.kind) - limitOrder(b.kind))
42}
43
44// 索引の項目は、パスとトークン数が変わらないかぎり読み直さない
45let counted: { path: string; tokens?: number; entries?: IndexEntry[] } | undefined
46
47async function describeIndex($: EngineInterface, path: string, tokens?: number): Promise<MemoryIndex> {
48  if (counted?.path !== path || counted.tokens !== tokens) {
49    let entries: IndexEntry[] | undefined
50    try {
51      entries = parseEntries(await $.fs.read(path))
52    } catch {
53      entries = undefined
54    }
55
56    counted = { path, ...(tokens !== undefined ? { tokens } : {}), ...(entries !== undefined ? { entries } : {}) }
57  }
58
59  return { path, ...(tokens !== undefined ? { tokens } : {}), ...(counted.entries !== undefined ? { entries: counted.entries } : {}) }
60}
61
62// 索引が載ったと分かったら覚える。トークン数は内訳から分かったときだけ更新し、一度載った索引は /clear まで消さない
63async function noteIndex($: EngineInterface, path: string, tokens?: number): Promise<void> {
64  const index = await describeIndex($, path, tokens)
65  await update($, memory, m => ({ ...m, index: { ...index, ...(index.tokens === undefined && m.index?.tokens !== undefined ? { tokens: m.index.tokens } : {}) } }))
66}
67
68// 内訳から、自動圧縮のしきい値と、カテゴリごとの行と、エンジンが数える memory files(CLAUDE.md 群と MEMORY.md)を取り出す
69async function absorb($: EngineInterface, b: SessionContextBreakdown): Promise<void> {
70  await update($, autoCompactAt, () => (b.isAutoCompactEnabled && b.autoCompactThreshold ? b.autoCompactThreshold : null))
71  await update($, breakdown, () => ({
72    categories: b.categories.flatMap(c => (c.kind === 'deferred' ? [] : [{ name: c.name, tokens: c.tokens, kind: c.kind }])),
73    window: b.rawMaxTokens,
74  }))
75
76  const others = b.memoryFiles.filter(f => f.type !== 'AutoMem')
77  const root = others.length > 0 ? await $.session.root() : undefined
78  const home = others.length > 0 ? await $.env.get('HOME') : undefined
79  for (const f of b.memoryFiles) {
80    if (f.type === 'AutoMem') {
81      await noteIndex($, f.path, f.tokens)
82    } else {
83      // 応答のたびに来るので、載っているものは並びを動かさない
84      await remember($, { name: shorten(f.path, root, home), path: f.path, how: 'auto' }, { keep: true })
85    }
86  }
87}
88
89// 読んだものとして覚える。同じファイルは 1 件だけ残し、読み直したら最新に上げる(keep なら動かさない)
90async function remember($: EngineInterface, read: Omit<MemoryRead, 'at'>, options?: { keep: boolean }): Promise<void> {
91  const at = await $.clock.now()
92  await update($, memory, m => {
93    if (options?.keep && m.reads.some(r => r.path === read.path)) {
94      return m
95    }
96
97    return { ...m, reads: [...m.reads.filter(r => r.path !== read.path), { ...read, at }].slice(-READS_KEPT) }
98  })
99}
100
101// サブエージェントでもチームメイトでもないループは、エンジン自身のもの(compaction やメモリ処理)
102async function isEngineLoop($: EngineInterface, agentId: string | undefined): Promise<boolean> {
103  if (agentId === undefined) {
104    return false
105  }
106
107  try {
108    return !(await $.agent.list()).some(a => a.id === agentId)
109  } catch {
110    return false
111  }
112}
113
114// エンジンが注入した文章に指示ファイルやメモリが含まれていたら、勝手に読まれたものとして覚える
115async function noteInjected($: EngineInterface, text: string): Promise<void> {
116  const files = loadedFilesIn(text)
117  const root = files.length > 0 ? await $.session.root() : undefined
118  const home = files.length > 0 ? await $.env.get('HOME') : undefined
119  for (const f of files) {
120    if (isMemoryIndex(f.path)) {
121      await noteIndex($, f.path)
122      continue
123    }
124
125    const name = memoryFileName(f.path)
126    await remember($, { name: name ? stem(name) : shorten(f.path, root, home), path: f.path, how: 'auto' })
127  }
128
129  // 見出しなしで本文に現れたメモリ(想起)も拾う
130  for (const path of memoryPathsIn(text)) {
131    const name = memoryFileName(path)
132    if (name && !files.some(f => f.path === path)) {
133      await remember($, { name: stem(name), path, how: 'auto' })
134    }
135  }
136}
137
138// 使用量と枠を測り直す。内訳は手元での見積もりなので API 呼び出しは発生しない
139async function refresh($: EngineInterface, withBreakdown: boolean): Promise<void> {
140  const measured = await $.session.usage(withBreakdown ? { breakdown: 'summary' } : undefined)
141  await update($, usage, () => snapshot(measured.context))
142  await update($, limits, () => toLimits(measured.rateLimits))
143
144  const b = measured.context.breakdown
145  if (b) {
146    await absorb($, b)
147  }
148}
149
150// Read か Bash でメモリファイルを読んでいたら、読んだものとして覚える。エンジン自身のループが読んだものは auto
151async function noteMemoryRead($: EngineInterface, e: ToolCallInput, result: ToolCallResult): Promise<void> {
152  if (result.deny !== undefined || result.isError) {
153    return
154  }
155
156  const paths = e.tool === 'Read' ? [e.file_path] : e.tool === 'Bash' ? memoryPathsIn(e.command) : []
157  if (paths.length === 0) {
158    return
159  }
160
161  const ageDays = ageDaysIn(result.text)
162  const how = (await isEngineLoop($, e.agentId)) ? 'auto' : 'tool'
163  for (const path of paths) {
164    const name = memoryFileName(path)
165    if (name) {
166      await remember($, { name: stem(name), path, how, ...(ageDays !== undefined ? { ageDays } : {}) })
167    }
168  }
169}
170
171async function toggle($: EngineInterface): Promise<string> {
172  const mine = (await $.ui.panes()).find(pane => pane.id === PANE)
173  if (mine?.isShown) {
174    await $.ui.close({ id: PANE })
175    return 'Token Weather closed.'
176  }
177
178  await $.ui.open({ id: PANE, title: TITLE, columns: PANE_COLUMNS })
179  return 'Token Weather opened.'
180}
181
182export const register: Register = on => {
183  on('session.start', async ($, e, next) => {
184    await $.command.register({ name: COMMAND, description: 'Open or close Token Weather (context usage as weather) on the right', immediate: true })
185    await refresh($, true)
186    for (const ms of LATE_REFRESH_MS) {
187      $.clock.after(ms, () => void refresh($, true).catch(() => undefined))
188    }
189
190    // 頼まれずに開くペインは、端末が 144 列以上のときだけ横に並ぶ。狭いときは /weather で開く
191    void $.ui.open({ id: PANE, title: TITLE, columns: PANE_COLUMNS })
192
193    return next(e)
194  })
195
196  // プロンプトが送られた時点でも取り直す。最初の応答を待たずに、起動時に載った memory files を出すため
197  on('turn.start', async ($, e, next) => {
198    await refresh($, true)
199    return next(e)
200  }).catch(($, e, next) => next(e))
201
202  on('command.run', { command: COMMAND }, async $ => ({ text: await toggle($) }))
203
204  // 応答のたびに Claude Code が使用量を知らせてくる
205  on('session.measure', async ($, e, next) => {
206    if (e.changed.includes('context')) {
207      const measured = snapshot(e.context)
208      await update($, usage, () => measured)
209      if (measured) {
210        await update($, history, list => [...list, measured.percent].slice(-HISTORY))
211      }
212
213      const detailed = await $.session.usage({ breakdown: 'summary' })
214      const b = detailed.context.breakdown
215      if (b) {
216        await absorb($, b)
217      }
218    }
219
220    if (e.changed.includes('rateLimits')) {
221      await update($, limits, () => toLimits(e.rateLimits))
222    }
223
224    return next(e)
225  })
226
227  // 長い作業の途中でも天気が変わるように、ツールを呼ぶたびにも測り直す。メモリを読んでいればそれも控える
228  on('tool.call', async ($, e, next) => {
229    const result = await next(e)
230    await noteMemoryRead($, e, result)
231    if (!e.agentId) {
232      await refresh($, false)
233    }
234
235    return result
236  }).catch(($, e, next) => next(e))
237
238  // エンジンがモデルに向けて勝手に差し込む文章(起動時の CLAUDE.md と MEMORY.md、ディレクトリに触れたときの CLAUDE.md、想起)
239  on('prompt.attachment', async ($, e, next) => {
240    await noteInjected($, e.text)
241    return next(e)
242  }).catch(($, e, next) => next(e))
243
244  on('session.end', async ($, e, next) => {
245    if (e.reason === 'clear') {
246      await update($, usage, () => null)
247      await update($, history, () => [])
248      await update($, autoCompactAt, () => null)
249      await update($, memory, () => ({ index: null, reads: [] }))
250      await update($, breakdown, () => null)
251    }
252
253    return next(e)
254  })
255
256  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
257    const { Box, Text, Button } = $.ui.resolve(e)
258    const u = await read($, usage)
259    const trend = await read($, history)
260    const windows = await read($, limits)
261    const compactAt = await read($, autoCompactAt)
262    const mem = await read($, memory)
263    const parts = await read($, breakdown)
264    const opened = await read($, open)
265    const now = await $.clock.now()
266
267    // 枠線 2 と左右の余白 2 を引いた、カードの中の幅
268    const inner = Math.max(12, e.props.bodyColumns - 4)
269    // 枠の名前(7 列)と使用率(5 列)、空白 1 列のあとに残る幅をバーにする
270    const limitBar = Math.max(inner - 13, 4)
271    // 印 2 列、メモリの名前、空白 1 列、日数 4 列
272    const memoryName = Math.max(inner - 7, 6)
273    // 内訳の行: カテゴリ名、空白 1 列、トークン数 5 列、空白 1 列、割合 4 列
274    const categoryName = Math.max(inner - 11, 6)
275
276    // カードの見出しを押すと、そのカードの詳細を開け閉めする。描画中は書けないので押されたときに書く
277    const toggle = (card: Card) => () => void update($, open, o => ({ ...o, [card]: !o[card] }))
278    const mark = (card: Card) => (opened[card] ? '▾' : '▸')
279
280    const weather = u ? weatherFor(u.percent) : undefined
281    const untilCompact = u && compactAt ? Math.max(compactAt - u.tokens, 0) : undefined
282    // 新しく読んだものを上に。開いていれば全部、畳んでいれば上から数件
283    const recent = [...mem.reads].reverse().slice(0, opened.memory ? mem.reads.length : READS_SHOWN)
284    const hidden = mem.reads.length - recent.length
285    const hasAuto = recent.some(r => r.how === 'auto')
286
287    return (
288      <Box flexDirection="column">
289        <Box borderStyle="round" borderColor={weather?.color ?? 'gray'} paddingX={1} flexDirection="column">
290          {u && weather ? (
291            <Box flexDirection="column">
292              <Box justifyContent="space-between">
293                <Button key="weather" plain onPress={toggle('weather')}>
294                  <Text bold color={weather.color}>
295                    {weather.icon} {weather.label}
296                  </Text>{' '}
297                  <Text dimColor>{mark('weather')}</Text>
298                </Button>
299                <Text bold color={weather.color}>
300                  {u.percent}%
301                </Text>
302              </Box>
303              <Text color={weather.color}>{bar(u.percent, inner)}</Text>
304              <Text>
305                {formatTokens(u.tokens)} / {formatTokens(u.window)} tokens
306              </Text>
307              {untilCompact !== undefined &&
308                (weather.note ? (
309                  <Text color="red">
310                    {weather.note} ({formatTokens(untilCompact)} left)
311                  </Text>
312                ) : (
313                  <Text dimColor>{formatTokens(untilCompact)} until auto-compact</Text>
314                ))}
315              {trend.length >= 2 && (
316                <Text dimColor wrap="truncate-end">
317                  Trend {sparkline(trend.slice(-(inner - 6)))}
318                </Text>
319              )}
320            </Box>
321          ) : (
322            <Box flexDirection="column">
323              <Button key="weather" plain onPress={toggle('weather')}>
324                <Text dimColor>🌫 Not measured yet</Text> <Text dimColor>{mark('weather')}</Text>
325              </Button>
326              <Text dimColor>Shows after the first reply</Text>
327            </Box>
328          )}
329          {/* 開くと /context と同じ内訳。手元の見積もりなので、上の使用率と合計は一致しないことがある */}
330          {opened.weather &&
331            (parts ? (
332              <Box flexDirection="column" marginTop={1}>
333                {parts.categories.map(c => (
334                  <Text wrap="truncate-end" dimColor={c.kind !== 'used'}>
335                    {padCells(c.name.slice(0, categoryName), categoryName)} {formatTokens(c.tokens).padStart(5)} {`${percentOf(c.tokens, parts.window)}%`.padStart(4)}
336                  </Text>
337                ))}
338                <Text dimColor>of {formatTokens(parts.window)} (est.)</Text>
339              </Box>
340            ) : (
341              <Text dimColor>No breakdown yet</Text>
342            ))}
343        </Box>
344
345        {windows.length > 0 && (
346          <Box borderStyle="round" borderColor={limitColor(Math.max(...windows.map(w => w.percentUsed)))} paddingX={1} flexDirection="column">
347            <Button key="limits" plain onPress={toggle('limits')}>
348              <Text bold>Usage limits</Text> <Text dimColor>{mark('limits')}</Text>
349            </Button>
350            {windows.map(w => (
351              <Box flexDirection="column">
352                <Text wrap="truncate-end">
353                  {padCells(limitLabel(w.kind), 7)}
354                  <Text color={limitColor(w.percentUsed)}>
355                    {`${w.percentUsed}%`.padStart(5)} {bar(w.percentUsed, limitBar)}
356                  </Text>
357                </Text>
358                {w.resetsAt && (
359                  <Text dimColor wrap="truncate-end">
360                    {'  '}
361                    resets {formatReset(w.resetsAt)}
362                  </Text>
363                )}
364                {/* 開くと、リセットまでの残り時間と枠の名前 */}
365                {opened.limits && w.resetsAt && (
366                  <Text dimColor wrap="truncate-end">
367                    {'  '}
368                    {formatLeft(Date.parse(w.resetsAt) - now)} left ({w.kind})
369                  </Text>
370                )}
371              </Box>
372            ))}
373          </Box>
374        )}
375
376        <Box borderStyle="round" borderColor="magenta" paddingX={1} flexDirection="column">
377          <Button key="memory" plain onPress={toggle('memory')}>
378            <Text bold>🧠 Memory</Text> <Text dimColor>{mark('memory')}</Text>
379          </Button>
380          {mem.index ? (
381            <Box justifyContent="space-between">
382              <Text wrap="truncate-end">Index {mem.index.entries !== undefined ? `${mem.index.entries.length} entries` : 'loaded'}</Text>
383              {mem.index.tokens !== undefined && <Text dimColor>{formatTokens(mem.index.tokens)}</Text>}
384            </Box>
385          ) : (
386            <Text dimColor>Index not in context</Text>
387          )}
388          {/* 開くと、索引に並ぶ項目の名前 */}
389          {opened.memory &&
390            mem.index?.entries?.map(entry => (
391              <Text dimColor wrap="truncate-end">
392                {'  · '}
393                {entry.title}
394              </Text>
395            ))}
396          {opened.memory && recent.length > 0 && <Text dimColor>Read this session</Text>}
397          {recent.length === 0 ? (
398            <Text dimColor>No memory file read yet</Text>
399          ) : (
400            recent.map(r => (
401              <Text wrap="truncate-end">
402                {r.how === 'auto' ? '* ' : '  '}
403                {padCells(r.name.slice(0, memoryName), memoryName)} <Text dimColor>{formatAge(r.ageDays).padStart(4)}</Text>
404              </Text>
405            ))
406          )}
407          {hidden > 0 && <Text dimColor>+{hidden} more</Text>}
408          {hasAuto && <Text dimColor>* loaded by the engine</Text>}
409        </Box>
410      </Box>
411    )
412  })
413}
414
hooks/memory.ts 85 lines
1// auto-memory(~/.claude/projects/<プロジェクト>/memory/)と指示ファイルの読み込みを見つける。Claude Code の API には触れない純粋な関数だけを置く
2
3// 1 つめの括弧がパス全体、2 つめがファイル名
4const MEMORY_FILE = /([^\s'"]*\/\.claude\/projects\/[^/\s'"]+\/memory\/([^/\s'"]+\.md))\b/
5const MEMORY_FILES = new RegExp(MEMORY_FILE.source, 'g')
6// エンジンがメモリファイルの Read 結果の先頭に付ける注意書き
7const AGE = /This memory is (\d+) days? old/
8// エンジンが注入する文章の、ファイルごとの見出し
9const CONTENTS_OF = /^Contents of (\S+?) \(([^)]*)\):/gm
10// 索引そのもの。読んだファイルの一覧には出さず、Index の行で示す
11const INDEX = 'MEMORY.md'
12
13/** auto-memory の個別ファイルのパスなら、そのファイル名を返す */
14export function memoryFileName(path: string): string | undefined {
15  const name = MEMORY_FILE.exec(path)?.[2]
16  return name && name !== INDEX ? name : undefined
17}
18
19/** auto-memory の索引(MEMORY.md)のパスなら true */
20export function isMemoryIndex(path: string): boolean {
21  return MEMORY_FILE.exec(path)?.[2] === INDEX
22}
23
24/** 文章の中に現れる auto-memory の個別ファイルのパスを、重複なく返す */
25export function memoryPathsIn(text: string): string[] {
26  const found = new Set<string>()
27  for (const m of text.matchAll(MEMORY_FILES)) {
28    if (m[2] !== INDEX && m[1]) {
29      found.add(m[1])
30    }
31  }
32
33  return [...found]
34}
35
36/** 注入された文章の見出し「Contents of <path> (<説明>):」を、パスと説明の組で返す */
37export function loadedFilesIn(text: string): { path: string; label: string }[] {
38  return [...text.matchAll(CONTENTS_OF)].flatMap(m => (m[1] ? [{ path: m[1], label: m[2] ?? '' }] : []))
39}
40
41/** Read の結果から「N days old」の N を取り出す */
42export function ageDaysIn(text: string | undefined): number | undefined {
43  const m = text ? AGE.exec(text) : null
44  return m ? Number(m[1]) : undefined
45}
46
47// 索引の 1 行。1 つめの括弧が Title、2 つめが file
48const ENTRY = /^\s*- \[([^\]]+)\]\(([^)]+)\)/
49
50/** MEMORY.md に並ぶ項目(- [Title](file.md) — hook)を、並んだ順に返す */
51export function parseEntries(index: string): { title: string; file: string }[] {
52  return index.split('\n').flatMap(line => {
53    const m = ENTRY.exec(line)
54    return m?.[1] && m[2] ? [{ title: m[1], file: m[2] }] : []
55  })
56}
57
58/** MEMORY.md に並ぶ項目を数える */
59export function countEntries(index: string): number {
60  return parseEntries(index).length
61}
62
63/** 「flow-html-structure.md」を「flow-html-structure」にする */
64export function stem(name: string): string {
65  return name.replace(/\.md$/, '')
66}
67
68/** プロジェクト配下なら相対パス、ホーム配下なら ~/ 始まりにする */
69export function shorten(path: string, root: string | undefined, home: string | undefined): string {
70  if (root && path.startsWith(`${root}/`)) {
71    return path.slice(root.length + 1)
72  }
73
74  if (home && path.startsWith(`${home}/`)) {
75    return `~/${path.slice(home.length + 1)}`
76  }
77
78  return path
79}
80
81/** 日数を「48d」にする。不明なら空 */
82export function formatAge(days: number | undefined): string {
83  return days === undefined ? '' : `${days}d`
84}
85
hooks/weather.ts 131 lines
1// 使用率を天気やバーに置き換える。Claude Code の API には触れない純粋な関数だけを置く
2
3export type Weather = { icon: string; label: string; color: string; note?: string }
4
5const STORM: Weather = { icon: '⛈', label: 'Stormy', color: 'red', note: 'Auto-compact soon' }
6
7// 上から順に、使用率がしきい値未満なら当てはまる。どれにも当たらなければ STORM
8const LEVELS: readonly (Weather & { below: number })[] = [
9  { below: 25, icon: '☀️', label: 'Sunny', color: 'green' },
10  { below: 50, icon: '🌤', label: 'Mostly sunny', color: 'green' },
11  { below: 70, icon: '⛅', label: 'Cloudy', color: 'yellow' },
12  { below: 85, icon: '🌧', label: 'Rainy', color: 'yellow' },
13]
14
15export function weatherFor(percent: number): Weather {
16  const level = LEVELS.find(l => percent < l.below)
17  if (!level) {
18    return STORM
19  }
20
21  const { below: _, ...weather } = level
22  return weather
23}
24
25/** 使用量の枠(5 時間・週)の色。天気より早めに注意を促す */
26export function limitColor(percentUsed: number): string {
27  if (percentUsed < 50) {
28    return 'green'
29  }
30
31  return percentUsed < 80 ? 'yellow' : 'red'
32}
33
34export function formatTokens(n: number): string {
35  if (n < 1000) {
36    return `${n}`
37  }
38
39  if (n < 1_000_000) {
40    return `${Math.round(n / 1000)}k`
41  }
42
43  return `${(n / 1_000_000).toFixed(1)}M`
44}
45
46/** 窓に対する割合を整数の百分率にする。窓が 0 なら 0 */
47export function percentOf(tokens: number, window: number): number {
48  return window > 0 ? Math.round((tokens / window) * 100) : 0
49}
50
51/** 残りミリ秒を「2h 13m」「3d 5h」「12m」にする。過ぎていれば 0m */
52export function formatLeft(ms: number): string {
53  const minutes = Math.max(Math.floor(ms / 60_000), 0)
54  const days = Math.floor(minutes / 1440)
55  const hours = Math.floor((minutes % 1440) / 60)
56  if (days > 0) {
57    return `${days}d ${hours}h`
58  }
59
60  return hours > 0 ? `${hours}h ${minutes % 60}m` : `${minutes}m`
61}
62
63/** width 文字ぶんの横棒。percent が 0〜100 の外でも端で止める */
64export function bar(percent: number, width: number): string {
65  const filled = Math.round((Math.min(Math.max(percent, 0), 100) / 100) * width)
66  return '█'.repeat(filled) + '░'.repeat(Math.max(width - filled, 0))
67}
68
69const SPARKS = '▁▂▃▄▅▆▇█'
70
71/** 0〜100 の値の並びを 1 行の推移グラフにする */
72export function sparkline(values: readonly number[]): string {
73  return values
74    .map(v => SPARKS[Math.min(SPARKS.length - 1, Math.floor((Math.min(Math.max(v, 0), 100) / 100) * SPARKS.length))] ?? '')
75    .join('')
76}
77
78const LIMIT_LABELS: Record<string, string> = {
79  five_hour: '5-hour',
80  seven_day: 'Weekly',
81  spend_limit: 'Spend',
82}
83
84export function limitLabel(kind: string): string {
85  return LIMIT_LABELS[kind] ?? kind
86}
87
88/** 枠を 5 時間・週・その他の順に並べる */
89export function limitOrder(kind: string): number {
90  const order = Object.keys(LIMIT_LABELS).indexOf(kind)
91  return order === -1 ? Object.keys(LIMIT_LABELS).length : order
92}
93
94const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
95
96/** ISO 8601 の時刻を「Thu 10/8 03:10」の形にする */
97export function formatReset(iso: string): string {
98  const d = new Date(iso)
99  if (Number.isNaN(d.getTime())) {
100    return iso
101  }
102
103  const hh = `${d.getHours()}`.padStart(2, '0')
104  const mm = `${d.getMinutes()}`.padStart(2, '0')
105  return `${WEEKDAYS[d.getDay()]} ${d.getMonth() + 1}/${d.getDate()} ${hh}:${mm}`
106}
107
108// 全角(東アジアの広い文字)は 2 列ぶんとして数える
109function cells(text: string): number {
110  let width = 0
111  for (const ch of text) {
112    const code = ch.codePointAt(0) ?? 0
113    const isWide =
114      (code >= 0x1100 && code <= 0x115f) ||
115      (code >= 0x2e80 && code <= 0xa4cf) ||
116      (code >= 0xac00 && code <= 0xd7a3) ||
117      (code >= 0xf900 && code <= 0xfaff) ||
118      (code >= 0xfe30 && code <= 0xfe4f) ||
119      (code >= 0xff00 && code <= 0xff60) ||
120      (code >= 0xffe0 && code <= 0xffe6)
121    width += isWide ? 2 : 1
122  }
123
124  return width
125}
126
127/** 表示幅が width 列になるまで右に空白を足す */
128export function padCells(text: string, width: number): string {
129  return text + ' '.repeat(Math.max(width - cells(text), 0))
130}
131
types/index.d.ts 47 lines
1// 最後に測ったコンテキストの使用状況
2export type Usage = { percent: number; tokens: number; window: number }
3
4// 使用量の枠(five_hour、seven_day など)。resetsAt は ISO 8601
5export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
6
7// 索引(MEMORY.md)の 1 項目: `- [Title](file.md) — hook` の Title と file
8export type IndexEntry = { title: string; file: string }
9
10// コンテキストに載っている auto-memory の索引(MEMORY.md)。entries は索引に並ぶ項目(読めなければ undefined)
11export type MemoryIndex = { path: string; tokens?: number; entries?: IndexEntry[] }
12
13// このセッションが読んだファイル。how は読まれ方: tool = Claude が Read や Bash で読んだ、
14// auto = エンジンが勝手に載せた(起動時の読み込み、ディレクトリに触れたときの CLAUDE.md、想起、エンジン自身のメモリ処理)。
15// ageDays はエンジンが Read の結果に付ける「N days old」
16export type MemoryRead = { name: string; path: string; at: number; how: 'tool' | 'auto'; ageDays?: number }
17
18// 過去セッションの記憶(auto-memory)と指示ファイルのうち、このセッションが読んだもの
19export type Memory = { index: MemoryIndex | null; reads: MemoryRead[] }
20
21// /context が出す内訳の 1 行。deferred(窓の外にある遅延ロードのツール定義)は載せない
22export type Category = { name: string; tokens: number; kind: 'used' | 'free' | 'buffer' }
23
24// 内訳と、それを測った窓(圧縮の窓。モデルの窓より小さいことがある)
25export type Breakdown = { categories: Category[]; window: number }
26
27// ペインのカード。押すと詳細が開く
28export type Card = 'weather' | 'limits' | 'memory'
29
30// どのカードが開いているか
31export type Open = Record<Card, boolean>
32
33declare module 'claude-code' {
34  interface PluginState {
35    'token-weather': {
36      usage: Usage | null
37      history: number[]
38      limits: Limit[]
39      // 自動圧縮が始まるトークン数(オフなら null)
40      autoCompactAt: number | null
41      memory: Memory
42      breakdown: Breakdown | null
43      open: Open
44    }
45  }
46}
47