throughline 0.12.8 → 0.13.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/CHANGELOG.md +35 -1
- package/README.ja.md +20 -0
- package/README.md +20 -0
- package/bin/throughline.mjs +10 -1
- package/docs/02_clear_auto_handoff_plan.md +2 -1
- package/docs/05_codex_first_roadmap.md +2 -0
- package/docs/adr/0031-serialized-schema-migration.md +43 -0
- package/docs/adr/0032-claude-compact-continuation.md +88 -0
- package/package.json +1 -1
- package/rag/01-hooks/raw/claude-compact-hooks-extract.md +56 -0
- package/rag/INDEX.md +4 -0
- package/src/claude-auto-handoff-config.mjs +38 -0
- package/src/claude-auto-handoff.mjs +207 -0
- package/src/claude-auto-handoff.test.mjs +308 -0
- package/src/claude-pre-compact.mjs +55 -0
- package/src/cli/auto-handoff.mjs +43 -5
- package/src/cli/install.mjs +28 -0
- package/src/cli/install.test.mjs +6 -0
- package/src/db-contention.test.mjs +27 -0
- package/src/db.mjs +62 -10
- package/src/hook-entrypoints.test.mjs +3 -1
- package/src/hook-failure-log.mjs +17 -1
- package/src/hook-failure-log.test.mjs +36 -2
- package/src/hosts/cursor.test.mjs +10 -0
- package/src/hosts/index.mjs +9 -1
- package/src/resume-context.mjs +101 -5
- package/src/resume-context.test.mjs +72 -0
- package/src/runtime-error-store.mjs +6 -0
- package/src/session-start.mjs +33 -1
- package/src/transcript-reader.mjs +58 -2
- package/src/transcript-reader.test.mjs +90 -0
- package/src/turn-backfill.mjs +7 -2
- package/src/turn-backfill.test.mjs +58 -0
|
@@ -25,6 +25,15 @@ function entryKind(entry) {
|
|
|
25
25
|
* @param {unknown} content
|
|
26
26
|
* @returns {string}
|
|
27
27
|
*/
|
|
28
|
+
/**
|
|
29
|
+
* Claude Code が圧縮の直後に書く要約行か。type は user だが、人の発言ではない
|
|
30
|
+
* (`isCompactSummary: true`。ADR 0032)。ターンの始まりにも本文にもしない。
|
|
31
|
+
* @param {object} entry
|
|
32
|
+
*/
|
|
33
|
+
export function isCompactSummaryEntry(entry) {
|
|
34
|
+
return entry?.isCompactSummary === true;
|
|
35
|
+
}
|
|
36
|
+
|
|
28
37
|
function extractText(content) {
|
|
29
38
|
if (typeof content === 'string') return content;
|
|
30
39
|
if (Array.isArray(content)) {
|
|
@@ -48,6 +57,9 @@ export function readTranscript(transcriptPath) {
|
|
|
48
57
|
const raw = readFileSync(transcriptPath, 'utf8');
|
|
49
58
|
|
|
50
59
|
const turns = [];
|
|
60
|
+
// turn_number は transcript 内の本文行の通し番号。圧縮の要約行は返さないが番号は進める。
|
|
61
|
+
// 番号を詰めると、要約行を数えていた頃に保存した bodies の turn_number と食い違う。
|
|
62
|
+
let position = 0;
|
|
51
63
|
for (const line of raw.split('\n')) {
|
|
52
64
|
const trimmed = line.trim();
|
|
53
65
|
if (!trimmed) continue;
|
|
@@ -81,14 +93,24 @@ export function readTranscript(transcriptPath) {
|
|
|
81
93
|
: extracted;
|
|
82
94
|
if (!text) continue;
|
|
83
95
|
|
|
96
|
+
const turnNumber = position++;
|
|
97
|
+
// 圧縮の要約は host が書いた行。ターンは圧縮をまたいで続くので、ここで区切らない。
|
|
98
|
+
if (isCompactSummaryEntry(entry)) continue;
|
|
99
|
+
|
|
84
100
|
const ts = typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : NaN;
|
|
85
101
|
// grok chat_history.jsonl は Claude の message 包みを持たず type/content 直置き。
|
|
86
102
|
turns.push({
|
|
87
103
|
role,
|
|
88
104
|
content: text,
|
|
89
|
-
turn_number:
|
|
105
|
+
turn_number: turnNumber,
|
|
90
106
|
timestamp: Number.isNaN(ts) ? null : ts,
|
|
91
|
-
...(role === 'user'
|
|
107
|
+
...(role === 'user'
|
|
108
|
+
? {
|
|
109
|
+
start: classifyTurnStart(entry, extracted),
|
|
110
|
+
// Claude Code が user 行に付ける prompt の id。hook payload の prompt_id と同じ値。
|
|
111
|
+
prompt_id: typeof entry.promptId === 'string' ? entry.promptId : null,
|
|
112
|
+
}
|
|
113
|
+
: {}),
|
|
92
114
|
});
|
|
93
115
|
}
|
|
94
116
|
|
|
@@ -160,6 +182,38 @@ export function getLogicalTurnGroups(transcriptPath) {
|
|
|
160
182
|
return groups;
|
|
161
183
|
}
|
|
162
184
|
|
|
185
|
+
/**
|
|
186
|
+
* 最後の user 発言と、それに続く assistant 本文の断片を返す。
|
|
187
|
+
*
|
|
188
|
+
* getLogicalTurnGroups は完了したターンの回収用で、本文の断片が無い群を返さない。
|
|
189
|
+
* こちらは作業途中のターン(Stop 前。本文がまだ無いこともある)を読むために使う。
|
|
190
|
+
*
|
|
191
|
+
* @param {string} transcriptPath
|
|
192
|
+
* @returns {{
|
|
193
|
+
* user: {content: string, timestamp: number|null, turn_number: number, start: 'prompt'|'self'|'unknown', prompt_id: string|null},
|
|
194
|
+
* fragments: Array<{index: number, content: string, timestamp: number|null}>,
|
|
195
|
+
* }|null}
|
|
196
|
+
*/
|
|
197
|
+
export function readLatestUserGroup(transcriptPath) {
|
|
198
|
+
const turns = readTranscript(transcriptPath);
|
|
199
|
+
let userIndex = -1;
|
|
200
|
+
for (let index = turns.length - 1; index >= 0; index--) {
|
|
201
|
+
if (turns[index].role === 'user') {
|
|
202
|
+
userIndex = index;
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (userIndex < 0) return null;
|
|
207
|
+
const { content, timestamp, turn_number, start, prompt_id } = turns[userIndex];
|
|
208
|
+
return {
|
|
209
|
+
user: { content, timestamp, turn_number, start, prompt_id },
|
|
210
|
+
fragments: turns
|
|
211
|
+
.slice(userIndex + 1)
|
|
212
|
+
.filter((turn) => turn.role === 'assistant')
|
|
213
|
+
.map((turn) => ({ index: turn.turn_number, content: turn.content, timestamp: turn.timestamp })),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
163
217
|
/**
|
|
164
218
|
* latest user group と、その1つ前のgroupが、どのassistant本文まで永続化されたかを返す。
|
|
165
219
|
*
|
|
@@ -315,6 +369,8 @@ export function sliceCurrentTurnEntries(entries) {
|
|
|
315
369
|
for (let i = lastAssistantTextIdx - 1; i >= 0; i--) {
|
|
316
370
|
const e = entries[i];
|
|
317
371
|
if (entryKind(e) !== 'user') continue;
|
|
372
|
+
// 圧縮の要約行はターンの始まりではない。圧縮より前の tool 入出力も同じターンに含める。
|
|
373
|
+
if (isCompactSummaryEntry(e)) continue;
|
|
318
374
|
const blocks = e.message?.content;
|
|
319
375
|
if (Array.isArray(blocks)) {
|
|
320
376
|
if (blocks.some((b) => b && b.type === 'text' && typeof b.text === 'string' && b.text.length > 0)) {
|
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
normalizeTerminalText,
|
|
8
8
|
normalizeToolResultContent,
|
|
9
9
|
readTranscript,
|
|
10
|
+
getLogicalTurnGroups,
|
|
11
|
+
readLatestUserGroup,
|
|
10
12
|
sliceCurrentTurnEntries,
|
|
11
13
|
extractDetailBlocks,
|
|
12
14
|
} from './transcript-reader.mjs';
|
|
@@ -153,6 +155,94 @@ test('sliceCurrentTurnEntries: 最後の user text → 最後の assistant text
|
|
|
153
155
|
assert.equal(slice[3].message.content[0].text, 'current response');
|
|
154
156
|
});
|
|
155
157
|
|
|
158
|
+
/** Claude Code が圧縮の直後に書く要約行(実測 2.1.289: type=user、本文は文字列) */
|
|
159
|
+
function compactSummaryEntry(text = 'This session is being continued from a previous conversation that ran out of context.') {
|
|
160
|
+
return { type: 'user', isCompactSummary: true, isVisibleInTranscriptOnly: true, message: { role: 'user', content: text } };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
test('readTranscript: 圧縮の要約行は返さず、turn_number は詰めない', () => {
|
|
164
|
+
const path = writeTranscript([
|
|
165
|
+
userEntry('依頼'),
|
|
166
|
+
asstTextEntry('途中の報告'),
|
|
167
|
+
compactSummaryEntry(),
|
|
168
|
+
asstTextEntry('最終回答'),
|
|
169
|
+
]);
|
|
170
|
+
const turns = readTranscript(path);
|
|
171
|
+
assert.deepEqual(turns.map((t) => [t.role, t.content, t.turn_number]), [
|
|
172
|
+
['user', '依頼', 0],
|
|
173
|
+
['assistant', '途中の報告', 1],
|
|
174
|
+
['assistant', '最終回答', 3],
|
|
175
|
+
]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('getLogicalTurnGroups: 圧縮をまたいだターンは1つの群になり、元の依頼を user に持つ', () => {
|
|
179
|
+
const path = writeTranscript([
|
|
180
|
+
userEntry('依頼'),
|
|
181
|
+
asstTextEntry('途中の報告'),
|
|
182
|
+
compactSummaryEntry(),
|
|
183
|
+
asstTextEntry('圧縮後の報告'),
|
|
184
|
+
compactSummaryEntry(),
|
|
185
|
+
asstTextEntry('最終回答'),
|
|
186
|
+
userEntry('次の依頼'),
|
|
187
|
+
asstTextEntry('次の回答'),
|
|
188
|
+
]);
|
|
189
|
+
const groups = getLogicalTurnGroups(path);
|
|
190
|
+
assert.equal(groups.length, 2);
|
|
191
|
+
assert.equal(groups[0].user.content, '依頼');
|
|
192
|
+
assert.deepEqual(groups[0].fragments.map((f) => f.index), [1, 3, 5]);
|
|
193
|
+
assert.equal(groups[0].representative.content, '最終回答');
|
|
194
|
+
assert.equal(groups[1].user.content, '次の依頼');
|
|
195
|
+
assert.equal(groups[1].representative.index, 7);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('readLatestUserGroup: 作業途中のターンを、本文がまだ無くても返す', () => {
|
|
199
|
+
const noAnswer = writeTranscript([
|
|
200
|
+
userEntry('前の依頼'),
|
|
201
|
+
asstTextEntry('前の回答'),
|
|
202
|
+
userEntry('今の依頼'),
|
|
203
|
+
asstToolUseEntry('toolu_1', 'Read', { file_path: '/a' }),
|
|
204
|
+
userToolResultEntry('toolu_1', 'contents'),
|
|
205
|
+
compactSummaryEntry(),
|
|
206
|
+
]);
|
|
207
|
+
assert.deepEqual(readLatestUserGroup(noAnswer), {
|
|
208
|
+
user: { content: '今の依頼', timestamp: null, turn_number: 2, start: 'unknown', prompt_id: null },
|
|
209
|
+
fragments: [],
|
|
210
|
+
});
|
|
211
|
+
const withAnswer = writeTranscript([
|
|
212
|
+
userEntry('今の依頼'),
|
|
213
|
+
asstTextEntry('途中1'),
|
|
214
|
+
compactSummaryEntry(),
|
|
215
|
+
asstTextEntry('途中2'),
|
|
216
|
+
]);
|
|
217
|
+
const latest = readLatestUserGroup(withAnswer);
|
|
218
|
+
assert.equal(latest.user.content, '今の依頼');
|
|
219
|
+
assert.deepEqual(latest.fragments.map((f) => [f.index, f.content]), [[1, '途中1'], [3, '途中2']]);
|
|
220
|
+
assert.equal(readLatestUserGroup(writeTranscript([asstTextEntry('user 行が無い')])), null);
|
|
221
|
+
const withPromptId = writeTranscript([{ ...userEntry('依頼'), promptId: 'prompt-1' }]);
|
|
222
|
+
assert.equal(readLatestUserGroup(withPromptId).user.prompt_id, 'prompt-1');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('sliceCurrentTurnEntries: 圧縮の要約行で区切らず、圧縮より前の tool も同じターンに含める', () => {
|
|
226
|
+
const entries = [
|
|
227
|
+
userEntry('old prompt'),
|
|
228
|
+
asstTextEntry('old response'),
|
|
229
|
+
userEntry('current prompt'),
|
|
230
|
+
asstToolUseEntry('toolu_1', 'Read', { file_path: '/a' }),
|
|
231
|
+
userToolResultEntry('toolu_1', 'before compaction'),
|
|
232
|
+
{ type: 'system', subtype: 'compact_boundary', compactMetadata: { trigger: 'auto' } },
|
|
233
|
+
compactSummaryEntry(),
|
|
234
|
+
asstToolUseEntry('toolu_2', 'Write', { file_path: '/b' }),
|
|
235
|
+
userToolResultEntry('toolu_2', 'after compaction'),
|
|
236
|
+
asstTextEntry('current response'),
|
|
237
|
+
];
|
|
238
|
+
const slice = sliceCurrentTurnEntries(entries);
|
|
239
|
+
assert.equal(slice[0].message.content[0].text, 'current prompt');
|
|
240
|
+
assert.deepEqual(
|
|
241
|
+
extractDetailBlocks(slice).filter((d) => d.kind === DETAIL_KIND.TOOL_INPUT).map((d) => d.tool_name),
|
|
242
|
+
['Read', 'Write'],
|
|
243
|
+
);
|
|
244
|
+
});
|
|
245
|
+
|
|
156
246
|
test('sliceCurrentTurnEntries: 空配列なら空を返す', () => {
|
|
157
247
|
assert.deepEqual(sliceCurrentTurnEntries([]), []);
|
|
158
248
|
});
|
package/src/turn-backfill.mjs
CHANGED
|
@@ -59,10 +59,15 @@ export function deriveTranscriptPath(projectPath, sessionId) {
|
|
|
59
59
|
* @param {string} opts.originSessionId transcript を所有する origin session_id
|
|
60
60
|
* @param {string|null|undefined} opts.transcriptPath
|
|
61
61
|
* @param {number} opts.now timestamp 欠損時の fallback epoch ms
|
|
62
|
+
* @param {number|null} [opts.beforeUserTurnNumber] 指定した時は、user 行の番号がこれより前の群だけを回収する。
|
|
63
|
+
* 作業途中のターンがある時点(自動圧縮の直後。ADR 0032)で呼ぶ時に、その群を除くために使う。
|
|
64
|
+
* 途中の断片を代表として保存すると、群レベル dedup で最終回答が二度と入らない。
|
|
62
65
|
* @returns {{groups: number, insertedTurns: number, skippedExisting: number, lastTurnNumber: number|null, turnNumbers: number[]}}
|
|
63
66
|
*/
|
|
64
|
-
export function backfillBodies(db, { targetSessionId, originSessionId, transcriptPath, now }) {
|
|
65
|
-
const groups = getLogicalTurnGroups(transcriptPath)
|
|
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
|
+
);
|
|
66
71
|
if (groups.length === 0) {
|
|
67
72
|
return { groups: 0, insertedTurns: 0, skippedExisting: 0, lastTurnNumber: null, turnNumbers: [] };
|
|
68
73
|
}
|
|
@@ -241,3 +241,61 @@ test('backfillBodies: user row keeps the turn start and assistant row leaves it
|
|
|
241
241
|
},
|
|
242
242
|
);
|
|
243
243
|
});
|
|
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
|
+
test('backfillBodies: 圧縮の要約行を数えていた頃に保存したターンは、重複させずにそのまま残す', () => {
|
|
268
|
+
const summary = { type: 'user', isCompactSummary: true, message: { role: 'user', content: 'This session is being continued' } };
|
|
269
|
+
withData(
|
|
270
|
+
[
|
|
271
|
+
entry('user', 'prompt', '2026-10-04T00:00:00Z'),
|
|
272
|
+
entry('assistant', 'before compaction', '2026-10-04T00:00:01Z'),
|
|
273
|
+
summary,
|
|
274
|
+
entry('assistant', 'after compaction', '2026-10-04T00:00:03Z'),
|
|
275
|
+
entry('user', 'next prompt', '2026-10-04T00:00:04Z'),
|
|
276
|
+
entry('assistant', 'next answer', '2026-10-04T00:00:05Z'),
|
|
277
|
+
],
|
|
278
|
+
(path) => {
|
|
279
|
+
const db = makeDb();
|
|
280
|
+
// 旧版は要約行を user 発言として、圧縮後の断片 (index 3) と組で保存していた
|
|
281
|
+
const insert = db.prepare(
|
|
282
|
+
`INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
|
|
283
|
+
VALUES ('S', 'S', 3, ?, ?, 1, 1)`,
|
|
284
|
+
);
|
|
285
|
+
insert.run('user', 'This session is being continued');
|
|
286
|
+
insert.run('assistant', 'after compaction');
|
|
287
|
+
const result = backfillBodies(db, { targetSessionId: 'S', originSessionId: 'S', transcriptPath: path, now: 1 });
|
|
288
|
+
assert.equal(result.skippedExisting, 1, '同じ断片を持つ群は入れ直さない');
|
|
289
|
+
assert.equal(result.insertedTurns, 1);
|
|
290
|
+
assert.deepEqual(
|
|
291
|
+
db.prepare('SELECT turn_number, role, text FROM bodies ORDER BY turn_number, role DESC').all().map((r) => [r.turn_number, r.role, r.text]),
|
|
292
|
+
[
|
|
293
|
+
[3, 'user', 'This session is being continued'],
|
|
294
|
+
[3, 'assistant', 'after compaction'],
|
|
295
|
+
[5, 'user', 'next prompt'],
|
|
296
|
+
[5, 'assistant', 'next answer'],
|
|
297
|
+
],
|
|
298
|
+
);
|
|
299
|
+
},
|
|
300
|
+
);
|
|
301
|
+
});
|