throughline 0.13.0 → 0.14.1

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.
@@ -20,7 +20,14 @@
20
20
  import { appendFileSync, mkdirSync } from 'node:fs';
21
21
  import { homedir } from 'node:os';
22
22
  import { dirname, join } from 'node:path';
23
- import { getLogicalTurnGroups } from './transcript-reader.mjs';
23
+ import {
24
+ extractDetailBlocks,
25
+ getLogicalTurnGroups,
26
+ isJunkAssistantText,
27
+ readLatestUserGroup,
28
+ readRawEntries,
29
+ sliceInFlightTurnEntries,
30
+ } from './transcript-reader.mjs';
24
31
 
25
32
  /**
26
33
  * バックフィル回収実績を ~/.throughline/logs/backfill.log に 1 行 JSON で記録する。
@@ -59,15 +66,10 @@ export function deriveTranscriptPath(projectPath, sessionId) {
59
66
  * @param {string} opts.originSessionId transcript を所有する origin session_id
60
67
  * @param {string|null|undefined} opts.transcriptPath
61
68
  * @param {number} opts.now timestamp 欠損時の fallback epoch ms
62
- * @param {number|null} [opts.beforeUserTurnNumber] 指定した時は、user 行の番号がこれより前の群だけを回収する。
63
- * 作業途中のターンがある時点(自動圧縮の直後。ADR 0032)で呼ぶ時に、その群を除くために使う。
64
- * 途中の断片を代表として保存すると、群レベル dedup で最終回答が二度と入らない。
65
69
  * @returns {{groups: number, insertedTurns: number, skippedExisting: number, lastTurnNumber: number|null, turnNumbers: number[]}}
66
70
  */
