throughline 0.3.2 → 0.3.3

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/README.md CHANGED
@@ -179,12 +179,56 @@ Example output (real values from a running 1M-context Opus session):
179
179
  before drawing, preserving ANSI color codes. The redraw cursor math cannot
180
180
  desync on narrow terminals.
181
181
 
182
- ### VS Code auto-start
183
-
184
- For contributors working on Throughline itself, a `.vscode/tasks.json` in this
185
- repo launches `throughline monitor` automatically in a dedicated terminal when
186
- you open the folder. Drop an equivalent config into your own project's
187
- `.vscode/tasks.json` to get the same behavior.
182
+ ### VS Code auto-start (automatic)
183
+
184
+ After `throughline install`, any VS Code / Cursor / VSCodium project you work in
185
+ gets `.vscode/tasks.json` provisioned automatically on the next assistant turn.
186
+ The file configures `runOn: folderOpen` so the monitor appears in a dedicated
187
+ terminal panel the next time you open that folder.
188
+
189
+ **How it works.** The Stop hook runs at the end of every assistant response.
190
+ Once per project it inspects `.vscode/tasks.json`:
191
+
192
+ - **No file yet** → creates one with a single `Throughline Monitor` task.
193
+ - **Plain JSON with other tasks** → appends the monitor task, preserves your
194
+ existing entries, `version`, and indentation.
195
+ - **JSONC (comments or trailing commas)** → does not touch the file. Prints a
196
+ one-time notice to stderr asking you to paste the snippet below.
197
+ - **Already contains a Throughline Monitor task** → does nothing (idempotent;
198
+ this is the common path on every subsequent turn).
199
+
200
+ The generated task uses `type: 'process'` with the absolute path to Node and
201
+ `bin/throughline.mjs` so Windows `.cmd` shims and missing PATH entries cannot
202
+ break it.
203
+
204
+ **Opt out:** set `THROUGHLINE_NO_VSCODE=1` in the environment used by Claude
205
+ Code. Delete `.vscode/tasks.json` (or just the monitor entry) if you want to
206
+ stop auto-start for a project that already has one.
207
+
208
+ **Manual snippet for JSONC tasks.json files.** If Throughline refused to edit
209
+ your `tasks.json` because it contains comments or trailing commas, add this
210
+ entry to the `tasks` array yourself:
211
+
212
+ ```jsonc
213
+ {
214
+ "label": "Throughline Monitor",
215
+ "type": "shell",
216
+ "command": "throughline monitor",
217
+ "isBackground": true,
218
+ "presentation": {
219
+ "reveal": "always",
220
+ "panel": "dedicated",
221
+ "group": "throughline",
222
+ "close": false,
223
+ "echo": false,
224
+ "focus": false,
225
+ "showReuseMessage": false,
226
+ "clear": true
227
+ },
228
+ "runOptions": { "runOn": "folderOpen" },
229
+ "problemMatcher": []
230
+ }
231
+ ```
188
232
 
189
233
  ---
190
234
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "throughline",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "type": "module",
5
5
  "description": "Claude Code hooks plugin for structured context compression (/clear-safe persistent memory)",
