throughline 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * token-monitor.mjs — マルチセッション対応トークンモニター
4
+ *
5
+ * 使い方:
6
+ * throughline monitor 現在のプロジェクトの全 active セッション
7
+ * throughline monitor --all 全プロジェクト全セッション
8
+ * throughline monitor --session <id> 特定セッションのみ
9
+ *
10
+ * VS Code の分割ターミナルなどで常時起動しておく。
11
+ *
12
+ * 設計: docs/PUBLIC_RELEASE_PLAN.md §4.5/4.6
13
+ * - 状態ファイルはセッション単位 (~/.throughline/state/<session_id>.json)
14
+ * - setInterval (1s) + mtime 差分検知で更新を捕捉
15
+ * - updatedAt 降順ソート、先頭行を ▶ でハイライト
16
+ * - stale は PID 生存チェックで判定
17
+ * - トークン数は transcript JSONL の最新 assistant usage を直読
18
+ */
19
+
20
+ import { basename } from 'node:path';
21
+ import { getStateDir, readAllSessionStates, snapshotStateMtimes, normalizeProjectPath } from './state-file.mjs';
22
+ import { readLatestUsage } from './transcript-usage.mjs';
23
+ import { stripAnsi } from './transcript-reader.mjs';
24
+
25
+ const REFRESH_MS = 1000;
26
+
27
+ // --- ANSI ---
28
+ const ANSI = {
29
+ hideCursor: '\x1b[?25l',
30
+ showCursor: '\x1b[?25h',
31
+ clearLine: '\x1b[2K',
32
+ clearScreen: '\x1b[2J\x1b[H',
33
+ clearBelow: '\x1b[0J', // 現在位置から画面末尾までをクリア
34
+ up: (n) => `\x1b[${n}A`, // CUU: カーソルを N 行上へ (列は変えない)
35
+ reset: '\x1b[0m',
36
+ dim: '\x1b[2m',
37
+ bold: '\x1b[1m',
38
+ green: '\x1b[32m',
39
+ yellow: '\x1b[33m',
40
+ red: '\x1b[31m',
41
+ cyan: '\x1b[36m',
42
+ };
43
+
44
+ function color(c, text) {
45
+ return `${c}${text}${ANSI.reset}`;
46
+ }
47
+
48
+ /** ANSI エスケープシーケンスを除いた可視文字数を返す(サロゲートペア考慮はしない簡易版) */
49
+ function visibleLength(s) {
50
+ return stripAnsi(s).length;
51
+ }
52
+
53
+ /**
54
+ * 行をターミナル幅に収まるよう切り詰める。ANSI コードを壊さないため、
55
+ * 可視文字だけを数えながらコピーし、上限に達したら reset を付けて返す。
56
+ * @param {string} line
57
+ * @param {number} maxWidth
58
+ */
59
+ function truncateToWidth(line, maxWidth) {
60
+ if (maxWidth <= 0) return '';
61
+ if (visibleLength(line) <= maxWidth) return line;
62
+ let out = '';
63
+ let visible = 0;
64
+ let i = 0;
65
+ while (i < line.length && visible < maxWidth) {
66
+ const ch = line[i];
67
+ if (ch === '\x1b' && line[i + 1] === '[') {
68
+ // ANSI sequence: copy until final byte (a-zA-Z)
69
+ const end = line.slice(i).search(/[a-zA-Z]/);
70
+ if (end === -1) break;
71
+ out += line.slice(i, i + end + 1);
72
+ i += end + 1;
73
+ continue;
74
+ }
75
+ out += ch;
76
+ visible++;
77
+ i++;
78
+ }
79
+ return out + ANSI.reset;
80
+ }
81
+
82
+ // --- CLI 引数 ---
83
+ function parseArgs(argv) {
84
+ const args = { all: false, session: null };
85
+ for (let i = 0; i < argv.length; i++) {
86
+ if (argv[i] === '--all') args.all = true;
87
+ else if (argv[i] === '--session') args.session = argv[++i];
88
+ }
89
+ return args;
90
+ }
91
+
92
+ // --- 表示 ---
93
+ function renderBar(ratio, width = 20) {
94
+ const filled = Math.max(0, Math.min(width, Math.round(ratio * width)));
95
+ return '█'.repeat(filled) + '░'.repeat(width - filled);
96
+ }
97
+
98
+ function formatNumber(n) {
99
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + 'M';
100
+ if (n >= 1_000) return (n / 1_000).toFixed(1) + 'k';
101
+ return String(n);
102
+ }
103
+
104
+ function formatLine({ state, usage, isActive }) {
105
+ const project = basename(state.projectPath || '?');
106
+ const shortId = state.sessionId.slice(0, 8);
107
+ const tokens = usage?.tokens ?? 0;
108
+ const max = usage?.contextWindowSize ?? 200_000;
109
+ const ratio = max > 0 ? tokens / max : 0;
110
+ const pct = Math.round(ratio * 100);
111
+ const remaining = Math.max(0, max - tokens);
112
+
113
+ const bar = renderBar(ratio);
114
+ const barColor =
115
+ ratio >= 0.9 ? ANSI.red :
116
+ ratio >= 0.7 ? ANSI.yellow :
117
+ ANSI.green;
118
+
119
+ const warn =
120
+ ratio >= 0.9 ? color(ANSI.red, ' ⚠ /clear 強く推奨') :
121
+ ratio >= 0.7 ? color(ANSI.yellow, ' ⚠ そろそろ /clear') :
122
+ '';
123
+
124
+ const marker = isActive ? color(ANSI.bold + ANSI.cyan, '▶') : ' ';
125
+ const projectCol = project.padEnd(18).slice(0, 18);
126
+ const idCol = color(ANSI.dim, shortId);
127
+ const barCol = color(barColor, bar);
128
+ const tokCol = `${formatNumber(tokens).padStart(6)} / ${pct.toString().padStart(3)}%`;
129
+ const remCol = color(ANSI.dim, `残 ${formatNumber(remaining)}`);
130
+ const modelCol = usage?.model ? color(ANSI.dim, usage.model) : color(ANSI.dim, '(未取得)');
131
+
132
+ return `${marker} ${projectCol} ${idCol} ${barCol} ${tokCol} ${remCol} ${modelCol}${warn}`;
133
+ }
134
+
135
+ // --- フィルタ ---
136
+ function filterStates(states, args, cwd) {
137
+ // stale (15 分無活動) は基本非表示。--all のときだけ stale も含める
138
+ let base = args.all ? states : states.filter((s) => !s.stale);
139
+ if (args.session) {
140
+ return states.filter((s) => s.sessionId === args.session || s.sessionId.startsWith(args.session));
141
+ }
142
+ if (args.all) return base;
143
+ const normalizedCwd = normalizeProjectPath(cwd);
144
+ return base.filter((s) => s.projectPath === normalizedCwd);
145
+ }
146
+
147
+ // --- メインループ ---
148
+ let lastRenderedLines = 0;
149
+ let lastMtimes = new Map();
150
+
151
+ function needsRerender() {
152
+ const current = snapshotStateMtimes();
153
+ if (current.size !== lastMtimes.size) {
154
+ lastMtimes = current;
155
+ return true;
156
+ }
157
+ for (const [name, mtime] of current) {
158
+ if (lastMtimes.get(name) !== mtime) {
159
+ lastMtimes = current;
160
+ return true;
161
+ }
162
+ }
163
+ // mtime は同じでも transcript JSONL のサイズが変われば再描画したい
164
+ // → transcript-usage のキャッシュ判定に任せるため毎秒呼ぶ設計。
165
+ // state ファイル変化なしでも再計算は走らせる(キャッシュヒット時は軽量)
166
+ return true;
167
+ }
168
+
169
+ function renderFrame(args) {
170
+ const states = readAllSessionStates();
171
+ const filtered = filterStates(states, args, process.cwd()).sort(
172
+ (a, b) => b.updatedAt - a.updatedAt,
173
+ );
174
+
175
+ const lines = [];
176
+ if (filtered.length === 0) {
177
+ lines.push(color(ANSI.dim, '[Throughline] 待機中 — アクティブなセッションがありません'));
178
+ if (!args.all) {
179
+ lines.push(color(ANSI.dim, ` (${normalizeProjectPath(process.cwd())} に state 無し。--all で全プロジェクト表示)`));
180
+ }
181
+ } else {
182
+ const header = color(
183
+ ANSI.bold,
184
+ `[Throughline] ${filtered.length} セッション${args.all ? ' (--all)' : ''}`,
185
+ );
186
+ lines.push(header);
187
+ for (let i = 0; i < filtered.length; i++) {
188
+ const state = filtered[i];
189
+ const usage = state.transcriptPath ? readLatestUsage(state.transcriptPath) : null;
190
+ lines.push(formatLine({ state, usage, isActive: i === 0 }));
191
+ }
192
+ }
193
+
194
+ // ★ 折り返し対策: 各行を (columns - 1) 幅に切り詰めて物理 1 行に収める。
195
+ // こうすれば ANSI.up(lines.length) と論理行数が物理行数と一致する。
196
+ // columns - 1 にしてるのはターミナル末尾列に書くと自動改行する端末があるため。
197
+ const columns = process.stdout.columns && process.stdout.columns > 10
198
+ ? process.stdout.columns - 1
199
+ : 120;
200
+ const clipped = lines.map((l) => truncateToWidth(l, columns));
201
+
202
+ // 前フレームを消去してから再描画:
203
+ // 1. カーソルを前フレームの先頭行へ戻す (CUU = 行移動のみ)
204
+ // 2. 列 1 へ戻す (CR)
205
+ // 3. 現在位置から画面末尾までを一括消去 (ED 0)
206
+ // CPL (\x1b[nF) は VSCode 統合ターミナルで挙動が不安定だったため使わない
207
+ if (lastRenderedLines > 0) {
208
+ process.stdout.write(ANSI.up(lastRenderedLines) + '\r' + ANSI.clearBelow);
209
+ }
210
+
211
+ process.stdout.write(clipped.join('\n') + '\n');
212
+ lastRenderedLines = clipped.length;
213
+ }
214
+
215
+ // --- 起動 ---
216
+ function main() {
217
+ const args = parseArgs(process.argv.slice(2));
218
+
219
+ process.stdout.write(ANSI.hideCursor);
220
+ process.stdout.write(color(ANSI.dim, `[Throughline] モニター起動 (state: ${getStateDir()}, Ctrl+C で終了)\n`));
221
+
222
+ renderFrame(args);
223
+ const timer = setInterval(() => {
224
+ if (needsRerender()) renderFrame(args);
225
+ }, REFRESH_MS);
226
+
227
+ const shutdown = () => {
228
+ clearInterval(timer);
229
+ process.stdout.write('\n' + ANSI.showCursor);
230
+ process.stdout.write(color(ANSI.dim, '[Throughline] モニター終了\n'));
231
+ process.exit(0);
232
+ };
233
+ process.on('SIGINT', shutdown);
234
+ process.on('SIGTERM', shutdown);
235
+ }
236
+
237
+ main();
@@ -0,0 +1,364 @@
1
+ /**
2
+ * transcript-reader.mjs
3
+ * Claude Code のトランスクリプト JSONL を解析するモジュール。
4
+ *
5
+ * 実際のフォーマット(確認済み):
6
+ * {type: "user", message: {role: "user", content: [{type:"text", text:"..."}]}, ...}
7
+ * {type: "assistant", message: {role: "assistant", content: [{type:"text", text:"..."}, {type:"thinking", ...}]}, ...}
8
+ * 他に queue-operation, attachment, file-history-snapshot 等があるが無視する
9
+ */
10
+
11
+ import { readFileSync, existsSync } from 'fs';
12
+ import { DETAIL_KIND } from './constants.mjs';
13
+
14
+ /**
15
+ * content 配列からテキスト部分だけを結合する。
16
+ * thinking ブロックは除外。
17
+ * @param {unknown} content
18
+ * @returns {string}
19
+ */
20
+ function extractText(content) {
21
+ if (typeof content === 'string') return content;
22
+ if (Array.isArray(content)) {
23
+ return content
24
+ .filter((b) => b && b.type === 'text' && typeof b.text === 'string')
25
+ .map((b) => b.text)
26
+ .join('');
27
+ }
28
+ return String(content ?? '');
29
+ }
30
+
31
+ /**
32
+ * トランスクリプト JSONL ファイルを読んで全ターンを返す。
33
+ * @param {string} transcriptPath
34
+ * @returns {Array<{role: string, content: string, turn_number: number}>}
35
+ */
36
+ export function readTranscript(transcriptPath) {
37
+ if (!transcriptPath || !existsSync(transcriptPath)) return [];
38
+
39
+ // existsSync で早期 return 済み。ここでの read 失敗は権限エラー等の本物の異常なので throw させる (§0 ルール)
40
+ const raw = readFileSync(transcriptPath, 'utf8');
41
+
42
+ const turns = [];
43
+ for (const line of raw.split('\n')) {
44
+ const trimmed = line.trim();
45
+ if (!trimmed) continue;
46
+
47
+ let entry;
48
+ try {
49
+ entry = JSON.parse(trimmed);
50
+ } catch {
51
+ continue;
52
+ }
53
+
54
+ // user / assistant エントリのみ対象
55
+ if (entry.type !== 'user' && entry.type !== 'assistant') continue;
56
+
57
+ const msg = entry.message;
58
+ if (!msg || !msg.role || msg.content == null) continue;
59
+
60
+ const text = extractText(msg.content);
61
+ if (!text) continue;
62
+
63
+ turns.push({
64
+ role: msg.role,
65
+ content: text,
66
+ turn_number: turns.length,
67
+ });
68
+ }
69
+
70
+ return turns;
71
+ }
72
+
73
+ /**
74
+ * ANSI エスケープシーケンスを除去する。
75
+ * ツール出力(特に Bash)にしばしば含まれる色コードを剥がす。
76
+ * @param {string} s
77
+ */
78
+ export function stripAnsi(s) {
79
+ if (typeof s !== 'string') return s;
80
+ // eslint-disable-next-line no-control-regex
81
+ return s.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '');
82
+ }
83
+
84
+ /**
85
+ * tool_result の content フィールドを単一テキストに正規化する。
86
+ * 実際のフォーマット:
87
+ * - string: そのまま
88
+ * - Array<{type:"text", text:string} | {type:"image", ...}>: text を結合、
89
+ * image は `[image]` プレースホルダ
90
+ * @param {unknown} content
91
+ * @returns {string}
92
+ */
93
+ export function normalizeToolResultContent(content) {
94
+ if (typeof content === 'string') return content;
95
+ if (Array.isArray(content)) {
96
+ return content
97
+ .map((b) => {
98
+ if (b && b.type === 'text' && typeof b.text === 'string') return b.text;
99
+ if (b && b.type === 'image') return '[image]';
100
+ return '';
101
+ })
102
+ .join('');
103
+ }
104
+ return '';
105
+ }
106
+
107
+ /**
108
+ * transcript JSONL を 1 行ずつ解析して、生エントリ配列を返す。
109
+ * 未知 type や parse 失敗は skip(§0 ルール: 上位で扱う)。
110
+ *
111
+ * @param {string} transcriptPath
112
+ * @returns {Array<object>}
113
+ */
114
+ export function readRawEntries(transcriptPath) {
115
+ if (!transcriptPath || !existsSync(transcriptPath)) return [];
116
+ const raw = readFileSync(transcriptPath, 'utf8');
117
+ const entries = [];
118
+ for (const line of raw.split('\n')) {
119
+ const trimmed = line.trim();
120
+ if (!trimmed) continue;
121
+ try {
122
+ entries.push(JSON.parse(trimmed));
123
+ } catch {
124
+ // 末尾 partial-write は JSONL 仕様上の許容
125
+ continue;
126
+ }
127
+ }
128
+ return entries;
129
+ }
130
+
131
+ /**
132
+ * 現在の「論理ターン」を構成するエントリ範囲を切り出す。
133
+ * 定義: 最後の assistant text ブロック (= Stop 時点の Claude 最終応答) を含むターン
134
+ * = 1 つ前の user text エントリの次から、最後の assistant エントリまで。
135
+ *
136
+ * 論理ターンの構造:
137
+ * user(text) ← このターンの開始
138
+ * assistant(thinking + tool_use)
139
+ * user(tool_result)
140
+ * assistant(text) ← このターンの終わり
141
+ *
142
+ * 間にある attachment / system エントリも同範囲に含める。
143
+ *
144
+ * @param {Array<object>} entries readRawEntries の結果
145
+ * @returns {Array<object>} 論理ターンに属するエントリのスライス
146
+ */
147
+ export function sliceCurrentTurnEntries(entries) {
148
+ if (!entries.length) return [];
149
+
150
+ // 最後の assistant text ブロックを含むエントリを探す
151
+ let lastAssistantTextIdx = -1;
152
+ for (let i = entries.length - 1; i >= 0; i--) {
153
+ const e = entries[i];
154
+ if (e.type !== 'assistant') continue;
155
+ const blocks = e.message?.content;
156
+ if (!Array.isArray(blocks)) continue;
157
+ if (blocks.some((b) => b && b.type === 'text' && typeof b.text === 'string' && b.text.length > 0)) {
158
+ lastAssistantTextIdx = i;
159
+ break;
160
+ }
161
+ }
162
+ if (lastAssistantTextIdx < 0) return [];
163
+
164
+ // そこから遡って、最後の user text ブロックを含むエントリを探す
165
+ let userTextIdx = -1;
166
+ for (let i = lastAssistantTextIdx - 1; i >= 0; i--) {
167
+ const e = entries[i];
168
+ if (e.type !== 'user') continue;
169
+ const blocks = e.message?.content;
170
+ if (Array.isArray(blocks)) {
171
+ if (blocks.some((b) => b && b.type === 'text' && typeof b.text === 'string' && b.text.length > 0)) {
172
+ userTextIdx = i;
173
+ break;
174
+ }
175
+ } else if (typeof blocks === 'string' && blocks.length > 0) {
176
+ userTextIdx = i;
177
+ break;
178
+ }
179
+ }
180
+ if (userTextIdx < 0) return [];
181
+
182
+ return entries.slice(userTextIdx, lastAssistantTextIdx + 1);
183
+ }
184
+
185
+ /**
186
+ * 論理ターン内の全エントリから L3 (details) 用の生レコードを抽出する。
187
+ *
188
+ * 返す各レコード:
189
+ * {
190
+ * kind: 'tool_input' | 'tool_output' | 'system',
191
+ * tool_name: string, // 表示用。system は 'SystemReminder' 等
192
+ * source_id: string, // 冪等再処理キー (tool_use.id / tool_use_id / uuid)
193
+ * input_text: string | null,
194
+ * output_text: string | null,
195
+ * }
196
+ *
197
+ * 分類ルール:
198
+ * - assistant の tool_use ブロック → tool_input (name, input を JSON 化して input_text に)
199
+ * - user の tool_result ブロック → tool_output (content を output_text に、ANSI 剥離)
200
+ * - assistant/user の thinking ブロック → 破棄
201
+ * - assistant/user の text ブロック → 扱わない(L2 bodies 側の責務)
202
+ * - attachment entry (hook_success) → system (hookName + content を出力に)
203
+ * - system entry (stop_hook_summary) → skip(hook タイミング情報で意味なし)
204
+ * - image ブロック → placeholder で kind='image'
205
+ *
206
+ * @param {Array<object>} turnEntries sliceCurrentTurnEntries の結果
207
+ * @returns {Array<{kind: string, tool_name: string, source_id: string, input_text: string|null, output_text: string|null}>}
208
+ */
209
+ export function extractDetailBlocks(turnEntries) {
210
+ const out = [];
211
+ // tool_use の name を後で tool_result にも添付するためのマップ
212
+ const toolNameById = new Map();
213
+
214
+ for (const e of turnEntries) {
215
+ if (e.type === 'assistant') {
216
+ const blocks = e.message?.content;
217
+ if (!Array.isArray(blocks)) continue;
218
+ for (const b of blocks) {
219
+ if (!b || !b.type) continue;
220
+ if (b.type === 'tool_use' && typeof b.id === 'string') {
221
+ toolNameById.set(b.id, b.name ?? 'unknown');
222
+ out.push({
223
+ kind: DETAIL_KIND.TOOL_INPUT,
224
+ tool_name: b.name ?? 'unknown',
225
+ source_id: b.id,
226
+ input_text: JSON.stringify(b.input ?? null),
227
+ output_text: null,
228
+ });
229
+ } else if (b.type === 'image') {
230
+ out.push({
231
+ kind: DETAIL_KIND.IMAGE,
232
+ tool_name: 'image',
233
+ source_id: null,
234
+ input_text: null,
235
+ output_text: '[image]',
236
+ });
237
+ }
238
+ // text / thinking は扱わない
239
+ }
240
+ } else if (e.type === 'user') {
241
+ const blocks = e.message?.content;
242
+ if (!Array.isArray(blocks)) continue;
243
+ for (const b of blocks) {
244
+ if (!b || !b.type) continue;
245
+ if (b.type === 'tool_result') {
246
+ const toolUseId = b.tool_use_id ?? null;
247
+ const toolName = toolUseId && toolNameById.has(toolUseId)
248
+ ? toolNameById.get(toolUseId)
249
+ : 'unknown';
250
+ const rawOutput = normalizeToolResultContent(b.content);
251
+ out.push({
252
+ kind: DETAIL_KIND.TOOL_OUTPUT,
253
+ tool_name: toolName,
254
+ source_id: toolUseId ? `${toolUseId}:result` : null,
255
+ input_text: null,
256
+ output_text: stripAnsi(rawOutput),
257
+ });
258
+ } else if (b.type === 'image') {
259
+ out.push({
260
+ kind: DETAIL_KIND.IMAGE,
261
+ tool_name: 'image',
262
+ source_id: null,
263
+ input_text: null,
264
+ output_text: '[image]',
265
+ });
266
+ }
267
+ // text は扱わない
268
+ }
269
+ } else if (e.type === 'attachment') {
270
+ // attachment は Claude Code が会話に差し込むコンテキスト全般を表す汎用エンベロープ。
271
+ // 既知の種別(実機観測):
272
+ // hook_success, async_hook_response, hook_additional_context,
273
+ // deferred_tools_delta, mcp_instructions_delta, skill_listing,
274
+ // nested_memory, todo_reminder, command_permissions
275
+ // すべて L3 kind=system として捕捉する。ペイロードのフィールド名は種別ごとに
276
+ // 異なるため、共通のテキストフィールドを優先度順に試す。
277
+ const a = e.attachment;
278
+ if (!a || !a.type) continue;
279
+
280
+ const output =
281
+ (typeof a.content === 'string' && a.content) ||
282
+ (Array.isArray(a.content) && a.content.join('\n')) ||
283
+ (typeof a.stdout === 'string' && a.stdout) ||
284
+ (Array.isArray(a.addedBlocks) && a.addedBlocks.join('\n')) ||
285
+ (Array.isArray(a.addedLines) && a.addedLines.join('\n')) ||
286
+ // 既知フィールドがすべて空ならメタ情報を JSON で残す(情報ロスを避ける §0)
287
+ JSON.stringify(a);
288
+
289
+ // 種別名 + hook イベント名で tool_name を一意化
290
+ const toolName = a.hookEvent ? `${a.type}:${a.hookEvent}` : a.type;
291
+
292
+ out.push({
293
+ kind: DETAIL_KIND.SYSTEM,
294
+ tool_name: toolName,
295
+ source_id: e.uuid ?? null,
296
+ input_text: a.command ?? a.path ?? null,
297
+ output_text: stripAnsi(String(output)),
298
+ });
299
+ }
300
+ // type === 'system' (stop_hook_summary) や queue-operation / file-history-snapshot は skip
301
+ }
302
+
303
+ return out;
304
+ }
305
+
306
+ /**
307
+ * 最後のターン(最後の user または assistant メッセージ)を返す。
308
+ * @param {string} transcriptPath
309
+ * @returns {{role: string, content: string, turn_number: number} | null}
310
+ */
311
+ export function getLastTurn(transcriptPath) {
312
+ const turns = readTranscript(transcriptPath);
313
+ return turns.length > 0 ? turns[turns.length - 1] : null;
314
+ }
315
+
316
+ /**
317
+ * 最後の assistant ターンだけを返す。
318
+ * @param {string} transcriptPath
319
+ * @returns {{role: string, content: string, turn_number: number} | null}
320
+ */
321
+ export function getLastAssistantTurn(transcriptPath) {
322
+ const turns = readTranscript(transcriptPath);
323
+ for (let i = turns.length - 1; i >= 0; i--) {
324
+ if (turns[i].role === 'assistant') return turns[i];
325
+ }
326
+ return null;
327
+ }
328
+
329
+ /**
330
+ * 最後の assistant ターンと、それに対応する直前の user ターンをペアで返す。
331
+ * Stop フックで L2 (bodies) に 1 往復分を保存するために使う。
332
+ *
333
+ * user メッセージには tool_result のような合成メッセージも混じるが、
334
+ * readTranscript() は text ブロックだけを抽出しているので、text が
335
+ * 空の user メッセージは自動的に除外されている(= tool_result のみの行は弾かれる)。
336
+ *
337
+ * @param {string} transcriptPath
338
+ * @returns {{
339
+ * user: {role: string, content: string, turn_number: number} | null,
340
+ * assistant: {role: string, content: string, turn_number: number} | null
341
+ * }}
342
+ */
343
+ export function getLastTurnPair(transcriptPath) {
344
+ const turns = readTranscript(transcriptPath);
345
+ let assistantIdx = -1;
346
+ for (let i = turns.length - 1; i >= 0; i--) {
347
+ if (turns[i].role === 'assistant') {
348
+ assistantIdx = i;
349
+ break;
350
+ }
351
+ }
352
+ if (assistantIdx < 0) return { user: null, assistant: null };
353
+
354
+ // assistant の直前の user ターンを探す
355
+ let userTurn = null;
356
+ for (let i = assistantIdx - 1; i >= 0; i--) {
357
+ if (turns[i].role === 'user') {
358
+ userTurn = turns[i];
359
+ break;
360
+ }
361
+ }
362
+
363
+ return { user: userTurn, assistant: turns[assistantIdx] };
364
+ }