Toasts once when the context crosses a threshold; /compact-advisor prints a ready-to-paste /compact line with branch, edited files, open tasks and the last goal

Tells you when the context is nearly full and hands you a /compact line that keeps what matters.
$.session.usage(), free) and, when it crosses the threshold (85% by default), toasts once: Context N% — /compact-advisor for a suggested /compact/compact), so it does not repeat every turn.Edit, Write, NotebookEdit, successful calls only)/compact-advisor prints a ready-to-paste line: /compact Keep: branch <b>; files edited: a, b, c; open tasks: x | y | z; last decision/goal: <first line of the last prompt>git rev-parse --abbrev-ref HEAD in the session folder- [ ] items of TODOS.md in the session folder, when presentNo band is drawn: context-gauge already shows the percentage above the prompt.
| Command | Effect |
|---|---|
/compact-advisor | Prints the suggested /compact line |
| Option | Default | Effect |
|---|---|---|
threshold | 85 | Context percentage that triggers the toast |
claude --plugin-dir /path/to/ModsTools/mods/compact-advisor
TODOS.md are taken, whatever section they are in.; inside tasks or the goal is turned into , so the line keeps its structure.claude plugin validate mods/compact-advisor
claude plugin test mods/compact-advisor # 10 testshooks/register.ts 79 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AdvisorState } from '../types'
5import { DEFAULT_THRESHOLD, EMPTY, compactLine, firstLine, isUserGoal, openTodos, relative, step, toastText, touch } from './advisor'
6
7const track = atom({ plugin: 'compact-advisor', key: 'track' } as const, EMPTY)
8
9async function branch($: EngineInterface): Promise<string | null> {
10 try {
11 const out = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd: await $.session.cwd() })
12 return out.exitCode === 0 ? out.stdout.trim() : null
13 } catch {
14 return null
15 }
16}
17
18async function todos($: EngineInterface): Promise<string[]> {
19 try {
20 return openTodos(await $.fs.read(`${await $.session.cwd()}/TODOS.md`))
21 } catch {
22 return []
23 }
24}
25
26async function check($: EngineInterface, threshold: number): Promise<void> {
27 const percent = (await $.session.usage()).context.percent
28 const state: AdvisorState = await read($, track)
29 const next = step(state.armed, percent, threshold)
30 if (next.armed !== state.armed) await update($, track, s => ({ ...s, armed: next.armed }))
31 if (next.toast && percent !== undefined) $.ui.toast(toastText(percent))
32}
33
34export const register: Register = (on, options) => {
35 const threshold = typeof options?.threshold === 'number' ? options.threshold : DEFAULT_THRESHOLD
36
37 on('session.start', async ($, e, next) => {
38 await $.command.register({
39 name: 'compact-advisor',
40 description: 'Print a ready-to-paste /compact line: branch, edited files, open tasks, last goal',
41 })
42
43 return next(e)
44 })
45
46 on('prompt.submit', async ($, e, next) => {
47 if (isUserGoal(e.origin.kind, e.text)) {
48 const goal = firstLine(e.text)
49 await update($, track, s => ({ ...s, goal }))
50 }
51
52 return next(e)
53 })
54
55 on('tool.call', async ($, e, next) => {
56 const ran = await next(e)
57 if (e.agentId !== undefined || ran.deny !== undefined || ran.isError === true) return ran
58 const path = e.tool === 'Edit' || e.tool === 'Write' ? e.file_path : e.tool === 'NotebookEdit' ? e.notebook_path : null
59 if (path !== null) {
60 const shown = relative(path, await $.session.cwd())
61 await update($, track, s => ({ ...s, files: touch(s.files, shown) }))
62 }
63
64 return ran
65 })
66
67 on('turn.complete', async ($, e, next) => {
68 if (e.agentId === undefined) await check($, threshold)
69
70 return next(e)
71 })
72
73 on('command.run', { command: 'compact-advisor' }, async $ => {
74 const state: AdvisorState = await read($, track)
75
76 return { text: compactLine({ branch: await branch($), files: state.files, todos: await todos($), goal: state.goal }) }
77 })
78}
79hooks/advisor.ts 72 lines1// Pure logic: when to toast, what to remember, and the suggested /compact line.
2import type { AdvisorState } from '../types'
3
4export const DEFAULT_THRESHOLD = 85
5export const REARM_GAP = 10
6export const MAX_FILES = 10
7export const MAX_TODOS = 3
8const KEEP_FILES = 50
9
10export const EMPTY: AdvisorState = { armed: true, files: [], goal: '' }
11
12// One toast per crossing: fires when armed and at or above the threshold, then re-arms below threshold - 10.
13export function step(armed: boolean, percent: number | undefined, threshold: number): { armed: boolean; toast: boolean } {
14 if (percent === undefined) return { armed, toast: false }
15 if (armed && percent >= threshold) return { armed: false, toast: true }
16 if (!armed && percent < threshold - REARM_GAP) return { armed: true, toast: false }
17
18 return { armed, toast: false }
19}
20
21export const toastText = (percent: number): string =>
22 `Context ${Math.round(percent)}% — /compact-advisor for a suggested /compact`
23
24export const firstLine = (text: string): string =>
25 text
26 .split('\n')
27 .map(l => l.trim())
28 .find(l => l !== '') ?? ''
29
30// A prompt the user typed (terminal, remote bridge or SDK host), not a slash command.
31export function isUserGoal(origin: string, text: string): boolean {
32 const line = firstLine(text)
33 return (origin === 'composer' || origin === 'bridge' || origin === 'sdk') && line !== '' && !line.startsWith('/')
34}
35
36// Most recent last, each path once.
37export const touch = (files: readonly string[], path: string): string[] =>
38 [...files.filter(f => f !== path), path].slice(-KEEP_FILES)
39
40export const relative = (path: string, cwd: string): string => {
41 const root = cwd.replace(/\/$/, '')
42 return path.startsWith(root + '/') ? path.slice(root.length + 1) : path
43}
44
45// The first open `- [ ]` items of a TODOS.md.
46export function openTodos(markdown: string, max = MAX_TODOS): string[] {
47 const items: string[] = []
48 for (const line of markdown.split('\n')) {
49 const m = /^\s*[-*+]\s+\[ \]\s+(.+)$/.exec(line)
50 if (m !== null) items.push((m[1] ?? '').trim())
51 if (items.length >= max) break
52 }
53
54 return items
55}
56
57const clean = (s: string) => s.replace(/;/g, ',').replace(/\s+/g, ' ').trim()
58
59export type Facts = { branch: string | null; files: readonly string[]; todos: readonly string[]; goal: string }
60
61// "/compact Keep: branch b; files edited: …; open tasks: …; last decision/goal: …", omitting what is unknown.
62export function compactLine(facts: Facts): string {
63 const parts: string[] = []
64 if (facts.branch !== null && facts.branch !== '') parts.push(`branch ${facts.branch}`)
65 const files = facts.files.slice(-MAX_FILES)
66 if (files.length > 0) parts.push(`files edited: ${files.join(', ')}`)
67 if (facts.todos.length > 0) parts.push(`open tasks: ${facts.todos.map(clean).join(' | ')}`)
68 if (facts.goal !== '') parts.push(`last decision/goal: ${clean(facts.goal)}`)
69
70 return parts.length === 0 ? '/compact' : `/compact Keep: ${parts.join('; ')}`
71}
72types/index.d.ts 15 lines1export type AdvisorState = {
2 /** True while a crossing of the threshold would toast. */
3 armed: boolean
4 /** Paths edited by the main agent, oldest first. */
5 files: string[]
6 /** First line of the last prompt the user typed. */
7 goal: string
8}
9
10declare module 'claude-code' {
11 interface PluginState {
12 'compact-advisor': { track: AdvisorState }
13 }
14}
15