6
6
  "keywords": [
@@ -1,272 +1,283 @@
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
- });
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
+ import { ensureMonitorTaskFile } from './vscode-task.mjs';
46
+
47
+ /** 直近 N ターンは bodies を生で残し、それより古いものだけ L1 要約する。 */
48
+ export const L2_WINDOW = 20;
49
+
50
+ /**
51
+ * target 配下の distinct (origin_session_id, turn_number) ターン数を返す。
52
+ * @param {import('node:sqlite').DatabaseSync} db
53
+ * @param {string} target
54
+ */
55
+ export function countDistinctBodyTurns(db, target) {
56
+ const row = db
57
+ .prepare(
58
+ `SELECT COUNT(*) AS c FROM (
59
+ SELECT DISTINCT origin_session_id, turn_number
60
+ FROM bodies
61
+ WHERE session_id = ?
62
+ )`,
63
+ )
64
+ .get(target);
65
+ return row?.c ?? 0;
66
+ }
67
+
68
+ /**
69
+ * bodies に存在し skeletons に未登録の最古ターンを 1 件返す。
70
+ * 遅延要約のターゲット選択に使う。
71
+ * @param {import('node:sqlite').DatabaseSync} db
72
+ * @param {string} target
73
+ * @returns {{ origin_session_id: string, turn_number: number, created_at: number } | null}
74
+ */
75
+ export function pickOldestUnsummarizedTurn(db, target) {
76
+ const row = db
77
+ .prepare(
78
+ `SELECT b.origin_session_id, b.turn_number, MIN(b.created_at) AS created_at
79
+ FROM bodies b
80
+ WHERE b.session_id = ?
81
+ AND NOT EXISTS (
82
+ SELECT 1 FROM skeletons s
83
+ WHERE s.session_id = b.session_id
84
+ AND s.origin_session_id = b.origin_session_id
85
+ AND s.turn_number = b.turn_number
86
+ )
87
+ GROUP BY b.origin_session_id, b.turn_number
88
+ ORDER BY created_at ASC
89
+ LIMIT 1`,
90
+ )
91
+ .get(target);
92
+ return row ?? null;
93
+ }
94
+
95
+ /**
96
+ * user assistant のペアを結合して L2 要約用テキストを作る。
97
+ * @param {{content: string} | null} userTurn
98
+ * @param {{content: string} | null} assistantTurn
99
+ * @returns {string}
100
+ */
101
+ function buildL2ForSummary(userTurn, assistantTurn) {
102
+ const parts = [];
103
+ if (userTurn?.content) parts.push(`[user]: ${userTurn.content}`);
104
+ if (assistantTurn?.content) parts.push(`[assistant]: ${assistantTurn.content}`);
105
+ return parts.join('\n\n');
106
+ }
107
+
108
+ async function main() {
109
+ let raw = '';
110
+ await new Promise((resolve) => {
111
+ process.stdin.setEncoding('utf8');
112
+ process.stdin.on('data', (chunk) => {
113
+ raw += chunk;
114
+ });
115
+ process.stdin.on('end', resolve);
116
+ });
117
+
118
+ const payload = JSON.parse(raw || '{}');
119
+ const { session_id, transcript_path, cwd } = payload;
120
+ if (!session_id) throw new Error('Missing session_id in Stop payload');
121
+
122
+ // VSCode で開かれたプロジェクトに .vscode/tasks.json を自動プロビジョニングする。
123
+ // 2 回目以降は冪等性チェックで即 return するので毎ターン走っても安全。
124
+ // 失敗しても主処理は継続させるため try/catch でラップ。
125
+ try {
126
+ ensureMonitorTaskFile({ cwd, env: process.env });
127
+ } catch (err) {
128
+ const msg = err instanceof Error ? err.message : 'unknown';
129
+ process.stderr.write(`[vscode-task] ${msg}\n`);
130
+ }
131
+
132
+ // Stop hook 時点で state ファイルを更新 → token-monitor の「アクティブ行」判定が
133
+ // アシスタント応答終了時刻まで追従する
134
+ writeSessionState({
135
+ sessionId: session_id,
136
+ projectPath: cwd ?? process.cwd(),
137
+ transcriptPath: transcript_path ?? null,
138
+ pid: process.ppid,
139
+ });
140
+
141
+ const db = getDb();
142
+ const now = Date.now();
143
+
144
+ // merge target 解決: 入力 session が既に合流済みなら target = 合流先
145
+ const { target, origin } = resolveMergeTarget(db, session_id);
146
+
147
+ // target の sessions 行を upsert
148
+ const existing = db
149
+ .prepare('SELECT session_id FROM sessions WHERE session_id = ?')
150
+ .get(target);
151
+ if (!existing) {
152
+ db.prepare(
153
+ `INSERT INTO sessions (session_id, project_path, status, created_at, updated_at)
154
+ VALUES (?, ?, 'active', ?, ?)`,
155
+ ).run(target, cwd ?? process.cwd(), now, now);
156
+ } else {
157
+ db.prepare('UPDATE sessions SET updated_at = ? WHERE session_id = ?').run(now, target);
158
+ }
159
+
160
+ // 最後の assistant ターン + 直前の user ターンを取得
161
+ const { user: userTurn, assistant: assistantTurn } = getLastTurnPair(transcript_path);
162
+ if (!assistantTurn) {
163
+ // /clear 直後などでトランスクリプトが空の場合は何もしない
164
+ process.exit(0);
165
+ }
166
+
167
+ const turnNumber = assistantTurn.turn_number;
168
+
169
+ // L2 = bodies に user / assistant を個別行で保存
170
+ const insertBody = db.prepare(
171
+ `INSERT OR IGNORE INTO bodies
172
+ (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
173
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
174
+ );
175
+ // user / assistant を「1 往復 = 1 ターン」として扱うため、同じ turn_number
176
+ // (= assistant 側の turn_number)でペアリングして保存する。
177
+ // これにより bodies と skeletons が同じ turn_number で突合できる。
178
+ if (userTurn?.content) {
179
+ insertBody.run(
180
+ target,
181
+ origin,
182
+ turnNumber,
183
+ 'user',
184
+ userTurn.content,
185
+ Math.round(userTurn.content.length / 4),
186
+ now,
187
+ );
188
+ }
189
+ if (assistantTurn?.content) {
190
+ insertBody.run(
191
+ target,
192
+ origin,
193
+ turnNumber,
194
+ 'assistant',
195
+ assistantTurn.content,
196
+ Math.round(assistantTurn.content.length / 4),
197
+ now,
198
+ );
199
+ }
200
+
201
+ // L1 = 遅延要約。target 配下の bodies ターン数 (distinct origin×turn)
202
+ // WINDOW を超えていたら、最古の未要約ターンを 1 件だけ要約する。
203
+ // 20 ターン以内で終わる作業では Haiku コストゼロ。
204
+ if (countDistinctBodyTurns(db, target) > L2_WINDOW) {
205
+ const oldest = pickOldestUnsummarizedTurn(db, target);
206
+ if (oldest) {
207
+ const rows = db
208
+ .prepare(
209
+ `SELECT role, text FROM bodies
210
+ WHERE session_id = ? AND origin_session_id = ? AND turn_number = ?`,
211
+ )
212
+ .all(target, oldest.origin_session_id, oldest.turn_number);
213
+ const userRow = rows.find((r) => r.role === 'user');
214
+ const asstRow = rows.find((r) => r.role === 'assistant');
215
+ const l2ForSummary = buildL2ForSummary(
216
+ userRow ? { content: userRow.text } : null,
217
+ asstRow ? { content: asstRow.text } : null,
218
+ );
219
+ const { summary } = summarizeToL1(l2ForSummary);
220
+
221
+ db.prepare(
222
+ `INSERT OR IGNORE INTO skeletons
223
+ (session_id, origin_session_id, turn_number, role, summary, created_at)
224
+ VALUES (?, ?, ?, 'assistant', ?, ?)`,
225
+ ).run(
226
+ target,
227
+ oldest.origin_session_id,
228
+ oldest.turn_number,
229
+ summary,
230
+ oldest.created_at,
231
+ );
232
+ }
233
+ }
234
+
235
+ // L3 = transcript から tool_use / tool_result / attachment (hook) を抽出して details に INSERT
236
+ // extractDetailBlocks はこの論理ターンの範囲のみをスキャンする。再実行時は
237
+ // source_id ベースの UNIQUE 制約で冪等性を確保(INSERT OR IGNORE)。
238
+ const allEntries = transcript_path ? readRawEntries(transcript_path) : [];
239
+ const turnEntries = sliceCurrentTurnEntries(allEntries);
240
+ const detailBlocks = extractDetailBlocks(turnEntries);
241
+
242
+ if (detailBlocks.length > 0) {
243
+ const insertDetail = db.prepare(
244
+ `INSERT OR IGNORE INTO details
245
+ (session_id, origin_session_id, turn_number, tool_name, input_text, output_text,
246
+ token_count, created_at, kind, source_id)
247
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
248
+ );
249
+ // 数百行の INSERT を 1 トランザクションにまとめて fsync コストを 1 回に抑える
250
+ db.exec('BEGIN');
251
+ try {
252
+ for (const d of detailBlocks) {
253
+ const tokenCount = Math.round(
254
+ ((d.input_text?.length ?? 0) + (d.output_text?.length ?? 0)) / 4,
255
+ );
256
+ insertDetail.run(
257
+ target,
258
+ origin,
259
+ turnNumber,
260
+ d.tool_name,
261
+ d.input_text,
262
+ d.output_text,
263
+ tokenCount,
264
+ now,
265
+ d.kind,
266
+ d.source_id,
267
+ );
268
+ }
269
+ db.exec('COMMIT');
270
+ } catch (err) {
271
+ db.exec('ROLLBACK');
272
+ throw err;
273
+ }
274
+ }
275
+
276
+ process.exit(0);
277
+ }
278
+
279
+ main().catch((err) => {
280
+ const msg = err instanceof Error ? err.message : String(err);
281
+ process.stderr.write(`[turn-processor] error: ${msg}\n`);
282
+ process.exit(1);
283
+ });