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,292 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {
4
+ stripAnsi,
5
+ normalizeToolResultContent,
6
+ sliceCurrentTurnEntries,
7
+ extractDetailBlocks,
8
+ } from './transcript-reader.mjs';
9
+ import { DETAIL_KIND } from './constants.mjs';
10
+
11
+ test('stripAnsi: ANSI 色コードを除去する', () => {
12
+ assert.equal(stripAnsi('\x1b[32mgreen\x1b[0m text'), 'green text');
13
+ assert.equal(stripAnsi('plain'), 'plain');
14
+ assert.equal(stripAnsi(''), '');
15
+ });
16
+
17
+ test('normalizeToolResultContent: string / array / image mix', () => {
18
+ assert.equal(normalizeToolResultContent('raw string'), 'raw string');
19
+ assert.equal(
20
+ normalizeToolResultContent([
21
+ { type: 'text', text: 'hello' },
22
+ { type: 'text', text: ' world' },
23
+ ]),
24
+ 'hello world',
25
+ );
26
+ assert.equal(
27
+ normalizeToolResultContent([
28
+ { type: 'text', text: 'before' },
29
+ { type: 'image', source: {} },
30
+ { type: 'text', text: 'after' },
31
+ ]),
32
+ 'before[image]after',
33
+ );
34
+ assert.equal(normalizeToolResultContent(null), '');
35
+ });
36
+
37
+ /** 単一 text ブロック user / assistant エントリを作る */
38
+ function userEntry(text) {
39
+ return { type: 'user', message: { role: 'user', content: [{ type: 'text', text }] } };
40
+ }
41
+ function asstTextEntry(text) {
42
+ return {
43
+ type: 'assistant',
44
+ message: { role: 'assistant', content: [{ type: 'text', text }] },
45
+ };
46
+ }
47
+ function asstToolUseEntry(id, name, input) {
48
+ return {
49
+ type: 'assistant',
50
+ message: { role: 'assistant', content: [{ type: 'tool_use', id, name, input }] },
51
+ };
52
+ }
53
+ function userToolResultEntry(toolUseId, content) {
54
+ return {
55
+ type: 'user',
56
+ message: {
57
+ role: 'user',
58
+ content: [{ type: 'tool_result', tool_use_id: toolUseId, content }],
59
+ },
60
+ };
61
+ }
62
+ function attachmentEntry(uuid, hookEvent, command, content) {
63
+ return {
64
+ type: 'attachment',
65
+ uuid,
66
+ attachment: { type: 'hook_success', hookEvent, command, content },
67
+ };
68
+ }
69
+
70
+ test('sliceCurrentTurnEntries: 最後の user text → 最後の assistant text を切り出す', () => {
71
+ const entries = [
72
+ userEntry('old prompt'),
73
+ asstTextEntry('old response'),
74
+ userEntry('current prompt'),
75
+ asstToolUseEntry('toolu_1', 'Bash', { command: 'ls' }),
76
+ userToolResultEntry('toolu_1', 'file1\nfile2'),
77
+ asstTextEntry('current response'),
78
+ ];
79
+ const slice = sliceCurrentTurnEntries(entries);
80
+ assert.equal(slice.length, 4);
81
+ assert.equal(slice[0].message.content[0].text, 'current prompt');
82
+ assert.equal(slice[3].message.content[0].text, 'current response');
83
+ });
84
+
85
+ test('sliceCurrentTurnEntries: 空配列なら空を返す', () => {
86
+ assert.deepEqual(sliceCurrentTurnEntries([]), []);
87
+ });
88
+
89
+ test('sliceCurrentTurnEntries: assistant text が無ければ空', () => {
90
+ const entries = [userEntry('hello'), asstToolUseEntry('t1', 'Read', { path: '/x' })];
91
+ assert.deepEqual(sliceCurrentTurnEntries(entries), []);
92
+ });
93
+
94
+ test('extractDetailBlocks: tool_use と tool_result をペアで抽出', () => {
95
+ const entries = [
96
+ userEntry('do it'),
97
+ asstToolUseEntry('toolu_42', 'Bash', { command: 'echo hi' }),
98
+ userToolResultEntry('toolu_42', 'hi\n'),
99
+ asstTextEntry('done'),
100
+ ];
101
+ const details = extractDetailBlocks(entries);
102
+ assert.equal(details.length, 2);
103
+
104
+ const [input, output] = details;
105
+ assert.equal(input.kind, DETAIL_KIND.TOOL_INPUT);
106
+ assert.equal(input.tool_name, 'Bash');
107
+ assert.equal(input.source_id, 'toolu_42');
108
+ assert.ok(input.input_text.includes('echo hi'));
109
+ assert.equal(input.output_text, null);
110
+
111
+ assert.equal(output.kind, DETAIL_KIND.TOOL_OUTPUT);
112
+ assert.equal(output.tool_name, 'Bash'); // tool_use からマップされる
113
+ assert.equal(output.source_id, 'toolu_42:result');
114
+ assert.equal(output.output_text, 'hi\n');
115
+ });
116
+
117
+ test('extractDetailBlocks: thinking / text ブロックは L3 に入れない', () => {
118
+ const entries = [
119
+ userEntry('prompt'),
120
+ {
121
+ type: 'assistant',
122
+ message: {
123
+ role: 'assistant',
124
+ content: [
125
+ { type: 'thinking', thinking: 'internal thoughts' },
126
+ { type: 'text', text: 'response' },
127
+ ],
128
+ },
129
+ },
130
+ ];
131
+ const details = extractDetailBlocks(entries);
132
+ assert.equal(details.length, 0);
133
+ });
134
+
135
+ test('extractDetailBlocks: attachment (hook_success) を system として抽出', () => {
136
+ const entries = [
137
+ userEntry('prompt'),
138
+ attachmentEntry('att-uuid-1', 'UserPromptSubmit', 'node hook.mjs', 'injected context'),
139
+ asstTextEntry('reply'),
140
+ ];
141
+ const details = extractDetailBlocks(entries);
142
+ assert.equal(details.length, 1);
143
+ assert.equal(details[0].kind, DETAIL_KIND.SYSTEM);
144
+ assert.equal(details[0].tool_name, 'hook_success:UserPromptSubmit');
145
+ assert.equal(details[0].source_id, 'att-uuid-1');
146
+ assert.equal(details[0].input_text, 'node hook.mjs');
147
+ assert.equal(details[0].output_text, 'injected context');
148
+ });
149
+
150
+ test('extractDetailBlocks: attachment (async_hook_response) を system として抽出', () => {
151
+ const entries = [
152
+ userEntry('prompt'),
153
+ {
154
+ type: 'attachment',
155
+ uuid: 'async-1',
156
+ attachment: {
157
+ type: 'async_hook_response',
158
+ hookName: 'Stop',
159
+ hookEvent: 'Stop',
160
+ stdout: 'hook output text',
161
+ stderr: '',
162
+ exitCode: 0,
163
+ },
164
+ },
165
+ asstTextEntry('reply'),
166
+ ];
167
+ const details = extractDetailBlocks(entries);
168
+ assert.equal(details.length, 1);
169
+ assert.equal(details[0].kind, DETAIL_KIND.SYSTEM);
170
+ assert.equal(details[0].tool_name, 'async_hook_response:Stop');
171
+ assert.equal(details[0].source_id, 'async-1');
172
+ assert.equal(details[0].output_text, 'hook output text');
173
+ });
174
+
175
+ test('extractDetailBlocks: attachment (nested_memory) も system として拾う', () => {
176
+ const entries = [
177
+ userEntry('prompt'),
178
+ {
179
+ type: 'attachment',
180
+ uuid: 'mem-1',
181
+ attachment: {
182
+ type: 'nested_memory',
183
+ path: 'C:\\Users\\x\\.claude\\rules\\x.md',
184
+ content: 'memory file body',
185
+ },
186
+ },
187
+ asstTextEntry('reply'),
188
+ ];
189
+ const details = extractDetailBlocks(entries);
190
+ assert.equal(details.length, 1);
191
+ assert.equal(details[0].tool_name, 'nested_memory');
192
+ assert.equal(details[0].input_text, 'C:\\Users\\x\\.claude\\rules\\x.md');
193
+ assert.equal(details[0].output_text, 'memory file body');
194
+ });
195
+
196
+ test('extractDetailBlocks: attachment (mcp_instructions_delta) の addedBlocks も拾う', () => {
197
+ const entries = [
198
+ userEntry('prompt'),
199
+ {
200
+ type: 'attachment',
201
+ uuid: 'mcp-1',
202
+ attachment: {
203
+ type: 'mcp_instructions_delta',
204
+ addedNames: ['plugin:foo'],
205
+ addedBlocks: ['## plugin:foo\nDo things'],
206
+ removedNames: [],
207
+ },
208
+ },
209
+ asstTextEntry('reply'),
210
+ ];
211
+ const details = extractDetailBlocks(entries);
212
+ assert.equal(details.length, 1);
213
+ assert.equal(details[0].tool_name, 'mcp_instructions_delta');
214
+ assert.ok(details[0].output_text.includes('plugin:foo'));
215
+ });
216
+
217
+ test('extractDetailBlocks: 未知の attachment 種別は JSON dump で残す (情報ロスゼロ)', () => {
218
+ const entries = [
219
+ userEntry('prompt'),
220
+ {
221
+ type: 'attachment',
222
+ uuid: 'unknown-1',
223
+ attachment: {
224
+ type: 'some_future_type',
225
+ foo: 'bar',
226
+ count: 42,
227
+ },
228
+ },
229
+ asstTextEntry('reply'),
230
+ ];
231
+ const details = extractDetailBlocks(entries);
232
+ assert.equal(details.length, 1);
233
+ assert.equal(details[0].tool_name, 'some_future_type');
234
+ assert.ok(details[0].output_text.includes('bar'));
235
+ assert.ok(details[0].output_text.includes('42'));
236
+ });
237
+
238
+ test('extractDetailBlocks: tool_output の ANSI コードは剥離される', () => {
239
+ const entries = [
240
+ userEntry('run it'),
241
+ asstToolUseEntry('t1', 'Bash', { command: 'ls' }),
242
+ userToolResultEntry('t1', '\x1b[32mgreen\x1b[0m file'),
243
+ asstTextEntry('ok'),
244
+ ];
245
+ const details = extractDetailBlocks(entries);
246
+ const output = details.find((d) => d.kind === DETAIL_KIND.TOOL_OUTPUT);
247
+ assert.equal(output.output_text, 'green file');
248
+ });
249
+
250
+ test('extractDetailBlocks: system (stop_hook_summary) と queue-operation はスキップ', () => {
251
+ const entries = [
252
+ userEntry('prompt'),
253
+ { type: 'system', subtype: 'stop_hook_summary', hookCount: 3 },
254
+ { type: 'queue-operation', op: 'enqueue' },
255
+ { type: 'file-history-snapshot', uuid: 'abc' },
256
+ asstTextEntry('reply'),
257
+ ];
258
+ const details = extractDetailBlocks(entries);
259
+ assert.equal(details.length, 0);
260
+ });
261
+
262
+ test('extractDetailBlocks: tool_result の content が配列でも処理できる', () => {
263
+ const entries = [
264
+ userEntry('fetch'),
265
+ asstToolUseEntry('t1', 'Read', { file_path: '/x' }),
266
+ userToolResultEntry('t1', [
267
+ { type: 'text', text: 'line1\n' },
268
+ { type: 'text', text: 'line2' },
269
+ ]),
270
+ asstTextEntry('done'),
271
+ ];
272
+ const details = extractDetailBlocks(entries);
273
+ const output = details.find((d) => d.kind === DETAIL_KIND.TOOL_OUTPUT);
274
+ assert.equal(output.output_text, 'line1\nline2');
275
+ });
276
+
277
+ test('extractDetailBlocks: 複数ツール連続呼び出しを全て拾う', () => {
278
+ const entries = [
279
+ userEntry('investigate'),
280
+ asstToolUseEntry('t1', 'Read', { path: '/a' }),
281
+ userToolResultEntry('t1', 'a contents'),
282
+ asstToolUseEntry('t2', 'Grep', { pattern: 'foo' }),
283
+ userToolResultEntry('t2', 'foo found'),
284
+ asstTextEntry('summary'),
285
+ ];
286
+ const details = extractDetailBlocks(entries);
287
+ assert.equal(details.length, 4);
288
+ assert.deepEqual(
289
+ details.map((d) => d.kind),
290
+ [DETAIL_KIND.TOOL_INPUT, DETAIL_KIND.TOOL_OUTPUT, DETAIL_KIND.TOOL_INPUT, DETAIL_KIND.TOOL_OUTPUT],
291
+ );
292
+ });
@@ -0,0 +1,128 @@
1
+ /**
2
+ * transcript-usage.mjs — Transcript JSONL の最新 assistant エントリから
3
+ * Anthropic API の実測 usage を抽出する(length/4 ヒューリスティックを置き換え)
4
+ *
5
+ * 各 assistant エントリの構造:
6
+ * {
7
+ * type: "assistant",
8
+ * message: {
9
+ * model: "claude-opus-4-6[1m]",
10
+ * usage: {
11
+ * input_tokens: 1234,
12
+ * cache_creation_input_tokens: 567,
13
+ * cache_read_input_tokens: 890,
14
+ * output_tokens: 42
15
+ * }
16
+ * }
17
+ * }
18
+ *
19
+ * 現在の context 使用量 = input_tokens + cache_creation_input_tokens + cache_read_input_tokens
20
+ * これは API が「このリクエストに投入された文脈サイズ」として返す実測値。
21
+ *
22
+ * 差分読みキャッシュ:
23
+ * path → { size, lastUsage, lastModel, lastReadAt }
24
+ * 次回呼び出し時、size が不変ならキャッシュ値を返す。
25
+ * 変化していれば全読みして末尾の assistant usage を更新。
26
+ * (差分 byte offset 読みは将来最適化。まずは全読みで正確性優先)
27
+ */
28
+
29
+ import { readFileSync, statSync, existsSync } from 'node:fs';
30
+
31
+ /**
32
+ * @typedef {Object} UsageSample
33
+ * @property {number} tokens - 文脈使用量 (input + cache_creation + cache_read)
34
+ * @property {string} model - モデル名 (e.g. "claude-opus-4-6[1m]")
35
+ * @property {number} contextWindowSize - 推定コンテキスト上限 (200_000 or 1_000_000)
36
+ * @property {number} outputTokens - output_tokens (参考)
37
+ */
38
+
39
+ /**
40
+ * モデル名 + 実測トークン数 + transcript 本文のヒントから context_window_size を推論する。
41
+ *
42
+ * 注意: Claude Code の transcript JSONL の `message.model` は base name のみで
43
+ * `[1m]` サフィックスが含まれない(実測確認済み 2026-04-15)。slug/entrypoint/version にも
44
+ * 1M コンテキスト識別子は無い。そのため純粋なモデル名推論では 1M セッションを検出できない。
45
+ *
46
+ * 検出優先順位:
47
+ * 1. モデル名に `[1m]` サフィックス
48
+ * 2. transcript 本文に `[1m]` / `1M context` 文字列(Claude の system prompt 由来)
49
+ * 3. 実測トークン数 > 200k(事後検出、フォールバック)
50
+ *
51
+ * @param {string} model
52
+ * @param {number} [observedTokens=0]
53
+ * @param {boolean} [rawHint=false] - transcript 本文に 1M 識別子が含まれるか
54
+ * @returns {number}
55
+ */
56
+ export function inferContextWindowSize(model, observedTokens = 0, rawHint = false) {
57
+ if (model && /\[1m\]/i.test(model)) return 1_000_000;
58
+ if (rawHint) return 1_000_000;
59
+ if (observedTokens > 200_000) return 1_000_000;
60
+ return 200_000;
61
+ }
62
+
63
+ /**
64
+ * transcript 本文に 1M コンテキスト識別子が含まれるかを判定する。
65
+ * Claude の system prompt に "claude-opus-4-6[1m]" や "(with 1M context)" が
66
+ * 含まれる場合、transcript JSONL の本文にも当該文字列が現れる。
67
+ *
68
+ * @param {string} raw
69
+ * @returns {boolean}
70
+ */
71
+ function hasContextWindowHint(raw) {
72
+ return /\[1m\]|1M context/i.test(raw);
73
+ }
74
+
75
+ /** @type {Map<string, {size: number, sample: UsageSample|null}>} */
76
+ const cache = new Map();
77
+
78
+ /**
79
+ * transcript JSONL から最新の assistant usage を抽出する
80
+ * @param {string} transcriptPath
81
+ * @returns {UsageSample | null}
82
+ */
83
+ export function readLatestUsage(transcriptPath) {
84
+ if (!transcriptPath || !existsSync(transcriptPath)) return null;
85
+
86
+ const { size } = statSync(transcriptPath);
87
+ const cached = cache.get(transcriptPath);
88
+ if (cached && cached.size === size) {
89
+ return cached.sample;
90
+ }
91
+
92
+ const raw = readFileSync(transcriptPath, 'utf8');
93
+ const rawHint = hasContextWindowHint(raw);
94
+ let latest = null;
95
+ for (const line of raw.split('\n')) {
96
+ const trimmed = line.trim();
97
+ if (!trimmed) continue;
98
+ let entry;
99
+ try {
100
+ entry = JSON.parse(trimmed);
101
+ } catch {
102
+ // JSONL 末尾の partial-write 行は skip する(JSONL 仕様上の許容)
103
+ continue;
104
+ }
105
+ if (entry.type !== 'assistant') continue;
106
+ const usage = entry.message?.usage;
107
+ if (!usage) continue;
108
+ const tokens =
109
+ (usage.input_tokens ?? 0) +
110
+ (usage.cache_creation_input_tokens ?? 0) +
111
+ (usage.cache_read_input_tokens ?? 0);
112
+ const model = entry.message?.model ?? '';
113
+ latest = {
114
+ tokens,
115
+ model,
116
+ contextWindowSize: inferContextWindowSize(model, tokens, rawHint),
117
+ outputTokens: usage.output_tokens ?? 0,
118
+ };
119
+ }
120
+
121
+ cache.set(transcriptPath, { size, sample: latest });
122
+ return latest;
123
+ }
124
+
125
+ /** キャッシュを全削除(テスト用) */
126
+ export function clearUsageCache() {
127
+ cache.clear();
128
+ }
@@ -0,0 +1,272 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Stop hook — L1 要約生成 + L2 本文保存 + turn_number 確定
4
+ *
5
+ * stdin: { session_id, transcript_path }
6
+ * 処理:
7
+ * 0. 【再帰ガード】環境変数 THROUGHLINE_IN_HAIKU_SUBPROCESS=1 が立っていたら即 exit
8
+ * (Haiku 要約用の claude -p subprocess 内で自分自身の Stop hook として起動された場合)
9
+ * 1. resolveMergeTarget で「実書き込み先 (target) / origin」を解決
10
+ * (input session が別セッションに合流済みなら合流先に書く)
11
+ * 2. 最後の assistant ターン + 直前の user ターンのペアを取得
12
+ * 3. L2 本文 (bodies) に user / assistant の 2 行を INSERT
13
+ * 4. 【遅延要約】target 配下の bodies ターン数 (distinct origin×turn) が
14
+ * WINDOW (=20) を超えていたら、最古の未要約ターンを 1 件だけ
15
+ * Haiku 4.5 で要約 → skeletons (L1) に INSERT。
16
+ * 20 ターン以内で作業が終わるケースでは Haiku コスト 0。
17
+ * /clear 跨ぎでも同様に、合流後のターン総数が 20 超えた時点から逐次発火。
18
+ * 失敗時は L2 全文をそのまま L1 に入れる(情報欠損ゼロ)
19
+ * 5. turn_number=NULL の details レコードを確定 (L3)
20
+ *
21
+ * schema v4 以降で動作。judgments テーブルは廃止済み。
22
+ */
23
+
24
+ // ★★★ 再帰暴走ガード ★★★
25
+ // haiku-summarizer が spawn する claude -p は独立した Claude Code セッションで、
26
+ // 同じ .claude/settings.json を読んで自分の Stop hook を起動する。放置すると
27
+ // turn-processor → claude -p → turn-processor → claude -p → ... の無限再帰で
28
+ // 大量の node プロセスが生まれ API 500 を引き起こす。
29
+ // haiku-summarizer が spawn 時に env.THROUGHLINE_IN_HAIKU_SUBPROCESS=1 を設定するので
30
+ // ここで即検出して exit する。env は child_process.spawn で継承される。
31
+ if (process.env.THROUGHLINE_IN_HAIKU_SUBPROCESS === '1') {
32
+ process.exit(0);
33
+ }
34
+
35
+ import { getDb } from './db.mjs';
36
+ import {
37
+ getLastTurnPair,
38
+ readRawEntries,
39
+ sliceCurrentTurnEntries,
40
+ extractDetailBlocks,
41
+ } from './transcript-reader.mjs';
42
+ import { resolveMergeTarget } from './session-merger.mjs';
43
+ import { writeSessionState } from './state-file.mjs';
44
+ import { summarizeToL1 } from './haiku-summarizer.mjs';
45
+
46
+ /** 直近 N ターンは bodies を生で残し、それより古いものだけ L1 要約する。 */
47
+ export const L2_WINDOW = 20;
48
+
49
+ /**
50
+ * target 配下の distinct (origin_session_id, turn_number) ターン数を返す。
51
+ * @param {import('node:sqlite').DatabaseSync} db
52
+ * @param {string} target
53
+ */
54
+ export function countDistinctBodyTurns(db, target) {
55
+ const row = db
56
+ .prepare(
57
+ `SELECT COUNT(*) AS c FROM (
58
+ SELECT DISTINCT origin_session_id, turn_number
59
+ FROM bodies
60
+ WHERE session_id = ?
61
+ )`,
62
+ )
63
+ .get(target);
64
+ return row?.c ?? 0;
65
+ }
66
+
67
+ /**
68
+ * bodies に存在し skeletons に未登録の最古ターンを 1 件返す。
69
+ * 遅延要約のターゲット選択に使う。
70
+ * @param {import('node:sqlite').DatabaseSync} db
71
+ * @param {string} target
72
+ * @returns {{ origin_session_id: string, turn_number: number, created_at: number } | null}
73
+ */
74
+ export function pickOldestUnsummarizedTurn(db, target) {
75
+ const row = db
76
+ .prepare(
77
+ `SELECT b.origin_session_id, b.turn_number, MIN(b.created_at) AS created_at
78
+ FROM bodies b
79
+ WHERE b.session_id = ?
80
+ AND NOT EXISTS (
81
+ SELECT 1 FROM skeletons s
82
+ WHERE s.session_id = b.session_id
83
+ AND s.origin_session_id = b.origin_session_id
84
+ AND s.turn_number = b.turn_number
85
+ )
86
+ GROUP BY b.origin_session_id, b.turn_number
87
+ ORDER BY created_at ASC
88
+ LIMIT 1`,
89
+ )
90
+ .get(target);
91
+ return row ?? null;
92
+ }
93
+
94
+ /**
95
+ * user と assistant のペアを結合して L2 要約用テキストを作る。
96
+ * @param {{content: string} | null} userTurn
97
+ * @param {{content: string} | null} assistantTurn
98
+ * @returns {string}
99
+ */
100
+ function buildL2ForSummary(userTurn, assistantTurn) {
101
+ const parts = [];
102
+ if (userTurn?.content) parts.push(`[user]: ${userTurn.content}`);
103
+ if (assistantTurn?.content) parts.push(`[assistant]: ${assistantTurn.content}`);
104
+ return parts.join('\n\n');
105
+ }
106
+
107
+ async function main() {
108
+ let raw = '';
109
+ await new Promise((resolve) => {
110
+ process.stdin.setEncoding('utf8');
111
+ process.stdin.on('data', (chunk) => {
112
+ raw += chunk;
113
+ });
114
+ process.stdin.on('end', resolve);
115
+ });
116
+
117
+ const payload = JSON.parse(raw || '{}');
118
+ const { session_id, transcript_path, cwd } = payload;
119
+ if (!session_id) throw new Error('Missing session_id in Stop payload');
120
+
121
+ // Stop hook 時点で state ファイルを更新 → token-monitor の「アクティブ行」判定が
122
+ // アシスタント応答終了時刻まで追従する
123
+ writeSessionState({
124
+ sessionId: session_id,
125
+ projectPath: cwd ?? process.cwd(),
126
+ transcriptPath: transcript_path ?? null,
127
+ pid: process.ppid,
128
+ });
129
+
130
+ const db = getDb();
131
+ const now = Date.now();
132
+
133
+ // merge target 解決: 入力 session が既に合流済みなら target = 合流先
134
+ const { target, origin } = resolveMergeTarget(db, session_id);
135
+
136
+ // target の sessions 行を upsert
137
+ const existing = db
138
+ .prepare('SELECT session_id FROM sessions WHERE session_id = ?')
139
+ .get(target);
140
+ if (!existing) {
141
+ db.prepare(
142
+ `INSERT INTO sessions (session_id, project_path, status, created_at, updated_at)
143
+ VALUES (?, ?, 'active', ?, ?)`,
144
+ ).run(target, cwd ?? process.cwd(), now, now);
145
+ } else {
146
+ db.prepare('UPDATE sessions SET updated_at = ? WHERE session_id = ?').run(now, target);
147
+ }
148
+
149
+ // 最後の assistant ターン + 直前の user ターンを取得
150
+ const { user: userTurn, assistant: assistantTurn } = getLastTurnPair(transcript_path);
151
+ if (!assistantTurn) {
152
+ // /clear 直後などでトランスクリプトが空の場合は何もしない
153
+ process.exit(0);
154
+ }
155
+
156
+ const turnNumber = assistantTurn.turn_number;
157
+
158
+ // L2 = bodies に user / assistant を個別行で保存
159
+ const insertBody = db.prepare(
160
+ `INSERT OR IGNORE INTO bodies
161
+ (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
162
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
163
+ );
164
+ // user / assistant を「1 往復 = 1 ターン」として扱うため、同じ turn_number
165
+ // (= assistant 側の turn_number)でペアリングして保存する。
166
+ // これにより bodies と skeletons が同じ turn_number で突合できる。
167
+ if (userTurn?.content) {
168
+ insertBody.run(
169
+ target,
170
+ origin,
171
+ turnNumber,
172
+ 'user',
173
+ userTurn.content,
174
+ Math.round(userTurn.content.length / 4),
175
+ now,
176
+ );
177
+ }
178
+ if (assistantTurn?.content) {
179
+ insertBody.run(
180
+ target,
181
+ origin,
182
+ turnNumber,
183
+ 'assistant',
184
+ assistantTurn.content,
185
+ Math.round(assistantTurn.content.length / 4),
186
+ now,
187
+ );
188
+ }
189
+
190
+ // L1 = 遅延要約。target 配下の bodies ターン数 (distinct origin×turn) が
191
+ // WINDOW を超えていたら、最古の未要約ターンを 1 件だけ要約する。
192
+ // 20 ターン以内で終わる作業では Haiku コストゼロ。
193
+ if (countDistinctBodyTurns(db, target) > L2_WINDOW) {
194
+ const oldest = pickOldestUnsummarizedTurn(db, target);
195
+ if (oldest) {
196
+ const rows = db
197
+ .prepare(
198
+ `SELECT role, text FROM bodies
199
+ WHERE session_id = ? AND origin_session_id = ? AND turn_number = ?`,
200
+ )
201
+ .all(target, oldest.origin_session_id, oldest.turn_number);
202
+ const userRow = rows.find((r) => r.role === 'user');
203
+ const asstRow = rows.find((r) => r.role === 'assistant');
204
+ const l2ForSummary = buildL2ForSummary(
205
+ userRow ? { content: userRow.text } : null,
206
+ asstRow ? { content: asstRow.text } : null,
207
+ );
208
+ const { summary } = summarizeToL1(l2ForSummary);
209
+
210
+ db.prepare(
211
+ `INSERT OR IGNORE INTO skeletons
212
+ (session_id, origin_session_id, turn_number, role, summary, created_at)
213
+ VALUES (?, ?, ?, 'assistant', ?, ?)`,
214
+ ).run(
215
+ target,
216
+ oldest.origin_session_id,
217
+ oldest.turn_number,
218
+ summary,
219
+ oldest.created_at,
220
+ );
221
+ }
222
+ }
223
+
224
+ // L3 = transcript から tool_use / tool_result / attachment (hook) を抽出して details に INSERT
225
+ // extractDetailBlocks はこの論理ターンの範囲のみをスキャンする。再実行時は
226
+ // source_id ベースの UNIQUE 制約で冪等性を確保(INSERT OR IGNORE)。
227
+ const allEntries = transcript_path ? readRawEntries(transcript_path) : [];
228
+ const turnEntries = sliceCurrentTurnEntries(allEntries);
229
+ const detailBlocks = extractDetailBlocks(turnEntries);
230
+
231
+ if (detailBlocks.length > 0) {
232
+ const insertDetail = db.prepare(
233
+ `INSERT OR IGNORE INTO details
234
+ (session_id, origin_session_id, turn_number, tool_name, input_text, output_text,
235
+ token_count, created_at, kind, source_id)
236
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
237
+ );
238
+ // 数百行の INSERT を 1 トランザクションにまとめて fsync コストを 1 回に抑える
239
+ db.exec('BEGIN');
240
+ try {
241
+ for (const d of detailBlocks) {
242
+ const tokenCount = Math.round(
243
+ ((d.input_text?.length ?? 0) + (d.output_text?.length ?? 0)) / 4,
244
+ );
245
+ insertDetail.run(
246
+ target,
247
+ origin,
248
+ turnNumber,
249
+ d.tool_name,
250
+ d.input_text,
251
+ d.output_text,
252
+ tokenCount,
253
+ now,
254
+ d.kind,
255
+ d.source_id,
256
+ );
257
+ }
258
+ db.exec('COMMIT');
259
+ } catch (err) {
260
+ db.exec('ROLLBACK');
261
+ throw err;
262
+ }
263
+ }
264
+
265
+ process.exit(0);
266
+ }
267
+
268
+ main().catch((err) => {
269
+ const msg = err instanceof Error ? err.message : String(err);
270
+ process.stderr.write(`[turn-processor] error: ${msg}\n`);
271
+ process.exit(1);
272
+ });