throughline 0.3.22 → 0.3.24
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.ja.md +255 -0
- package/README.md +138 -16
- package/bin/throughline.mjs +0 -0
- package/package.json +1 -1
- package/src/cli/install.mjs +53 -1
- package/src/cli/install.test.mjs +43 -1
- package/src/turn-processor.mjs +304 -304
- package/src/vscode-task.mjs +160 -22
- package/src/vscode-task.test.mjs +403 -2
package/src/turn-processor.mjs
CHANGED
|
@@ -1,304 +1,304 @@
|
|
|
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
|
-
import { readLatestUsage } from './transcript-usage.mjs';
|
|
47
|
-
|
|
48
|
-
/** 直近 N ターンは bodies を生で残し、それより古いものだけ L1 要約する。 */
|
|
49
|
-
export const L2_WINDOW = 20;
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* target 配下の distinct (origin_session_id, turn_number) ターン数を返す。
|
|
53
|
-
* @param {import('node:sqlite').DatabaseSync} db
|
|
54
|
-
* @param {string} target
|
|
55
|
-
*/
|
|
56
|
-
export function countDistinctBodyTurns(db, target) {
|
|
57
|
-
const row = db
|
|
58
|
-
.prepare(
|
|
59
|
-
`SELECT COUNT(*) AS c FROM (
|
|
60
|
-
SELECT DISTINCT origin_session_id, turn_number
|
|
61
|
-
FROM bodies
|
|
62
|
-
WHERE session_id = ?
|
|
63
|
-
)`,
|
|
64
|
-
)
|
|
65
|
-
.get(target);
|
|
66
|
-
return row?.c ?? 0;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* bodies に存在し skeletons に未登録の最古ターンを 1 件返す。
|
|
71
|
-
* 遅延要約のターゲット選択に使う。
|
|
72
|
-
* @param {import('node:sqlite').DatabaseSync} db
|
|
73
|
-
* @param {string} target
|
|
74
|
-
* @returns {{ origin_session_id: string, turn_number: number, created_at: number } | null}
|
|
75
|
-
*/
|
|
76
|
-
export function pickOldestUnsummarizedTurn(db, target) {
|
|
77
|
-
const row = db
|
|
78
|
-
.prepare(
|
|
79
|
-
`SELECT b.origin_session_id, b.turn_number, MIN(b.created_at) AS created_at
|
|
80
|
-
FROM bodies b
|
|
81
|
-
WHERE b.session_id = ?
|
|
82
|
-
AND NOT EXISTS (
|
|
83
|
-
SELECT 1 FROM skeletons s
|
|
84
|
-
WHERE s.session_id = b.session_id
|
|
85
|
-
AND s.origin_session_id = b.origin_session_id
|
|
86
|
-
AND s.turn_number = b.turn_number
|
|
87
|
-
)
|
|
88
|
-
GROUP BY b.origin_session_id, b.turn_number
|
|
89
|
-
ORDER BY created_at ASC
|
|
90
|
-
LIMIT 1`,
|
|
91
|
-
)
|
|
92
|
-
.get(target);
|
|
93
|
-
return row ?? null;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* user と assistant のペアを結合して L2 要約用テキストを作る。
|
|
98
|
-
* @param {{content: string} | null} userTurn
|
|
99
|
-
* @param {{content: string} | null} assistantTurn
|
|
100
|
-
* @returns {string}
|
|
101
|
-
*/
|
|
102
|
-
function buildL2ForSummary(userTurn, assistantTurn) {
|
|
103
|
-
const parts = [];
|
|
104
|
-
if (userTurn?.content) parts.push(`[user]: ${userTurn.content}`);
|
|
105
|
-
if (assistantTurn?.content) parts.push(`[assistant]: ${assistantTurn.content}`);
|
|
106
|
-
return parts.join('\n\n');
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
async function main() {
|
|
110
|
-
let raw = '';
|
|
111
|
-
await new Promise((resolve) => {
|
|
112
|
-
process.stdin.setEncoding('utf8');
|
|
113
|
-
process.stdin.on('data', (chunk) => {
|
|
114
|
-
raw += chunk;
|
|
115
|
-
});
|
|
116
|
-
process.stdin.on('end', resolve);
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
const payload = JSON.parse(raw || '{}');
|
|
120
|
-
const { session_id, transcript_path, cwd } = payload;
|
|
121
|
-
if (!session_id) throw new Error('Missing session_id in Stop payload');
|
|
122
|
-
|
|
123
|
-
// VSCode で開かれたプロジェクトに .vscode/tasks.json を自動プロビジョニングする。
|
|
124
|
-
// 2 回目以降は冪等性チェックで即 return するので毎ターン走っても安全。
|
|
125
|
-
// 失敗しても主処理は継続させるため try/catch でラップ。
|
|
126
|
-
try {
|
|
127
|
-
ensureMonitorTaskFile({ cwd, env: process.env });
|
|
128
|
-
} catch (err) {
|
|
129
|
-
const msg = err instanceof Error ? err.message : 'unknown';
|
|
130
|
-
process.stderr.write(`[vscode-task] ${msg}\n`);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Stop hook 時点で state ファイルを更新 → token-monitor の「アクティブ行」判定が
|
|
134
|
-
// アシスタント応答終了時刻まで追従する
|
|
135
|
-
writeSessionState({
|
|
136
|
-
sessionId: session_id,
|
|
137
|
-
projectPath: cwd ?? process.cwd(),
|
|
138
|
-
transcriptPath: transcript_path ?? null,
|
|
139
|
-
pid: process.ppid,
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
const db = getDb();
|
|
143
|
-
const now = Date.now();
|
|
144
|
-
|
|
145
|
-
// merge target 解決: 入力 session が既に合流済みなら target = 合流先
|
|
146
|
-
const { target, origin } = resolveMergeTarget(db, session_id);
|
|
147
|
-
|
|
148
|
-
// target の sessions 行を upsert
|
|
149
|
-
const existing = db
|
|
150
|
-
.prepare('SELECT session_id FROM sessions WHERE session_id = ?')
|
|
151
|
-
.get(target);
|
|
152
|
-
if (!existing) {
|
|
153
|
-
db.prepare(
|
|
154
|
-
`INSERT INTO sessions (session_id, project_path, status, created_at, updated_at)
|
|
155
|
-
VALUES (?, ?, 'active', ?, ?)`,
|
|
156
|
-
).run(target, cwd ?? process.cwd(), now, now);
|
|
157
|
-
} else {
|
|
158
|
-
db.prepare('UPDATE sessions SET updated_at = ? WHERE session_id = ?').run(now, target);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// 最後の assistant ターン + 直前の user ターンを取得
|
|
162
|
-
const { user: userTurn, assistant: assistantTurn } = getLastTurnPair(transcript_path);
|
|
163
|
-
if (!assistantTurn) {
|
|
164
|
-
// /clear 直後などでトランスクリプトが空の場合は何もしない
|
|
165
|
-
process.exit(0);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const turnNumber = assistantTurn.turn_number;
|
|
169
|
-
|
|
170
|
-
// L2 = bodies に user / assistant を個別行で保存
|
|
171
|
-
const insertBody = db.prepare(
|
|
172
|
-
`INSERT OR IGNORE INTO bodies
|
|
173
|
-
(session_id, origin_session_id, turn_number, role, text, token_count, created_at)
|
|
174
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
175
|
-
);
|
|
176
|
-
// user / assistant を「1 往復 = 1 ターン」として扱うため、同じ turn_number
|
|
177
|
-
// (= assistant 側の turn_number)でペアリングして保存する。
|
|
178
|
-
// これにより bodies と skeletons が同じ turn_number で突合できる。
|
|
179
|
-
if (userTurn?.content) {
|
|
180
|
-
insertBody.run(
|
|
181
|
-
target,
|
|
182
|
-
origin,
|
|
183
|
-
turnNumber,
|
|
184
|
-
'user',
|
|
185
|
-
userTurn.content,
|
|
186
|
-
Math.round(userTurn.content.length / 4),
|
|
187
|
-
now,
|
|
188
|
-
);
|
|
189
|
-
}
|
|
190
|
-
if (assistantTurn?.content) {
|
|
191
|
-
insertBody.run(
|
|
192
|
-
target,
|
|
193
|
-
origin,
|
|
194
|
-
turnNumber,
|
|
195
|
-
'assistant',
|
|
196
|
-
assistantTurn.content,
|
|
197
|
-
Math.round(assistantTurn.content.length / 4),
|
|
198
|
-
now,
|
|
199
|
-
);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// L1 = 遅延要約。target 配下の bodies ターン数 (distinct origin×turn) が
|
|
203
|
-
// WINDOW を超えていたら、最古の未要約ターンを 1 件だけ要約する。
|
|
204
|
-
// 20 ターン以内で終わる作業では Haiku コストゼロ。
|
|
205
|
-
if (countDistinctBodyTurns(db, target) > L2_WINDOW) {
|
|
206
|
-
const oldest = pickOldestUnsummarizedTurn(db, target);
|
|
207
|
-
if (oldest) {
|
|
208
|
-
const rows = db
|
|
209
|
-
.prepare(
|
|
210
|
-
`SELECT role, text FROM bodies
|
|
211
|
-
WHERE session_id = ? AND origin_session_id = ? AND turn_number = ?`,
|
|
212
|
-
)
|
|
213
|
-
.all(target, oldest.origin_session_id, oldest.turn_number);
|
|
214
|
-
const userRow = rows.find((r) => r.role === 'user');
|
|
215
|
-
const asstRow = rows.find((r) => r.role === 'assistant');
|
|
216
|
-
const l2ForSummary = buildL2ForSummary(
|
|
217
|
-
userRow ? { content: userRow.text } : null,
|
|
218
|
-
asstRow ? { content: asstRow.text } : null,
|
|
219
|
-
);
|
|
220
|
-
const { summary } = summarizeToL1(l2ForSummary);
|
|
221
|
-
|
|
222
|
-
db.prepare(
|
|
223
|
-
`INSERT OR IGNORE INTO skeletons
|
|
224
|
-
(session_id, origin_session_id, turn_number, role, summary, created_at)
|
|
225
|
-
VALUES (?, ?, ?, 'assistant', ?, ?)`,
|
|
226
|
-
).run(
|
|
227
|
-
target,
|
|
228
|
-
oldest.origin_session_id,
|
|
229
|
-
oldest.turn_number,
|
|
230
|
-
summary,
|
|
231
|
-
oldest.created_at,
|
|
232
|
-
);
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// L3 = transcript から tool_use / tool_result / attachment (hook) を抽出して details に INSERT
|
|
237
|
-
// extractDetailBlocks はこの論理ターンの範囲のみをスキャンする。再実行時は
|
|
238
|
-
// source_id ベースの UNIQUE 制約で冪等性を確保(INSERT OR IGNORE)。
|
|
239
|
-
const allEntries = transcript_path ? readRawEntries(transcript_path) : [];
|
|
240
|
-
const turnEntries = sliceCurrentTurnEntries(allEntries);
|
|
241
|
-
const detailBlocks = extractDetailBlocks(turnEntries);
|
|
242
|
-
|
|
243
|
-
if (detailBlocks.length > 0) {
|
|
244
|
-
const insertDetail = db.prepare(
|
|
245
|
-
`INSERT OR IGNORE INTO details
|
|
246
|
-
(session_id, origin_session_id, turn_number, tool_name, input_text, output_text,
|
|
247
|
-
token_count, created_at, kind, source_id)
|
|
248
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
249
|
-
);
|
|
250
|
-
// 数百行の INSERT を 1 トランザクションにまとめて fsync コストを 1 回に抑える
|
|
251
|
-
db.exec('BEGIN');
|
|
252
|
-
try {
|
|
253
|
-
for (const d of detailBlocks) {
|
|
254
|
-
const tokenCount = Math.round(
|
|
255
|
-
((d.input_text?.length ?? 0) + (d.output_text?.length ?? 0)) / 4,
|
|
256
|
-
);
|
|
257
|
-
insertDetail.run(
|
|
258
|
-
target,
|
|
259
|
-
origin,
|
|
260
|
-
turnNumber,
|
|
261
|
-
d.tool_name,
|
|
262
|
-
d.input_text,
|
|
263
|
-
d.output_text,
|
|
264
|
-
tokenCount,
|
|
265
|
-
now,
|
|
266
|
-
d.kind,
|
|
267
|
-
d.source_id,
|
|
268
|
-
);
|
|
269
|
-
}
|
|
270
|
-
db.exec('COMMIT');
|
|
271
|
-
} catch (err) {
|
|
272
|
-
db.exec('ROLLBACK');
|
|
273
|
-
throw err;
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// monitor が JSONL を毎フレーム再スキャンせずに済むよう、現在確定している usage を
|
|
278
|
-
// state ファイルに固定する。Stop 完了時点で assistant エントリは transcript に
|
|
279
|
-
// 書き出し済みなので readLatestUsage が最新値を返す。
|
|
280
|
-
// 取得失敗は致命ではないので try/catch で握る(stderr には出す)。
|
|
281
|
-
try {
|
|
282
|
-
const usage = transcript_path ? readLatestUsage(transcript_path) : null;
|
|
283
|
-
if (usage) {
|
|
284
|
-
writeSessionState({
|
|
285
|
-
sessionId: session_id,
|
|
286
|
-
projectPath: cwd ?? process.cwd(),
|
|
287
|
-
transcriptPath: transcript_path ?? null,
|
|
288
|
-
pid: process.ppid,
|
|
289
|
-
usage,
|
|
290
|
-
});
|
|
291
|
-
}
|
|
292
|
-
} catch (err) {
|
|
293
|
-
const msg = err instanceof Error ? err.message : 'unknown';
|
|
294
|
-
process.stderr.write(`[turn-processor] usage snapshot failed: ${msg}\n`);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
process.exit(0);
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
main().catch((err) => {
|
|
301
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
302
|
-
process.stderr.write(`[turn-processor] error: ${msg}\n`);
|
|
303
|
-
process.exit(1);
|
|
304
|
-
});
|
|
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
|
+
import { readLatestUsage } from './transcript-usage.mjs';
|
|
47
|
+
|
|
48
|
+
/** 直近 N ターンは bodies を生で残し、それより古いものだけ L1 要約する。 */
|
|
49
|
+
export const L2_WINDOW = 20;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* target 配下の distinct (origin_session_id, turn_number) ターン数を返す。
|
|
53
|
+
* @param {import('node:sqlite').DatabaseSync} db
|
|
54
|
+
* @param {string} target
|
|
55
|
+
*/
|
|
56
|
+
export function countDistinctBodyTurns(db, target) {
|
|
57
|
+
const row = db
|
|
58
|
+
.prepare(
|
|
59
|
+
`SELECT COUNT(*) AS c FROM (
|
|
60
|
+
SELECT DISTINCT origin_session_id, turn_number
|
|
61
|
+
FROM bodies
|
|
62
|
+
WHERE session_id = ?
|
|
63
|
+
)`,
|
|
64
|
+
)
|
|
65
|
+
.get(target);
|
|
66
|
+
return row?.c ?? 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* bodies に存在し skeletons に未登録の最古ターンを 1 件返す。
|
|
71
|
+
* 遅延要約のターゲット選択に使う。
|
|
72
|
+
* @param {import('node:sqlite').DatabaseSync} db
|
|
73
|
+
* @param {string} target
|
|
74
|
+
* @returns {{ origin_session_id: string, turn_number: number, created_at: number } | null}
|
|
75
|
+
*/
|
|
76
|
+
export function pickOldestUnsummarizedTurn(db, target) {
|
|
77
|
+
const row = db
|
|
78
|
+
.prepare(
|
|
79
|
+
`SELECT b.origin_session_id, b.turn_number, MIN(b.created_at) AS created_at
|
|
80
|
+
FROM bodies b
|
|
81
|
+
WHERE b.session_id = ?
|
|
82
|
+
AND NOT EXISTS (
|
|
83
|
+
SELECT 1 FROM skeletons s
|
|
84
|
+
WHERE s.session_id = b.session_id
|
|
85
|
+
AND s.origin_session_id = b.origin_session_id
|
|
86
|
+
AND s.turn_number = b.turn_number
|
|
87
|
+
)
|
|
88
|
+
GROUP BY b.origin_session_id, b.turn_number
|
|
89
|
+
ORDER BY created_at ASC
|
|
90
|
+
LIMIT 1`,
|
|
91
|
+
)
|
|
92
|
+
.get(target);
|
|
93
|
+
return row ?? null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* user と assistant のペアを結合して L2 要約用テキストを作る。
|
|
98
|
+
* @param {{content: string} | null} userTurn
|
|
99
|
+
* @param {{content: string} | null} assistantTurn
|
|
100
|
+
* @returns {string}
|
|
101
|
+
*/
|
|
102
|
+
function buildL2ForSummary(userTurn, assistantTurn) {
|
|
103
|
+
const parts = [];
|
|
104
|
+
if (userTurn?.content) parts.push(`[user]: ${userTurn.content}`);
|
|
105
|
+
if (assistantTurn?.content) parts.push(`[assistant]: ${assistantTurn.content}`);
|
|
106
|
+
return parts.join('\n\n');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function main() {
|
|
110
|
+
let raw = '';
|
|
111
|
+
await new Promise((resolve) => {
|
|
112
|
+
process.stdin.setEncoding('utf8');
|
|
113
|
+
process.stdin.on('data', (chunk) => {
|
|
114
|
+
raw += chunk;
|
|
115
|
+
});
|
|
116
|
+
process.stdin.on('end', resolve);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const payload = JSON.parse(raw || '{}');
|
|
120
|
+
const { session_id, transcript_path, cwd } = payload;
|
|
121
|
+
if (!session_id) throw new Error('Missing session_id in Stop payload');
|
|
122
|
+
|
|
123
|
+
// VSCode で開かれたプロジェクトに .vscode/tasks.json を自動プロビジョニングする。
|
|
124
|
+
// 2 回目以降は冪等性チェックで即 return するので毎ターン走っても安全。
|
|
125
|
+
// 失敗しても主処理は継続させるため try/catch でラップ。
|
|
126
|
+
try {
|
|
127
|
+
ensureMonitorTaskFile({ cwd, env: process.env });
|
|
128
|
+
} catch (err) {
|
|
129
|
+
const msg = err instanceof Error ? err.message : 'unknown';
|
|
130
|
+
process.stderr.write(`[vscode-task] ${msg}\n`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Stop hook 時点で state ファイルを更新 → token-monitor の「アクティブ行」判定が
|
|
134
|
+
// アシスタント応答終了時刻まで追従する
|
|
135
|
+
writeSessionState({
|
|
136
|
+
sessionId: session_id,
|
|
137
|
+
projectPath: cwd ?? process.cwd(),
|
|
138
|
+
transcriptPath: transcript_path ?? null,
|
|
139
|
+
pid: process.ppid,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const db = getDb();
|
|
143
|
+
const now = Date.now();
|
|
144
|
+
|
|
145
|
+
// merge target 解決: 入力 session が既に合流済みなら target = 合流先
|
|
146
|
+
const { target, origin } = resolveMergeTarget(db, session_id);
|
|
147
|
+
|
|
148
|
+
// target の sessions 行を upsert
|
|
149
|
+
const existing = db
|
|
150
|
+
.prepare('SELECT session_id FROM sessions WHERE session_id = ?')
|
|
151
|
+
.get(target);
|
|
152
|
+
if (!existing) {
|
|
153
|
+
db.prepare(
|
|
154
|
+
`INSERT INTO sessions (session_id, project_path, status, created_at, updated_at)
|
|
155
|
+
VALUES (?, ?, 'active', ?, ?)`,
|
|
156
|
+
).run(target, cwd ?? process.cwd(), now, now);
|
|
157
|
+
} else {
|
|
158
|
+
db.prepare('UPDATE sessions SET updated_at = ? WHERE session_id = ?').run(now, target);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 最後の assistant ターン + 直前の user ターンを取得
|
|
162
|
+
const { user: userTurn, assistant: assistantTurn } = getLastTurnPair(transcript_path);
|
|
163
|
+
if (!assistantTurn) {
|
|
164
|
+
// /clear 直後などでトランスクリプトが空の場合は何もしない
|
|
165
|
+
process.exit(0);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const turnNumber = assistantTurn.turn_number;
|
|
169
|
+
|
|
170
|
+
// L2 = bodies に user / assistant を個別行で保存
|
|
171
|
+
const insertBody = db.prepare(
|
|
172
|
+
`INSERT OR IGNORE INTO bodies
|
|
173
|
+
(session_id, origin_session_id, turn_number, role, text, token_count, created_at)
|
|
174
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
175
|
+
);
|
|
176
|
+
// user / assistant を「1 往復 = 1 ターン」として扱うため、同じ turn_number
|
|
177
|
+
// (= assistant 側の turn_number)でペアリングして保存する。
|
|
178
|
+
// これにより bodies と skeletons が同じ turn_number で突合できる。
|
|
179
|
+
if (userTurn?.content) {
|
|
180
|
+
insertBody.run(
|
|
181
|
+
target,
|
|
182
|
+
origin,
|
|
183
|
+
turnNumber,
|
|
184
|
+
'user',
|
|
185
|
+
userTurn.content,
|
|
186
|
+
Math.round(userTurn.content.length / 4),
|
|
187
|
+
now,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
if (assistantTurn?.content) {
|
|
191
|
+
insertBody.run(
|
|
192
|
+
target,
|
|
193
|
+
origin,
|
|
194
|
+
turnNumber,
|
|
195
|
+
'assistant',
|
|
196
|
+
assistantTurn.content,
|
|
197
|
+
Math.round(assistantTurn.content.length / 4),
|
|
198
|
+
now,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// L1 = 遅延要約。target 配下の bodies ターン数 (distinct origin×turn) が
|
|
203
|
+
// WINDOW を超えていたら、最古の未要約ターンを 1 件だけ要約する。
|
|
204
|
+
// 20 ターン以内で終わる作業では Haiku コストゼロ。
|
|
205
|
+
if (countDistinctBodyTurns(db, target) > L2_WINDOW) {
|
|
206
|
+
const oldest = pickOldestUnsummarizedTurn(db, target);
|
|
207
|
+
if (oldest) {
|
|
208
|
+
const rows = db
|
|
209
|
+
.prepare(
|
|
210
|
+
`SELECT role, text FROM bodies
|
|
211
|
+
WHERE session_id = ? AND origin_session_id = ? AND turn_number = ?`,
|
|
212
|
+
)
|
|
213
|
+
.all(target, oldest.origin_session_id, oldest.turn_number);
|
|
214
|
+
const userRow = rows.find((r) => r.role === 'user');
|
|
215
|
+
const asstRow = rows.find((r) => r.role === 'assistant');
|
|
216
|
+
const l2ForSummary = buildL2ForSummary(
|
|
217
|
+
userRow ? { content: userRow.text } : null,
|
|
218
|
+
asstRow ? { content: asstRow.text } : null,
|
|
219
|
+
);
|
|
220
|
+
const { summary } = summarizeToL1(l2ForSummary);
|
|
221
|
+
|
|
222
|
+
db.prepare(
|
|
223
|
+
`INSERT OR IGNORE INTO skeletons
|
|
224
|
+
(session_id, origin_session_id, turn_number, role, summary, created_at)
|
|
225
|
+
VALUES (?, ?, ?, 'assistant', ?, ?)`,
|
|
226
|
+
).run(
|
|
227
|
+
target,
|
|
228
|
+
oldest.origin_session_id,
|
|
229
|
+
oldest.turn_number,
|
|
230
|
+
summary,
|
|
231
|
+
oldest.created_at,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// L3 = transcript から tool_use / tool_result / attachment (hook) を抽出して details に INSERT
|
|
237
|
+
// extractDetailBlocks はこの論理ターンの範囲のみをスキャンする。再実行時は
|
|
238
|
+
// source_id ベースの UNIQUE 制約で冪等性を確保(INSERT OR IGNORE)。
|
|
239
|
+
const allEntries = transcript_path ? readRawEntries(transcript_path) : [];
|
|
240
|
+
const turnEntries = sliceCurrentTurnEntries(allEntries);
|
|
241
|
+
const detailBlocks = extractDetailBlocks(turnEntries);
|
|
242
|
+
|
|
243
|
+
if (detailBlocks.length > 0) {
|
|
244
|
+
const insertDetail = db.prepare(
|
|
245
|
+
`INSERT OR IGNORE INTO details
|
|
246
|
+
(session_id, origin_session_id, turn_number, tool_name, input_text, output_text,
|
|
247
|
+
token_count, created_at, kind, source_id)
|
|
248
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
249
|
+
);
|
|
250
|
+
// 数百行の INSERT を 1 トランザクションにまとめて fsync コストを 1 回に抑える
|
|
251
|
+
db.exec('BEGIN');
|
|
252
|
+
try {
|
|
253
|
+
for (const d of detailBlocks) {
|
|
254
|
+
const tokenCount = Math.round(
|
|
255
|
+
((d.input_text?.length ?? 0) + (d.output_text?.length ?? 0)) / 4,
|
|
256
|
+
);
|
|
257
|
+
insertDetail.run(
|
|
258
|
+
target,
|
|
259
|
+
origin,
|
|
260
|
+
turnNumber,
|
|
261
|
+
d.tool_name,
|
|
262
|
+
d.input_text,
|
|
263
|
+
d.output_text,
|
|
264
|
+
tokenCount,
|
|
265
|
+
now,
|
|
266
|
+
d.kind,
|
|
267
|
+
d.source_id,
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
db.exec('COMMIT');
|
|
271
|
+
} catch (err) {
|
|
272
|
+
db.exec('ROLLBACK');
|
|
273
|
+
throw err;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// monitor が JSONL を毎フレーム再スキャンせずに済むよう、現在確定している usage を
|
|
278
|
+
// state ファイルに固定する。Stop 完了時点で assistant エントリは transcript に
|
|
279
|
+
// 書き出し済みなので readLatestUsage が最新値を返す。
|
|
280
|
+
// 取得失敗は致命ではないので try/catch で握る(stderr には出す)。
|
|
281
|
+
try {
|
|
282
|
+
const usage = transcript_path ? readLatestUsage(transcript_path) : null;
|
|
283
|
+
if (usage) {
|
|
284
|
+
writeSessionState({
|
|
285
|
+
sessionId: session_id,
|
|
286
|
+
projectPath: cwd ?? process.cwd(),
|
|
287
|
+
transcriptPath: transcript_path ?? null,
|
|
288
|
+
pid: process.ppid,
|
|
289
|
+
usage,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
} catch (err) {
|
|
293
|
+
const msg = err instanceof Error ? err.message : 'unknown';
|
|
294
|
+
process.stderr.write(`[turn-processor] usage snapshot failed: ${msg}\n`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
process.exit(0);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
main().catch((err) => {
|
|
301
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
302
|
+
process.stderr.write(`[turn-processor] error: ${msg}\n`);
|
|
303
|
+
process.exit(1);
|
|
304
|
+
});
|