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.
- package/LICENSE +21 -0
- package/README.md +329 -0
- package/bin/throughline.mjs +78 -0
- package/package.json +33 -0
- package/src/cli/doctor.mjs +98 -0
- package/src/cli/install.mjs +109 -0
- package/src/cli/status.mjs +41 -0
- package/src/constants.mjs +16 -0
- package/src/db.mjs +201 -0
- package/src/haiku-summarizer.mjs +100 -0
- package/src/resume-context.mjs +148 -0
- package/src/sc-detail.mjs +212 -0
- package/src/session-merger.mjs +127 -0
- package/src/session-merger.test.mjs +151 -0
- package/src/session-start.mjs +67 -0
- package/src/state-file.mjs +117 -0
- package/src/token-estimator.mjs +16 -0
- package/src/token-monitor.mjs +237 -0
- package/src/transcript-reader.mjs +364 -0
- package/src/transcript-reader.test.mjs +292 -0
- package/src/transcript-usage.mjs +128 -0
- package/src/turn-processor.mjs +272 -0
- package/src/turn-processor.test.mjs +155 -0
|
@@ -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
|
+
}
|