汎用スキル: ドキュメント運用 / 徹底質問 (grill-me) / 10歳向け図解説明 (eli5) / 表現・デザイン思考 / セッション振り返り (retro) / スケジュール提案の抑制 / 意味のある単位でのコミット分割。書き込み直後に docs 構成違反と AI くさい日本語の癖を検知する…

汎用スキルと PostToolUse フックに加えて、長いセッションの直近の会話を Haiku で図にまとめ、右ペインに表示する Claude Code mod session-digest を同梱しています。
claude plugin install basic@sugiken-dev-public
# インストール済みの場合
claude plugin update basic@sugiken-dev-public
ローカルのソースを試す場合は、開発対象のプロジェクトルートから起動します。
claude --plugin-dir /absolute/path/to/sugiken-dev-plugins-public/plugins/basic
Claude Code CLI v2.1.287 以降が必要です。起動後、/plugin の mod 一覧で basic が読み込まれていることを確認してください。インストール済みのセッションでは /reload-plugins または再起動で更新を読み込みます。
長いセッションで、人間が会話を読み返さなくても今の状況と押さえるべき変化を掴めるようにするためのペインです。対話型の terminal / Desktop でセッション開始時に自動で開きます(キーボードフォーカスは奪いません)。
Haiku(haiku エイリアス)には、メインの会話の直近2万文字を渡します。最後の応答だけを渡すのではないので、最後のターンが小さくても、それまでの流れを踏まえた要約になります。会話記録に含めるのは次のとおりです。
ツールの結果は長いので含めません。エージェントの応答文に、結果から分かったことが書かれているためです。$.session.messages() で会話を読めなかった場合は、ターンの最終応答だけを渡します。
Haiku は文章を書かず、テキストの図を 1 つだけ返します。図に入れるのは次の 4 種類で、該当しないものは省きます。
! 付きの箱)旧 → 新 の形)? 付きの箱)箱や矢印の言葉は短い名詞句に限り、ファイル単位の作業や細かい手順は図に入れさせません。
次の 2 つのタイミングで Haiku を呼びます。呼び出しはエージェントの作業とは別に行うので、作業も次のプロンプトも待たせません。
turn.step)。モデルは 1 回のリクエストで考え、文章を書き、ツール呼び出しを決めます。ツールを並列で複数呼んでも 1 ステップです。ステップの数はメインの会話だけで数え、サブエージェントのステップとターンは数えません。数はターン終了時と Haiku を呼んだときに 0 に戻ります。次の呼び出しは「直前の要約から何ステップ進んだか」で決まるので、同じ記録を短い間隔で二重に要約しません。
Haiku の呼び出しは重なりません。前の要約が終わる前に次の呼び出し時期が来た場合、その呼び出しは待ち、さらに新しいものが来れば古いほうを捨てて最新だけを残します。会話は呼び出す直前に読むので、待った後の要約にも最新の会話が反映される仕組みです。
間隔は /config の plugin 設定 digestEverySteps(既定値 10)で変えられます。0 にするとターン終了時だけになります。
1 回の入力は最大2万文字なので、呼び出し回数が増えても 1 回あたりの費用と待ち時間は変わりません。50 ステップのターンでは、ターン終了時だけの 1 回が 6 回になります。認証は Claude Code セッションのものを使うため、API キーは不要です。
表示内容は上から次のとおりです。
Haiku: <状態>:状態は未実行、リクエスト中、応答待ち、応答あり、失敗、スキップのどれかです。応答ありには何ステップ時点かターン終了時かと所要時間(ms)が、失敗には理由と HTTP ステータスが付きます。要約: 10 ステップごと+ターン終了時)session-digest vX.Y.Z)右ペインの希望幅は画面幅の約30%です。Claude の最低幅・配置ルール、ドラッグ等で調整した幅が優先されます。
| コマンド | 動作 |
|---|---|
/session-digest | ペインを開き、フォーカスを移す |
/session-digest refresh | 直近の会話をすぐに要約する |
/session-digest off | ペインを閉じ、このセッションの自動表示を止める |
/session-digest on | 自動表示を再開する |
ペインが描画されるのは terminal と Desktop の Code タブだけで、VS Code のチャットパネルや claude -p には出ません。
claude plugin validate plugins/basic
claude plugin test plugins/basic
mod のテストは実セッション・ログイン・通信・モデル呼び出しを使わずに、イベントと描画ツリーを検証します。会話記録とモデルの応答はテスト内で差し替えます。実画面のレイアウト確認は別途必要です。
mod の API は次の公式ドキュメントを参照してください。
hooks/session-digest.ts 190 lines1// session-digest: a right pane with a Haiku digest of the recent conversation,
2// meant to lower what a person has to keep in mind during a long session.
3// Haiku runs every N main-loop steps (turn.step, one model request of the
4// turn) and at turn.complete, each time on the last MAX_INPUT characters of
5// the main conversation, not on the latest answer alone.
6const PANE = 'session-digest';
7const COMMAND = 'session-digest';
8const MODEL = 'haiku';
9const MAX_INPUT = 20000;
10const MAX_TOOL_INPUT = 200;
11const DEFAULT_EVERY = 10;
12export const VERSION = '1.0.0';
13
14const SYSTEM = [
15 'あなたは長い開発セッションを横で見ている人間のための要約者です。',
16 '目的は、人間が会話を読み返さなくても、今の状況と押さえるべき変化を一目で掴めるようにし、認知負荷を下げることです。',
17 '入力は直近の会話記録(ユーザーの依頼、エージェントの応答、呼んだツール)です。最後のやり取りだけでなく記録全体を読み、重要度の高いものを優先してください。',
18 '出力は文章ではなく、テキストの図だけにしてください。コードブロック1つだけを出力し、その外には何も書かないでください。',
19 '図に入れるもの(該当しないものは入れない):',
20 '* 目指しているゴールと、今どこまで進んだか(流れを矢印でつなぎ、現在地に ★ を付ける)',
21 '* 意外な発見(! を付けた箱)',
22 '* 仕様・方針の変更(「旧 → 新」の形)',
23 '* 人間の判断待ち(? を付けた箱)',
24 '箱や矢印に書く言葉は短い名詞句にし、文にしない。1行は全角20文字以内、全体で25行以内。',
25 'ファイル単位の作業や細かい手順は図に入れない。推測は書かず、記録に無いことは書かない。',
26].join('\n');
27
28type Job = { label: string; fallback?: string };
29
30let interactive = false, enabled = true, opened = false, columns = 0, wanted = 0;
31const IDLE = '未実行(ステップの進行・ターン終了を待機中)';
32let status = IDLE, summary = '', tokens = '';
33let every = DEFAULT_EVERY;
34// Haiku calls never overlap: one runs at a time and only the newest waiting
35// job is kept. `generation` drops results from before a /clear.
36let busy = false, pending: Job | undefined, generation = 0;
37// Main-loop steps since the last call; reset at each call and at turn end.
38let stepsTotal = 0, stepsSinceCall = 0;
39let lastJob: Job | undefined;
40const redraw = ($: any) => $.ui.invalidate('ui.render');
41const safe = async (fn: () => Promise<any>) => { try { return await fn(); } catch { return undefined; } };
42
43async function open($: any, focus = false) {
44 if (!interactive) return;
45 const result = await $.ui.open({ id: PANE, title: 'Session Digest',
46 ...(wanted > 0 ? { columns: wanted } : {}), ...(focus ? { focus: true } : {}) });
47 opened = true;
48 columns = wanted;
49 if (result?.isPlaced === false) status = '端末幅待ち。/' + COMMAND + ' で開けます';
50}
51
52function request($: any, job: Job) {
53 lastJob = job;
54 if (busy) { pending = job; status = MODEL + ' 応答待ち(次: ' + job.label + ')'; redraw($); return; }
55 void run($, job);
56}
57
58async function run($: any, job: Job) {
59 busy = true;
60 const gen = generation;
61 await summarize($, job, gen);
62 busy = false;
63 const next = pending;
64 pending = undefined;
65 if (next) await run($, next);
66}
67
68async function summarize($: any, job: Job, gen: number) {
69 const started = Date.now();
70 // Read the conversation now, so a job that waited sees the newest rows.
71 const messages = await safe(() => $.session.messages());
72 const body = (Array.isArray(messages) ? transcript(messages) : '') || (job.fallback ?? '').slice(-MAX_INPUT);
73 if (!body) { if (gen === generation) { status = 'スキップ: 会話が空'; redraw($); } return; }
74 status = MODEL + ' にリクエスト中…(' + job.label + ')';
75 redraw($);
76 const r = await safe(() => $.model.complete({ model: MODEL, system: SYSTEM,
77 prompt: '【直近の会話記録(' + job.label + ')】\n\n' + body, maxTokens: 2000, timeoutMs: 90000 }));
78 if (gen !== generation) return; // Dropped by /clear.
79 const ms = Date.now() - started;
80 if (r?.isAnswered) {
81 const u = r.usage ?? {};
82 summary = r.text;
83 tokens = `入力 ${(u.input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0)} / 出力 ${u.output_tokens ?? 0} トークン`;
84 status = MODEL + ' 応答あり(' + job.label + '、' + ms + ' ms)';
85 } else {
86 status = MODEL + ' 失敗(' + job.label + '、' + ms + ' ms): ' + (r ? r.reason + (r.status ? ' ' + r.status : '') : '呼び出し不可');
87 }
88 redraw($);
89}
90
91// The main conversation as plain text, tool results left out (they are long
92// and the agent's own text says what it learned from them).
93function transcript(messages: readonly any[]) {
94 const parts: string[] = [];
95 for (const m of messages) {
96 const text = typeof m.text === 'string' ? m.text.trim() : '';
97 if (m.role === 'user') { if (text) parts.push('### ユーザー\n' + text); continue; }
98 const lines = text ? ['### エージェント', text] : [];
99 for (const t of m.toolUses ?? []) {
100 let input = '';
101 try { input = JSON.stringify(t.input) ?? ''; } catch { /* unserializable input */ }
102 lines.push('ツール: ' + t.tool + ' ' + input.slice(0, MAX_TOOL_INPUT) + (t.isError ? '(エラー)' : ''));
103 }
104 if (lines.length) parts.push(lines.join('\n'));
105 }
106 return parts.join('\n\n').slice(-MAX_INPUT);
107}
108
109export function register(on: any, options: any = {}) {
110 const n = Number(options.digestEverySteps);
111 every = Number.isFinite(n) && n >= 0 ? Math.floor(n) : DEFAULT_EVERY;
112
113 on('session.start', async ($: any, e: any, next: any) => {
114 interactive = e.isInteractive && (e.surface === 'terminal' || e.surface === 'desktop');
115 await $.command.register({ name: COMMAND, description: 'Haiku のセッション要約を表示 (off / on / refresh)', immediate: true });
116 await safe(() => open($));
117 return next(e);
118 });
119
120 on('turn.step', async function* ($: any, e: any, next: any) {
121 const result = yield* next(e);
122 if (e.agentId || !enabled || !interactive || !result) return result;
123 stepsTotal++;
124 stepsSinceCall++;
125 if (every > 0 && stepsSinceCall >= every) {
126 stepsSinceCall = 0;
127 const label = 'ターン途中、' + stepsTotal + ' ステップ時点';
128 if (!opened) await safe(() => open($));
129 // Outside this dispatch so the step never waits on the model.
130 await safe(() => $.clock.after(0, () => { request($, { label }); }));
131 }
132 return result;
133 });
134
135 on('turn.complete', async ($: any, e: any, next: any) => {
136 const result = await next(e);
137 if (e.agentId) return result;
138 const answer = typeof e.answer === 'string' ? e.answer.trim() : '';
139 stepsTotal = 0; stepsSinceCall = 0;
140 if (!enabled) return result;
141 if (!interactive) { status = 'スキップ: 非対話'; return result; }
142 if (!opened) await safe(() => open($));
143 // Outside this dispatch so the turn never waits on the model.
144 await safe(() => $.clock.after(0, () => { request($, { label: 'ターン終了時', fallback: answer }); }));
145 return result;
146 });
147
148 on('classic.SessionStart', async ($: any, e: any, next: any) => {
149 if (e.source === 'clear') {
150 generation++; pending = undefined; lastJob = undefined; summary = ''; tokens = '';
151 stepsTotal = 0; stepsSinceCall = 0;
152 status = IDLE; redraw($);
153 }
154 return next(e);
155 });
156
157 on('command.run', { command: COMMAND }, async ($: any, e: any) => {
158 const arg = e.args.trim();
159 if (arg === 'off') { enabled = false; opened = false; await $.ui.close({ id: PANE }); return {}; }
160 if (arg && !['on', 'refresh'].includes(arg)) return { text: '/' + COMMAND + ' [on | off | refresh]' };
161 enabled = true;
162 await open($, true);
163 if (arg === 'refresh') request($, { label: '手動' });
164 return interactive ? {} : { text: 'Session Digest ペインは対話型 terminal / Desktop で表示できます。' };
165 });
166
167 on('ui.close', async ($: any, e: any, next: any) => {
168 if (e.id === PANE && e.origin.kind === 'person') { enabled = false; opened = false; }
169 return next(e);
170 });
171
172 on('ui.render', async ($: any, e: any, next: any) => {
173 if (e.viewport?.columns > 0) wanted = Math.max(1, Math.round(e.viewport.columns * 0.3));
174 if (e.component !== 'Pane' || e.requestId !== PANE) return next(e);
175 if (opened && wanted !== columns) await safe(() => open($));
176 const { Box, Text, Button, Markdown } = $.ui.resolve(e);
177 const text = (s: string, dim = false) => Text({ children: [s], ...(dim ? { dimColor: true } : {}) });
178 const children: any[] = [text('Haiku: ' + status, true),
179 Box({ flexDirection: 'row', columnGap: 1, children: [
180 ...(lastJob ? [Button({ key: 'analyze', label: '再要約', onPress: async () => { request($, { label: '手動' }); } })] : []),
181 Button({ key: 'close', label: '閉じる', onPress: async () => { enabled = false; opened = false; await $.ui.close({ id: PANE }); } }),
182 ] }),
183 ];
184 if (summary) children.push(Markdown({ text: summary }), text(tokens, true));
185 children.push(text('要約: ' + (every > 0 ? every + ' ステップごと+ターン終了時' : 'ターン終了時のみ'), true),
186 text('session-digest v' + VERSION, true));
187 return Box({ flexDirection: 'column', children });
188 });
189}
190