SLOPSHOPPER

basic

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

newpanebandspinnerrowscommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · basic
│ ┃ Session Digest ✕ › fix the failing auth test and add an audit log call │ ┃ Haiku: │ ┃ 未実行(ステップの進行・ターン終了を待機中) ⏺ Read(src/auth.ts) │ ┃ [ 閉じる ] ⎿ Read 6 lines │ ┃ 要約: 10 ステップごと+ターン終了時 ⏺ Update(src/auth.ts) │ ┃ session-digest v1.0.0 ⎿ 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 │ │ › /session-digest │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Session Digest
Haiku: 未実行(ステップの進行・ターン終了を待機中) [ 閉じる ] 要約: 10 ステップごと+ターン終了時 session-digest v1.0.0
README

basic plugin

汎用スキルと 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 または再起動で更新を読み込みます。

session-digest ペイン

長いセッションで、人間が会話を読み返さなくても今の状況と押さえるべき変化を掴めるようにするためのペインです。対話型の terminal / Desktop でセッション開始時に自動で開きます(キーボードフォーカスは奪いません)。

何を要約するか

Haiku(haiku エイリアス)には、メインの会話の直近2万文字を渡します。最後の応答だけを渡すのではないので、最後のターンが小さくても、それまでの流れを踏まえた要約になります。会話記録に含めるのは次のとおりです。

  • ユーザーの依頼
  • エージェントの応答文
  • 呼んだツールの名前と、引数の先頭200文字(エラーになったものには印を付けます)

ツールの結果は長いので含めません。エージェントの応答文に、結果から分かったことが書かれているためです。$.session.messages() で会話を読めなかった場合は、ターンの最終応答だけを渡します。

Haiku は文章を書かず、テキストの図を 1 つだけ返します。図に入れるのは次の 4 種類で、該当しないものは省きます。

  • ゴールまでの流れと現在地(矢印でつなぎ、現在地に ★)
  • 意外な発見(! 付きの箱)
  • 仕様・方針の変更(旧 → 新 の形)
  • 人間の判断待ち(? 付きの箱)

箱や矢印の言葉は短い名詞句に限り、ファイル単位の作業や細かい手順は図に入れさせません。

いつ要約するか

次の 2 つのタイミングで Haiku を呼びます。呼び出しはエージェントの作業とは別に行うので、作業も次のプロンプトも待たせません。

  • ステップが 10 回進むごと:ステップは、1 ターンの中で Claude Code がモデルへ送るリクエスト 1 回です(mod API の turn.step)。モデルは 1 回のリクエストで考え、文章を書き、ツール呼び出しを決めます。ツールを並列で複数呼んでも 1 ステップです。
  • ターン終了時

ステップの数はメインの会話だけで数え、サブエージェントのステップとターンは数えません。数はターン終了時と Haiku を呼んだときに 0 に戻ります。次の呼び出しは「直前の要約から何ステップ進んだか」で決まるので、同じ記録を短い間隔で二重に要約しません。

Haiku の呼び出しは重なりません。前の要約が終わる前に次の呼び出し時期が来た場合、その呼び出しは待ち、さらに新しいものが来れば古いほうを捨てて最新だけを残します。会話は呼び出す直前に読むので、待った後の要約にも最新の会話が反映される仕組みです。

間隔は /config の plugin 設定 digestEverySteps(既定値 10)で変えられます。0 にするとターン終了時だけになります。

費用と待ち時間

1 回の入力は最大2万文字なので、呼び出し回数が増えても 1 回あたりの費用と待ち時間は変わりません。50 ステップのターンでは、ターン終了時だけの 1 回が 6 回になります。認証は Claude Code セッションのものを使うため、API キーは不要です。

表示と操作

表示内容は上から次のとおりです。

  • Haiku: <状態>:状態は未実行、リクエスト中、応答待ち、応答あり、失敗、スキップのどれかです。応答ありには何ステップ時点かターン終了時かと所要時間(ms)が、失敗には理由と HTTP ステータスが付きます。
  • 「再要約」「閉じる」ボタン
  • Haiku の図と使用トークン数
  • 要約の間隔(要約: 10 ステップごと+ターン終了時)
  • 最下部に mod の version(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 は次の公式ドキュメントを参照してください。

Source 1 files
hooks/session-digest.ts 190 lines
1// 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