67
- export function backfillBodies(db, { targetSessionId, originSessionId, transcriptPath, now, beforeUserTurnNumber = null }) {
68
- const groups = getLogicalTurnGroups(transcriptPath).filter(
69
- (group) => beforeUserTurnNumber === null || group.user.turn_number < beforeUserTurnNumber,
70
- );
71
+ export function backfillBodies(db, { targetSessionId, originSessionId, transcriptPath, now }) {
72
+ const groups = getLogicalTurnGroups(transcriptPath);
71
73
  if (groups.length === 0) {
72
74
  return { groups: 0, insertedTurns: 0, skippedExisting: 0, lastTurnNumber: null, turnNumbers: [] };
73
75
  }
@@ -137,3 +139,75 @@ export function backfillBodies(db, { targetSessionId, originSessionId, transcrip
137
139
  turnNumbers: groups.map((group) => group.representative.index),
138
140
  };
139
141
  }
142
+
143
+ /**
144
+ * 作業途中で止めたターンを bodies と details へ取り込む (ADR 0033)。
145
+ *
146
+ * hook で止めたターンでは Stop hook が走らない。止めた側(自動継続の worker)が、後継を立てる前に呼ぶ。
147
+ * Codex の自動継続が、止めたターンを取り込んでから記憶を作るのと同じ。
148
+ *
149
+ * - 完了したターンは最後の本文(代表断片)だけを残すが、止めたターンには結論の発言が無い。
150
+ * ここまでの発言を全部つないで assistant の本文にする。本文が1つも無ければ user の行だけを入れる。
151
+ * - turn_number は最後の本文断片の index(無ければ user 行の index)。backfillBodies は断片の index が
152
+ * bodies にある群を回収しないので、後継が前任を合流させる時に、代表断片の行が重ねて入らない。
153
+ * - details は最後の user 本文の行から transcript の末尾まで。止めた道具の呼び出しも入る。
154
+ * source_id の UNIQUE で、同じターンをもう一度取り込んでも増えない。
155
+ *
156
+ * @param {import('node:sqlite').DatabaseSync} db
157
+ * @param {object} opts
158
+ * @param {string} opts.targetSessionId 書き込み先 session_id(合流済みなら合流先)
159
+ * @param {string} opts.originSessionId transcript を持つ session_id
160
+ * @param {string|null|undefined} opts.transcriptPath
161
+ * @param {number} opts.now timestamp が無い時と details の created_at
162
+ * @returns {{turnNumber: number, userAt: number, assistantAt: number|null, insertedBodies: boolean, details: number}|null}
163
+ */
164
+ export function captureInFlightTurn(db, { targetSessionId, originSessionId, transcriptPath, now }) {
165
+ const latest = readLatestUserGroup(transcriptPath);
166
+ if (!latest) return null;
167
+ const fragments = latest.fragments.filter((f) => !isJunkAssistantText(f.content));
168
+ const candidates = [latest.user.turn_number, ...latest.fragments.map((f) => f.index)];
169
+ const captured = db
170
+ .prepare(
171
+ `SELECT turn_number FROM bodies
172
+ WHERE origin_session_id = ? AND turn_number IN (${candidates.map(() => '?').join(',')})
173
+ ORDER BY turn_number DESC LIMIT 1`,
174
+ )
175
+ .get(originSessionId, ...candidates);
176
+ const turnNumber = captured?.turn_number ?? fragments.at(-1)?.index ?? latest.user.turn_number;
177
+ const userAt = latest.user.timestamp ?? now;
178
+ const assistantAt = fragments.length > 0 ? (fragments.at(-1).timestamp ?? now) : null;
179
+ const assistantText = fragments.map((f) => f.content.trim()).join('\n\n');
180
+ const detailBlocks = extractDetailBlocks(sliceInFlightTurnEntries(readRawEntries(transcriptPath)));
181
+
182
+ const insertBody = db.prepare(
183
+ `INSERT OR IGNORE INTO bodies
184
+ (session_id, origin_session_id, turn_number, role, text, token_count, created_at, turn_start)
185
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
186
+ );
187
+ const insertDetail = db.prepare(
188
+ `INSERT OR IGNORE INTO details
189
+ (session_id, origin_session_id, turn_number, tool_name, input_text, output_text,
190
+ token_count, created_at, kind, source_id)
191
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
192
+ );
193
+ db.exec('BEGIN');
194
+ try {
195
+ if (!captured) {
196
+ insertBody.run(targetSessionId, originSessionId, turnNumber, 'user', latest.user.content,
197
+ Math.round(latest.user.content.length / 4), userAt, latest.user.start);
198
+ if (assistantText) {
199
+ insertBody.run(targetSessionId, originSessionId, turnNumber, 'assistant', assistantText,
200
+ Math.round(assistantText.length / 4), assistantAt, null);
201
+ }
202
+ }
203
+ for (const d of detailBlocks) {
204
+ insertDetail.run(targetSessionId, originSessionId, turnNumber, d.tool_name, d.input_text, d.output_text,
205
+ Math.round(((d.input_text?.length ?? 0) + (d.output_text?.length ?? 0)) / 4), now, d.kind, d.source_id);
206
+ }
207
+ db.exec('COMMIT');
208
+ } catch (err) {
209
+ db.exec('ROLLBACK');
210
+ throw err;
211
+ }
212
+ return { turnNumber, userAt, assistantAt, insertedBodies: !captured, details: detailBlocks.length };
213
+ }
@@ -4,7 +4,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
4
  import { tmpdir, homedir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { DatabaseSync } from 'node:sqlite';
7
- import { backfillBodies, deriveTranscriptPath } from './turn-backfill.mjs';
7
+ import { backfillBodies, captureInFlightTurn, deriveTranscriptPath } from './turn-backfill.mjs';
8
8
 
9
9
  function makeDb() {
10
10
  const db = new DatabaseSync(':memory:');
@@ -242,28 +242,6 @@ test('backfillBodies: user row keeps the turn start and assistant row leaves it
242
242
  );
243
243
  });
244
244
 
245
- test('backfillBodies: beforeUserTurnNumber は作業途中のターンを回収から外す', () => {
246
- withData(
247
- [
248
- entry('user', 'done prompt', '2026-10-04T00:00:00Z'),
249
- entry('assistant', 'done answer', '2026-10-04T00:00:01Z'),
250
- entry('user', 'in-flight prompt', '2026-10-04T00:00:02Z'),
251
- entry('assistant', 'interim remark', '2026-10-04T00:00:03Z'),
252
- ],
253
- (path) => {
254
- const db = makeDb();
255
- const partial = backfillBodies(db, { targetSessionId: 'S', originSessionId: 'S', transcriptPath: path, now: 1, beforeUserTurnNumber: 2 });
256
- assert.equal(partial.insertedTurns, 1);
257
- assert.deepEqual(partial.turnNumbers, [1]);
258
- assert.deepEqual(db.prepare('SELECT text FROM bodies ORDER BY id').all().map((r) => r.text), ['done prompt', 'done answer']);
259
- // Stop で改めて全体を回収すると、作業途中だったターンが最終回答つきで入る
260
- const full = backfillBodies(db, { targetSessionId: 'S', originSessionId: 'S', transcriptPath: path, now: 1 });
261
- assert.equal(full.insertedTurns, 1);
262
- assert.equal(full.skippedExisting, 1);
263
- },
264
- );
265
- });
266
-
267
245
  test('backfillBodies: 圧縮の要約行を数えていた頃に保存したターンは、重複させずにそのまま残す', () => {
268
246
  const summary = { type: 'user', isCompactSummary: true, message: { role: 'user', content: 'This session is being continued' } };
269
247
  withData(
@@ -299,3 +277,77 @@ test('backfillBodies: 圧縮の要約行を数えていた頃に保存したタ
299
277
  },
300
278
  );
301
279
  });
280
+
281
+ // --- 作業途中で止めたターンの取り込み (ADR 0033) ---
282
+
283
+ function withDetails(db) {
284
+ db.exec(`
285
+ CREATE TABLE details (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, origin_session_id TEXT,
286
+ turn_number INTEGER, tool_name TEXT NOT NULL, input_text TEXT, output_text TEXT, token_count INTEGER NOT NULL DEFAULT 0,
287
+ created_at INTEGER NOT NULL, kind TEXT, source_id TEXT);
288
+ CREATE UNIQUE INDEX uq_details_source ON details(session_id, origin_session_id, source_id) WHERE source_id IS NOT NULL;
289
+ `);
290
+ return db;
291
+ }
292
+
293
+ const toolUse = (id, name, input) => ({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'tool_use', id, name, input }] } });
294
+ const toolResult = (id, content) => ({ type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, content }] } });
295
+
296
+ test('captureInFlightTurn: 止めたターンの発言を全部つないで本文にし、末尾の道具まで details に入れる。合流時の回収は重ねない', () => {
297
+ withData(
298
+ [
299
+ entry('user', '前の依頼', '2026-10-04T02:00:00Z'),
300
+ entry('assistant', '前の回答', '2026-10-04T02:00:05Z'),
301
+ entry('user', '3つ読んで', '2026-10-04T03:00:00Z'),
302
+ toolUse('t1', 'Read', { file_path: '/w/1' }),
303
+ toolResult('t1', 'one'),
304
+ entry('assistant', '1 を読みました。', '2026-10-04T03:00:02Z'),
305
+ toolUse('t2', 'Read', { file_path: '/w/2' }),
306
+ toolResult('t2', 'two'),
307
+ entry('assistant', '2 を読みました。', '2026-10-04T03:00:04Z'),
308
+ toolUse('t3', 'Read', { file_path: '/w/3' }),
309
+ toolResult('t3', 'PreToolUse:Read hook error'),
310
+ ],
311
+ (path) => {
312
+ const db = withDetails(makeDb());
313
+ const captured = captureInFlightTurn(db, { targetSessionId: 'S', originSessionId: 'S', transcriptPath: path, now: 9_000 });
314
+ assert.deepEqual(captured, { turnNumber: 4, userAt: Date.parse('2026-10-04T03:00:00Z'),
315
+ assistantAt: Date.parse('2026-10-04T03:00:04Z'), insertedBodies: true, details: 6 });
316
+ assert.deepEqual(db.prepare('SELECT turn_number, role, text, turn_start FROM bodies ORDER BY id').all().map((row) => ({ ...row })), [
317
+ { turn_number: 4, role: 'user', text: '3つ読んで', turn_start: 'unknown' },
318
+ { turn_number: 4, role: 'assistant', text: '1 を読みました。\n\n2 を読みました。', turn_start: null },
319
+ ]);
320
+ assert.deepEqual(db.prepare('SELECT source_id, turn_number FROM details ORDER BY id').all().map((row) => `${row.source_id}@${row.turn_number}`),
321
+ ['t1@4', 't1:result@4', 't2@4', 't2:result@4', 't3@4', 't3:result@4'], '最後の発言の後に呼んだ、止めた道具も入る');
322
+
323
+ // もう一度取り込んでも増えない
324
+ const again = captureInFlightTurn(db, { targetSessionId: 'S', originSessionId: 'S', transcriptPath: path, now: 9_500 });
325
+ assert.equal(again.turnNumber, 4);
326
+ assert.equal(again.insertedBodies, false);
327
+ assert.equal(db.prepare('SELECT COUNT(*) AS n FROM details').get().n, 6);
328
+
329
+ // 後継が前任を合流させる時の回収: 完了した前のターンだけを入れ、止めたターンへ代表断片を重ねない
330
+ const backfill = backfillBodies(db, { targetSessionId: 'S', originSessionId: 'S', transcriptPath: path, now: 9_900 });
331
+ assert.equal(backfill.insertedTurns, 1);
332
+ assert.equal(backfill.skippedExisting, 1);
333
+ assert.equal(db.prepare(`SELECT text FROM bodies WHERE turn_number = 4 AND role = 'assistant'`).get().text,
334
+ '1 を読みました。\n\n2 を読みました。');
335
+ },
336
+ );
337
+ });
338
+
339
+ test('captureInFlightTurn: 発言が1つも無いターンは、user の行と道具の入出力だけを入れる', () => {
340
+ withData(
341
+ [entry('user', '黙って読んで', '2026-10-04T03:00:00Z'), toolUse('t1', 'Read', { file_path: '/w/1' }), toolResult('t1', 'one'),
342
+ toolUse('t2', 'Read', { file_path: '/w/2' })],
343
+ (path) => {
344
+ const db = withDetails(makeDb());
345
+ const captured = captureInFlightTurn(db, { targetSessionId: 'S', originSessionId: 'S', transcriptPath: path, now: 9_000 });
346
+ assert.deepEqual(captured, { turnNumber: 0, userAt: Date.parse('2026-10-04T03:00:00Z'), assistantAt: null, insertedBodies: true, details: 3 });
347
+ assert.deepEqual(db.prepare('SELECT turn_number, role, text FROM bodies').all().map((row) => ({ ...row })),
348
+ [{ turn_number: 0, role: 'user', text: '黙って読んで' }]);
349
+ assert.equal(captureInFlightTurn(db, { targetSessionId: 'S', originSessionId: 'S', transcriptPath: join(path, 'none'), now: 1 }), null);
350
+ },
351
+ );
352
+ });
353
+
@@ -49,6 +49,7 @@ import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
49
49
  import { logHookFailure } from './hook-failure-log.mjs';
50
50
  import { writeCompletedTurnReceipt } from './completed-turn-receipts.mjs';
51
51
  import { hostAdapterForSessionId, parseHookPayload } from './hosts/index.mjs';
52
+ import { completeClaudeTurnWithoutHandoff } from './claude-auto-handoff.mjs';
52
53
 
53
54
  /** 直近 N ターンは bodies を生で残し、それより古いものだけ L1 要約する。 */
54
55
  export const L2_WINDOW = 20;
@@ -253,6 +254,9 @@ async function processStop() {
253
254
  if (!session_id) throw new Error('Missing session_id in Stop payload');
254
255
  failureContext = { session_id, transcript_path };
255
256
 
257
+ // 自動圧縮を止めた後、道具を呼ばずにターンが終わった。続ける作業が無いので、引き継ぎの記録を取り下げる (ADR 0033)。
258
+ completeClaudeTurnWithoutHandoff({ sessionId: session_id });
259
+
256
260
  // VSCode で開かれたプロジェクトに .vscode/tasks.json を自動プロビジョニングする。
257
261
  // 2 回目以降は冪等性チェックで即 return するので毎ターン走っても安全。
258
262
  // 失敗しても主処理は継続させるため try/catch でラップ。