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

Плагин Claude Code: боковая панель «Память чата» и две кнопки в полосе над строкой ввода.
Панель открывается при старте сессии и командой /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 .hooks/register.tsx 277 lines1import { 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}
277hooks/chart.ts 47 lines1// 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, '&').replace(/</g, '<').replace(/>/g, '>')
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}
47hooks/digest.ts 110 lines1import 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}
110hooks/layout.ts 46 lines1import 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}
46types/index.d.ts 26 lines1export 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