SLOPSHOPPER

chat-memory

Боковая панель с памятью чата и цифрами сессии, кнопки «Память» и «Сжать» над строкой ввода; память сжимает Haiku по кнопке и перед компактом

newpanebandcommandtoastmodel
★ 15v0.6.1MITupdated 2026-10-09tsergeytovarov/ai-settings/plugins/chat-memory
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · chat-memory
│ ┃ Память чата ✕ › fix the failing auth test and add an audit log call │ ┃ [ 🧠 Память ] [ 📊 Цифры ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ Пока пусто. Нажми ↻ Память над строкой ввода ⎿ 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 │ │ › /chat-memory │ ⎿ chat-memory: Панель «Память чата» открыта. │ │ [ ↻ Память ] [ 🗜 Сжать ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[ ↻ Память ] [ 🗜 Сжать ]
Pane · Память чата
[ 🧠 Память ] [ 📊 Цифры ] Пока пусто. Нажми ↻ Память над строкой ввода или сожми чат: перед сжатием память соберётся сама.
README

chat-memory

Плагин Claude Code: боковая панель «Память чата» и две кнопки в полосе над строкой ввода.

Панель

  • Память. Каждый раздел в своём блоке с цветной рамкой: цель, решения, выводы, открытые вопросы, следующий шаг. Внутри блока markdown, тем же оформлением, что ответы в чате. Пустые разделы не показываются.
  • Цифры: сколько потрачено и сколько в час, сколько идёт сессия, запросов и изменённых файлов, заполнение контекста, лимиты на 5 часов и неделю со временем до сброса. В десктопе вкладка рисуется одной SVG-картинкой: раскладка своя, ничего не наезжает. В терминале — строками.

Панель открывается при старте сессии и командой /chat-memory, листается самим десктопом.

Кнопки в полосе над вводом

  • «↻ Память»: обновить память.
  • «🗜 Сжать»: обновить память и сжать чат. Свой хук session.compact движок для собственного вызова плагина пропускает, поэтому кнопка сама передаёт память в инструкции компакта. Если прямой $.session.compact отказал (движок считает, что идёт ход, а нажатие кнопки может так считаться), кнопка ставит /compact с теми же инструкциями в очередь, и он выполнится, когда сессия освободится. Настоящая причина отказа уходит в debug-лог. Тост показывается, только если не вышло и так, и в нём текст ошибки от движка.

Полоса всегда на экране. Если её рисует ещё и context-weather, кнопки встают справа от лимитов: чужая полоса занимает место, которое оставили кнопки, а кнопки не сжимаются.

Когда обновляется память

Только по кнопке «↻ Память» и перед компактом: /compact, автокомпакт, «🗜 Сжать». Перед сжатием память догоняет чат и уходит в инструкции компакта, чтобы сводка не потеряла решения и выводы.

Сжимает Haiku 5.5 (claude-haiku-5-5) через $.model.complete: получает прошлую память и только новые сообщения.

Полоса по цепочке

Полосу рисуют по цепочке: каждый плагин берёт то, что нарисовали плагины под ним (next(e)), и ставит рядом своё. Когда под плагином никто не рисует, next(e) отдаёт служебный элемент { type: 'engine' }. Вкладывать его в своё дерево нельзя: движок отклоняет такое дерево и не рисует полосу вовсе. Поэтому оба плагина его отбрасывают.

Закреплённого низа у панели в движке нет, а свой фрейм с прокруткой в десктопе ломается. Поэтому кнопки живут в полосе, а не в панели.

Установка

claude plugin marketplace add ~/work/ai-settings/plugins
claude plugin install chat-memory@popovs-plugins

Разработка

  • hooks/digest.ts — промпт для Haiku, разбор ответа, разделы памяти, инструкции компакту.
  • hooks/layout.ts — статистика по транскрипту, форматирование.
  • hooks/chart.ts — SVG вкладки «Цифры».
  • hooks/register.tsx — хуки, полоса, панель.
  • Проверка: claude plugin validate . и claude plugin test .
Source 5 files
hooks/register.tsx 277 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement, SessionMessage } from 'claude-code'
3
4import type { MemoryStatus, Tab } from '../types'
5import { statsSvg, statsSvgHeight } from './chart'
6import type { StatCell } from './chart'
7import { MODEL, SYSTEM, compactInstructions, memorySections, parseMemory, prompt } from './digest'
8import { clockTime, formatDuration, formatTokens, transcriptStats } from './layout'
9
10const PANE = 'chat-memory'
11const TITLE = 'Память чата'
12const MODEL_TIMEOUT_MS = 60_000
13const LIMIT_LABELS: Record<string, string> = { five_hour: 'Лимит 5 ч', seven_day: 'Лимит неделя' }
14
15const memory = atom({ plugin: 'chat-memory', key: 'memory' } as const, null)
16const IDLE: MemoryStatus = { cursor: 0, isRunning: false }
17const status = atom({ plugin: 'chat-memory', key: 'status' } as const, IDLE)
18const tab = atom({ plugin: 'chat-memory', key: 'tab' } as const, 'memory' as Tab)
19
20// Module state: starts over on reload, while the atoms above are the host's and stay.
21let running: Promise<void> | null = null
22
23// Folds the messages past the cursor into the memory with Haiku. `messages`
24// is the whole transcript; the cursor says how much of it is already in.
25async function catchUp($: EngineInterface, messages: readonly SessionMessage[]): Promise<void> {
26  const before = await read($, status)
27  // A compaction shortened the transcript: start over on what is left.
28  const cursor = before.cursor > messages.length ? 0 : before.cursor
29  const fresh = messages.slice(cursor)
30  if (fresh.length === 0) return
31
32  await update($, status, s => ({ ...s, isRunning: true }))
33  const reply = await $.model.complete({
34    model: MODEL,
35    system: SYSTEM,
36    prompt: prompt(await read($, memory), fresh),
37    maxTokens: 2000,
38    effort: 'low',
39    timeoutMs: MODEL_TIMEOUT_MS,
40  })
41  const next = reply.isAnswered ? parseMemory(reply.text) : null
42  if (next === null) {
43    await update($, status, s => ({ ...s, isRunning: false }))
44    $.ui.toast(reply.isAnswered ? 'Память: Haiku ответила не JSON' : `Память: Haiku не ответила (${reply.reason})`)
45    return
46  }
47
48  await update($, memory, () => next)
49  await update($, status, () => ({ cursor: messages.length, isRunning: false }))
50}
51
52// One catch-up at a time; a second ask waits for the first.
53async function refresh($: EngineInterface, messages?: readonly SessionMessage[]): Promise<void> {
54  if (running !== null) await running
55  running = (async () => catchUp($, messages ?? (await $.session.messages())))()
56  try {
57    await running
58  } finally {
59    running = null
60  }
61}
62
63function reasonOf(error: unknown): string {
64  return error instanceof Error ? error.message : String(error)
65}
66
67// The button's compaction. The engine skips the calling plugin's own
68// session.compact hook, so this does what that hook does for /compact.
69async function compactNow($: EngineInterface): Promise<void> {
70  await refresh($)
71  const current = await read($, memory)
72  const instructions = current === null ? '' : compactInstructions(current, undefined)
73  try {
74    const result = await $.session.compact(instructions === '' ? {} : { instructions })
75    if (result.messages === undefined) {
76      $.ui.toast(`Сжатие отменено: ${result.skip}`)
77      return
78    }
79    const kept = result.messages.length
80    await update($, status, s => ({ ...s, cursor: kept }))
81  } catch (direct) {
82    // The direct call refuses while the engine counts a turn as running, and a
83    // button press may count as one. /compact as a command waits for the session
84    // to go idle and runs then, the memory passed along as its instructions.
85    $.ui.log(`chat-memory: $.session.compact refused: ${reasonOf(direct)}`, { to: 'debug' })
86    try {
87      await $.command.run({ command: 'compact', args: instructions })
88    } catch (queued) {
89      $.ui.toast(`Сжать не вышло: ${reasonOf(queued)}`)
90    }
91  }
92}
93
94// Instructions this plugin already wrote: a /compact the button queued carries them.
95const KEPT_MARK = 'Обязательно сохрани в сводке:'
96
97// `next(e)` answers `{ type: 'engine' }` when nothing beneath drew: the engine's
98// own band, which may only stand alone, never inside another tree.
99function drawnBeneath(rest: RenderElement): RenderElement | null {
100  return rest.type === 'engine' ? null : rest
101}
102
103export const register: Register = on => {
104  on('session.start', async ($, e, next) => {
105    await $.command.register({ name: 'chat-memory', description: 'Открыть панель с памятью чата' })
106    void $.ui.open({ id: PANE, title: TITLE })
107
108    return next(e)
109  })
110
111  on('command.run', { command: 'chat-memory' }, async $ => {
112    await $.ui.open({ id: PANE, title: TITLE })
113
114    return { text: 'Панель «Память чата» открыта.' }
115  })
116
117  // Before a compaction: bring the memory up to date and ask the summary to keep it.
118  on('session.compact', async ($, e, next) => {
119    if (e.agentId !== undefined || e.trigger === 'precompute') return next(e)
120
121    await refresh($, e.messages)
122    const current = await read($, memory)
123    const isKept = current === null || (e.instructions ?? '').includes(KEPT_MARK)
124    const compacted = await next(isKept ? e : { ...e, instructions: compactInstructions(current, e.instructions) })
125    if (compacted.messages !== undefined) {
126      const kept = compacted.messages.length
127      await update($, status, s => ({ ...s, cursor: kept }))
128    }
129
130    return compacted
131  })
132
133  // The buttons live in the band above the prompt, which never scrolls away. Another
134  // plugin may draw the band too (context-weather does): its tree comes first, these after.
135  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
136    const rest = await next(e)
137    if (e.props.hasSurvey) return rest
138
139    const { Box, Button } = $.ui.resolve(e)
140    const state = await read($, status)
141
142    const buttons = (
143      <Box flexDirection="row" alignItems="center" gap={1} flexShrink={0}>
144        <Button
145          key="memory-refresh"
146          label={state.isRunning ? '⏳ Память' : '↻ Память'}
147          onPress={async () => {
148            if (!state.isRunning) await refresh($)
149          }}
150        />
151        <Button key="memory-compact" label="🗜 Сжать" onPress={async () => compactNow($)} />
152      </Box>
153    )
154    const others = drawnBeneath(rest)
155    if (others === null) return buttons
156
157    // The other plugin's band (context-weather's is full width) takes the room the buttons leave.
158    return (
159      <Box flexDirection="row" alignItems="center" gap={1} width="100%">
160        <Box flexGrow={1} flexShrink={1} minWidth={0}>
161          {others}
162        </Box>
163        {buttons}
164      </Box>
165    )
166  })
167
168  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
169    const { Box, Text, Button, Markdown } = $.ui.resolve(e)
170    const shown = await read($, tab)
171
172    let body
173    if (shown === 'memory') {
174      const current = await read($, memory)
175      body =
176        current === null ? (
177          <Markdown
178            dimColor
179            text="Пока пусто. Нажми **↻ Память** над строкой ввода или сожми чат: перед сжатием память соберётся сама."
180          />
181        ) : (
182          <Box flexDirection="column">
183            {memorySections(current).map(section => (
184              <Box
185                key={section.key}
186                flexDirection="column"
187                borderStyle="round"
188                borderColor={section.color}
189                paddingX={1}
190                marginBottom={1}
191              >
192                <Text bold color={section.color}>
193                  {section.title}
194                </Text>
195                <Markdown text={section.markdown} />
196              </Box>
197            ))}
198          </Box>
199        )
200    } else {
201      const usage = await $.session.usage()
202      const now = await $.clock.now()
203      const seen = transcriptStats(await $.session.messages())
204      const hours = (now - usage.startedAt) / 3_600_000
205      const cost = usage.cost?.usd
206      const context = usage.context
207      const cells: StatCell[] = [
208        {
209          label: 'Потрачено',
210          value: cost === undefined ? '—' : `$${cost.toFixed(2)}`,
211          note: cost === undefined || hours < 0.05 ? '' : `$${(cost / hours).toFixed(2)} в час`,
212        },
213        { label: 'Идёт', value: formatDuration(now - usage.startedAt), note: `с ${clockTime(usage.startedAt)}` },
214        { label: 'Запросов', value: String(seen.prompts), note: `файлов изменено: ${seen.changedFiles}` },
215        {
216          label: 'Контекст',
217          value: context.percent === undefined ? '—' : `${context.percent}%`,
218          note: `${formatTokens(context.tokens ?? 0)} из ${formatTokens(context.window)}`,
219        },
220        ...usage.rateLimits.flatMap(limit => {
221          const label = LIMIT_LABELS[limit.kind]
222          if (label === undefined) return []
223          const resetsAt = limit.resetsAt === undefined ? NaN : Date.parse(limit.resetsAt)
224          const note = Number.isNaN(resetsAt) ? '' : `сброс через ${formatDuration(resetsAt - now)}`
225          return [{ label, value: `${Math.round(limit.percentUsed)}%`, note }]
226        }),
227      ]
228
229      if (e.surface === 'terminal') {
230        body = (
231          <Box flexDirection="column">
232            {cells.map(cell => (
233              <Text>
234                <Text dimColor>{cell.label.padEnd(14)}</Text>
235                <Text bold>{cell.value}</Text>
236                {cell.note === '' ? '' : <Text dimColor> · {cell.note}</Text>}
237              </Text>
238            ))}
239          </Box>
240        )
241      } else {
242        const { Svg } = $.ui.resolve(e)
243        body = (
244          <Svg
245            source={statsSvg(cells)}
246            alt={cells.map(cell => `${cell.label}: ${cell.value}`).join(', ')}
247            height={statsSvgHeight(cells.length)}
248          />
249        )
250      }
251    }
252
253    const tabs: Array<[Tab, string]> = [
254      ['memory', '🧠 Память'],
255      ['stats', '📊 Цифры'],
256    ]
257
258    return (
259      <Box flexDirection="column">
260        <Box flexDirection="row" gap={1} marginBottom={1}>
261          {tabs.map(([id, label]) => (
262            <Button
263              key={`tab-${id}`}
264              label={label}
265              variant={id === shown ? 'primary' : 'secondary'}
266              onPress={async () => {
267                await update($, tab, () => id)
268              }}
269            />
270          ))}
271        </Box>
272        {body}
273      </Box>
274    )
275  })
276}
277
hooks/chart.ts 47 lines
1// The stats tab as one SVG image for the desktop: figures in a two-column grid.
2// Laid out here, so nothing overlaps whatever the pane's width does; the image
3// scales down to the slot.
4
5export type StatCell = { label: string; value: string; note: string }
6
7const WIDTH = 320
8const GAP = 10
9const CELL_W = (WIDTH - GAP) / 2
10const CELL_H = 66
11
12const STYLE =
13  'text{font-family:-apple-system,system-ui,sans-serif;fill:#29261b}' +
14  '.dim{fill:#73726c}.cell{fill:#00000009}' +
15  '.label{font-size:11px}.value{font-size:19px;font-weight:600}.note{font-size:11px}' +
16  '@media (prefers-color-scheme:dark){text{fill:#ebe9e1}.dim{fill:#9c9a92}.cell{fill:#ffffff0d}}'
17
18function escape(text: string): string {
19  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
20}
21
22function cut(text: string, limit: number): string {
23  return text.length > limit ? `${text.slice(0, limit - 1)}…` : text
24}
25
26export function statsSvg(cells: readonly StatCell[]): string {
27  const parts = cells.map((cell, i) => {
28    const x = (i % 2) * (CELL_W + GAP)
29    const y = Math.floor(i / 2) * (CELL_H + GAP)
30    return (
31      `<rect class="cell" x="${x}" y="${y}" width="${CELL_W}" height="${CELL_H}" rx="10"/>` +
32      `<text class="label dim" x="${x + 12}" y="${y + 19}">${escape(cut(cell.label, 22))}</text>` +
33      `<text class="value" x="${x + 12}" y="${y + 41}">${escape(cut(cell.value, 14))}</text>` +
34      `<text class="note dim" x="${x + 12}" y="${y + 57}">${escape(cut(cell.note, 24))}</text>`
35    )
36  })
37  const height = statsSvgHeight(cells.length)
38  return (
39    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${WIDTH} ${height}" width="${WIDTH}" height="${height}">` +
40    `<style>${STYLE}</style>${parts.join('')}</svg>`
41  )
42}
43
44export function statsSvgHeight(cells: number): number {
45  return Math.ceil(cells / 2) * (CELL_H + GAP) - GAP
46}
47
hooks/digest.ts 110 lines
1import type { SessionMessage } from 'claude-code'
2
3import type { Memory } from '../types'
4
5export const MODEL = 'claude-haiku-5-5'
6
7// Each message is cut to this many characters, the whole batch to the last MAX_BATCH.
8const MAX_MESSAGE = 1500
9const MAX_BATCH = 80_000
10
11export const SYSTEM = `Ты ведёшь краткую память рабочей сессии человека с ИИ-ассистентом.
12Тебе дают текущую память и новые сообщения. Обнови память и верни ТОЛЬКО JSON без пояснений и без markdown:
13{"goal": string, "decisions": string[], "findings": string[], "questions": string[], "next": string}
14
15goal — цель сессии одной фразой.
16decisions — что решили делать или не делать.
17findings — выводы и проверенные факты: что выяснили, что работает, что нет.
18questions — только открытые вопросы; закрытые убирай.
19next — следующий шаг одной фразой.
20
21Правила: по-русски, каждый пункт до 140 символов, в каждом списке не больше 8 пунктов,
22устаревшее и отменённое убирай, новое важнее старого. Ничего не выдумывай: только то, что есть в сообщениях.`
23
24const EMPTY: Memory = { goal: '', decisions: [], findings: [], questions: [], next: '' }
25
26function cut(text: string, limit: number): string {
27  return text.length <= limit ? text : `${text.slice(0, limit)}…`
28}
29
30function pathOf(input: Record<string, unknown>): string | undefined {
31  const path = input.file_path ?? input.notebook_path
32  return typeof path === 'string' ? path : undefined
33}
34
35export function transcript(messages: readonly SessionMessage[]): string {
36  const lines = messages.flatMap(message => {
37    const who = message.role === 'user' ? 'Пользователь' : 'Ассистент'
38    const tools = message.toolUses.map(use => {
39      const path = pathOf(use.input)
40      return path === undefined ? use.tool : `${use.tool}(${path})`
41    })
42    const text = message.text.trim()
43    if (text === '' && tools.length === 0) return []
44    const toolLine = tools.length === 0 ? '' : `\n[инструменты: ${tools.join(', ')}]`
45    return [`${who}: ${cut(text, MAX_MESSAGE)}${toolLine}`]
46  })
47  const joined = lines.join('\n\n')
48  return joined.length <= MAX_BATCH ? joined : joined.slice(joined.length - MAX_BATCH)
49}
50
51export function prompt(memory: Memory | null, messages: readonly SessionMessage[]): string {
52  const current = memory === null ? 'пусто' : JSON.stringify(memory)
53  return `Текущая память:\n${current}\n\nНовые сообщения:\n${transcript(messages)}\n\nОбнови память.`
54}
55
56function strings(value: unknown): string[] {
57  return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
58}
59
60// The model's reply as a Memory, or null when it is not the JSON asked for.
61export function parseMemory(reply: string): Memory | null {
62  const start = reply.indexOf('{')
63  const end = reply.lastIndexOf('}')
64  if (start === -1 || end <= start) return null
65  let data: unknown
66  try {
67    data = JSON.parse(reply.slice(start, end + 1))
68  } catch {
69    return null
70  }
71  if (typeof data !== 'object' || data === null) return null
72  const record = data as Record<string, unknown>
73  return {
74    ...EMPTY,
75    goal: typeof record.goal === 'string' ? record.goal : '',
76    decisions: strings(record.decisions),
77    findings: strings(record.findings),
78    questions: strings(record.questions),
79    next: typeof record.next === 'string' ? record.next : '',
80  }
81}
82
83// What a compaction is asked to keep, so the summary does not lose it.
84export function compactInstructions(memory: Memory, own: string | undefined): string {
85  const list = (title: string, items: string[]) =>
86    items.length === 0 ? '' : `\n${title}:\n${items.map(item => `- ${item}`).join('\n')}`
87  const kept =
88    `Обязательно сохрани в сводке:\nЦель: ${memory.goal}` +
89    list('Решения', memory.decisions) +
90    list('Выводы', memory.findings) +
91    list('Открытые вопросы', memory.questions) +
92    (memory.next === '' ? '' : `\nСледующий шаг: ${memory.next}`)
93  return own === undefined || own.trim() === '' ? kept : `${own.trim()}\n\n${kept}`
94}
95
96export type MemorySection = { key: string; title: string; color: string; markdown: string }
97
98// The memory as the pane shows it: one block per non-empty section, its body markdown.
99export function memorySections(memory: Memory): MemorySection[] {
100  const list = (items: string[]) => items.map(item => `- ${item}`).join('\n')
101  const sections: MemorySection[] = [
102    { key: 'goal', title: '🎯 Цель', color: 'claude', markdown: memory.goal },
103    { key: 'decisions', title: `✅ Решения · ${memory.decisions.length}`, color: 'success', markdown: list(memory.decisions) },
104    { key: 'findings', title: `💡 Выводы · ${memory.findings.length}`, color: 'suggestion', markdown: list(memory.findings) },
105    { key: 'questions', title: `❓ Открытые вопросы · ${memory.questions.length}`, color: 'warning', markdown: list(memory.questions) },
106    { key: 'next', title: '→ Следующий шаг', color: 'claude', markdown: memory.next },
107  ]
108  return sections.filter(section => section.markdown.trim() !== '')
109}
110
hooks/layout.ts 46 lines
1import type { SessionMessage } from 'claude-code'
2
3export function formatDuration(ms: number): string {
4  const minutes = Math.max(0, Math.floor(ms / 60_000))
5  if (minutes < 60) return `${minutes} мин`
6  const hours = Math.floor(minutes / 60)
7  if (hours < 24) return `${hours} ч ${minutes % 60} мин`
8  return `${Math.floor(hours / 24)} д ${hours % 24} ч`
9}
10
11export function formatTokens(n: number): string {
12  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
13  if (n >= 1_000) return `${Math.round(n / 1_000)}k`
14  return String(n)
15}
16
17export function clockTime(at: number): string {
18  const date = new Date(at)
19  const pad = (n: number) => String(n).padStart(2, '0')
20  return `${pad(date.getHours())}:${pad(date.getMinutes())}`
21}
22
23const WRITERS = new Set(['Edit', 'Write', 'NotebookEdit'])
24
25export type TranscriptStats = {
26  // Messages the person typed: user rows with text and no tool results.
27  prompts: number
28  // Distinct files written with Edit, Write or NotebookEdit.
29  changedFiles: number
30}
31
32export function transcriptStats(messages: readonly SessionMessage[]): TranscriptStats {
33  const written = new Set<string>()
34  let prompts = 0
35  for (const message of messages) {
36    if (message.role === 'user' && message.text.trim() !== '' && (message.toolResults?.length ?? 0) === 0) {
37      prompts += 1
38    }
39    for (const use of message.toolUses) {
40      const path = use.input.file_path ?? use.input.notebook_path
41      if (WRITERS.has(use.tool) && typeof path === 'string') written.add(path)
42    }
43  }
44  return { prompts, changedFiles: written.size }
45}
46
types/index.d.ts 26 lines
1export type Memory = {
2  goal: string
3  decisions: string[]
4  findings: string[]
5  questions: string[]
6  next: string
7}
8
9export type MemoryStatus = {
10  // How many messages of the transcript the memory already covers.
11  cursor: number
12  isRunning: boolean
13}
14
15export type Tab = 'memory' | 'stats'
16
17declare module 'claude-code' {
18  interface PluginState {
19    'chat-memory': {
20      memory: Memory | null
21      status: MemoryStatus
22      tab: Tab
23    }
24  }
25}
26