throughline 0.12.9 → 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 +21 -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/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/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
package/src/resume-context.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 呼び出し元:
|
|
5
5
|
* - handoff-executor.mjs(最初のUserPromptSubmitでauto path / baton path共通注入)
|
|
6
|
+
* - claude-auto-handoff.mjs(自動圧縮の直後のSessionStartで同じ会話へ注入。ADR 0032)
|
|
6
7
|
*
|
|
7
8
|
* 設計 (docs/02_clear_auto_handoff_plan.md):
|
|
8
9
|
* - 予算付き注入: ヘッダ + 現在地アンカー + pull案内を固定部とし、残りへ
|
|
@@ -55,6 +56,28 @@ const RESUME_HEADER_TEMPLATE = (turnCount, handoffDisclosure) =>
|
|
|
55
56
|
|
|
56
57
|
const NORMAL_HEADER = '## Throughline: セッション記憶';
|
|
57
58
|
|
|
59
|
+
const DEFAULT_ANCHOR_TITLE = '### 現在地 (直前のやりとり)';
|
|
60
|
+
|
|
61
|
+
// 自動圧縮の直後に、同じ会話へ注入する時のヘッダ (ADR 0032)。/clear の引き継ぎと違い、
|
|
62
|
+
// 作業の途中で、次のユーザー入力は来ない。宣言はさせず、そのまま続けさせる。
|
|
63
|
+
const COMPACT_HEADER =
|
|
64
|
+
`## Throughline: 自動圧縮後の継続用コンテキスト\n` +
|
|
65
|
+
`\n` +
|
|
66
|
+
`**読み方 (重要):**\n` +
|
|
67
|
+
`- この会話は、たった今 Claude Code の自動圧縮で要約に置き換わりました。下記は Throughline が` +
|
|
68
|
+
`圧縮の前に保存した **あなた自身とユーザーの会話の原文** です。\n` +
|
|
69
|
+
`- 圧縮の要約と下記の記録が食い違う時は、下記の記録(元のユーザーの依頼・合意)を正としてください。\n` +
|
|
70
|
+
`- 「現在地」のターンは **作業の途中** です。ユーザーの追加入力を待たず、未完了の作業をそのまま続けてください。\n` +
|
|
71
|
+
`- 完了済みの操作を繰り返さないでください。直前の実行結果が分からない時は、作業ツリーや記録を確かめてから続けてください。\n` +
|
|
72
|
+
'- **完了したターンの詳細**: **`Bash` ツールで `throughline detail HH:MM:SS` を実行** ' +
|
|
73
|
+
`(該当ターンの本文+詳細を stdout に返します)`;
|
|
74
|
+
|
|
75
|
+
const COMPACT_ANCHOR_TITLE = '### 現在地 (作業途中のターン)';
|
|
76
|
+
// 作業途中のターンは Stop の前で、まだ bodies に無い。依頼の原文を残せる場所はここだけなので、
|
|
77
|
+
// /clear のアンカー (600 字) より厚く取る。
|
|
78
|
+
const IN_FLIGHT_USER_MAX_CHARS = 4_000;
|
|
79
|
+
const IN_FLIGHT_ASSISTANT_MAX_CHARS = 1_500;
|
|
80
|
+
|
|
58
81
|
// v0.10.12以前にThroughline自身がassistantへ付与させていた宣言行。
|
|
59
82
|
// 製品由来のメタ情報だけを引き継ぎ材料から除き、会話本文への模倣連鎖を止める。
|
|
60
83
|
// userが同じ文を引用した場合は会話内容なので保持する。
|
|
@@ -193,14 +216,14 @@ function buildResumeSections(
|
|
|
193
216
|
});
|
|
194
217
|
}
|
|
195
218
|
|
|
196
|
-
return { header, anchorLines, l1Lines, l2Lines };
|
|
219
|
+
return { header, anchorTitle: DEFAULT_ANCHOR_TITLE, anchorLines, l1Lines, l2Lines };
|
|
197
220
|
}
|
|
198
221
|
|
|
199
|
-
function joinSections({ header, anchorLines, l1Lines, l2Lines }, { l1Note = null, l2Note = null } = {}) {
|
|
222
|
+
function joinSections({ header, anchorTitle, anchorLines, l1Lines, l2Lines }, { l1Note = null, l2Note = null } = {}) {
|
|
200
223
|
const lines = [header];
|
|
201
224
|
if (anchorLines.length > 0) {
|
|
202
225
|
lines.push('');
|
|
203
|
-
lines.push(
|
|
226
|
+
lines.push(anchorTitle);
|
|
204
227
|
lines.push(...anchorLines);
|
|
205
228
|
}
|
|
206
229
|
if (l1Lines.length > 0 || l1Note) {
|
|
@@ -396,7 +419,11 @@ export function buildBudgetedResumeContext(
|
|
|
396
419
|
handoffDisclosure,
|
|
397
420
|
});
|
|
398
421
|
if (!sections) return null;
|
|
422
|
+
return renderBudgetedSections(db, sections, { sessionId, excludeOriginId, maxChars });
|
|
423
|
+
}
|
|
399
424
|
|
|
425
|
+
/** 組み立て済みのセクションを、予算内の注入テキストにする(/clear の引き継ぎと自動圧縮後の継続が共有)。 */
|
|
426
|
+
function renderBudgetedSections(db, sections, { sessionId, excludeOriginId, maxChars }) {
|
|
400
427
|
const lineCost = (s) => s.length + 1; // join('\n') 分
|
|
401
428
|
|
|
402
429
|
// L2 行をターン単位のグループにまとめる(元の古い順を保つ)
|
|
@@ -418,7 +445,7 @@ export function buildBudgetedResumeContext(
|
|
|
418
445
|
const fixedCost =
|
|
419
446
|
lineCost(sections.header) +
|
|
420
447
|
(sections.anchorLines.length > 0
|
|
421
|
-
? lineCost('') + lineCost(
|
|
448
|
+
? lineCost('') + lineCost(sections.anchorTitle) +
|
|
422
449
|
sections.anchorLines.reduce((a, l) => a + lineCost(l), 0)
|
|
423
450
|
: 0) +
|
|
424
451
|
lineCost('') + lineCost('### 直前の対話 (L2 / active work thread, 古い順)');
|
|
@@ -487,7 +514,7 @@ export function buildBudgetedResumeContext(
|
|
|
487
514
|
const lines = [sections.header];
|
|
488
515
|
if (sections.anchorLines.length > 0) {
|
|
489
516
|
lines.push('');
|
|
490
|
-
lines.push(
|
|
517
|
+
lines.push(sections.anchorTitle);
|
|
491
518
|
lines.push(...sections.anchorLines);
|
|
492
519
|
}
|
|
493
520
|
lines.push('');
|
|
@@ -511,3 +538,72 @@ export function buildBudgetedResumeContext(
|
|
|
511
538
|
truncatedNewestL2,
|
|
512
539
|
};
|
|
513
540
|
}
|
|
541
|
+
|
|
542
|
+
// 長い依頼は、指示が先頭にも末尾にも書かれる。先頭 3/4 と末尾 1/4 を残し、間を抜いたことを明示する。
|
|
543
|
+
function truncateInFlight(text, maxChars) {
|
|
544
|
+
const trimmed = text.trim();
|
|
545
|
+
if (trimmed.length <= maxChars) return trimmed;
|
|
546
|
+
const head = Math.ceil(maxChars * 0.75);
|
|
547
|
+
const tail = maxChars - head;
|
|
548
|
+
return `${trimmed.slice(0, head)} …(長いため中略 ${trimmed.length - maxChars} 字)… ${trimmed.slice(-tail)}`;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* 自動圧縮の直後に、同じ会話へ注入するテキスト (ADR 0032)。
|
|
553
|
+
*
|
|
554
|
+
* 構成は /clear の引き継ぎと同じ予算付き(ヘッダ + 現在地 + 案内 + 入るだけの L2)。違いは現在地で、
|
|
555
|
+
* DB の最新ターンではなく、transcript から読んだ作業途中のターンを載せる。作業途中のターンは
|
|
556
|
+
* Stop の前で bodies に無いので、L2 セクションには出ない。
|
|
557
|
+
*
|
|
558
|
+
* 完了したターンが 1 つも無い会話(最初のターンの途中で圧縮)でも、現在地だけを返す。
|
|
559
|
+
*
|
|
560
|
+
* @param {import('node:sqlite').DatabaseSync} db
|
|
561
|
+
* @param {{
|
|
562
|
+
* sessionId: string,
|
|
563
|
+
* inFlight: {
|
|
564
|
+
* user: {content: string, timestamp: number|null},
|
|
565
|
+
* fragments: Array<{content: string, timestamp: number|null}>,
|
|
566
|
+
* } | null,
|
|
567
|
+
* inFlightUnreadable?: boolean, transcript に今の依頼がまだ書かれていない(hook の prompt_id と合わない)
|
|
568
|
+
* maxChars?: number,
|
|
569
|
+
* }} params
|
|
570
|
+
*/
|
|
571
|
+
export function buildCompactContinuationContext(
|
|
572
|
+
db,
|
|
573
|
+
{ sessionId, inFlight, inFlightUnreadable = false, maxChars = INJECTION_BUDGET_CHARS },
|
|
574
|
+
) {
|
|
575
|
+
const stored = buildResumeSections(db, { sessionId, isInheritance: true, handoffDisclosure: 'silent' });
|
|
576
|
+
const stamp = (timestamp) => (timestamp == null ? '' : ` [${formatClock(timestamp)}]`);
|
|
577
|
+
const anchorLines = [];
|
|
578
|
+
if (inFlight) {
|
|
579
|
+
anchorLines.push(
|
|
580
|
+
`**作業中のユーザー依頼**${stamp(inFlight.user.timestamp)}: ` +
|
|
581
|
+
truncateInFlight(inFlight.user.content, IN_FLIGHT_USER_MAX_CHARS),
|
|
582
|
+
);
|
|
583
|
+
const lastFragment = inFlight.fragments.at(-1);
|
|
584
|
+
if (lastFragment) {
|
|
585
|
+
anchorLines.push(
|
|
586
|
+
`**圧縮直前のあなたの発言**${stamp(lastFragment.timestamp)}: ` +
|
|
587
|
+
truncateInFlight(lastFragment.content, IN_FLIGHT_ASSISTANT_MAX_CHARS),
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
if (!inFlight && inFlightUnreadable) {
|
|
592
|
+
anchorLines.push(
|
|
593
|
+
'**作業中のユーザー依頼**: (記録からまだ読めません。圧縮後の文脈にある最新のユーザー依頼を続けてください)',
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
if (!stored && anchorLines.length === 0) return null;
|
|
597
|
+
|
|
598
|
+
return renderBudgetedSections(
|
|
599
|
+
db,
|
|
600
|
+
{
|
|
601
|
+
header: COMPACT_HEADER,
|
|
602
|
+
anchorTitle: COMPACT_ANCHOR_TITLE,
|
|
603
|
+
anchorLines,
|
|
604
|
+
l1Lines: [],
|
|
605
|
+
l2Lines: stored?.l2Lines ?? [],
|
|
606
|
+
},
|
|
607
|
+
{ sessionId, excludeOriginId: null, maxChars },
|
|
608
|
+
);
|
|
609
|
+
}
|
|
@@ -4,6 +4,7 @@ import { DatabaseSync } from 'node:sqlite';
|
|
|
4
4
|
import {
|
|
5
5
|
buildResumeContext,
|
|
6
6
|
buildBudgetedResumeContext,
|
|
7
|
+
buildCompactContinuationContext,
|
|
7
8
|
INJECTION_BUDGET_CHARS,
|
|
8
9
|
} from './resume-context.mjs';
|
|
9
10
|
|
|
@@ -905,3 +906,74 @@ test('budgeted: header and anchor always survive even under pressure', () => {
|
|
|
905
906
|
assert.match(budgeted.text, /^## Throughline: 直前スレッドの継続応答用コンテキスト/);
|
|
906
907
|
assert.ok(budgeted.text.includes('### 現在地 (直前のやりとり)'));
|
|
907
908
|
});
|
|
909
|
+
|
|
910
|
+
// --- 自動圧縮後の継続 (ADR 0032) ---
|
|
911
|
+
|
|
912
|
+
test('buildCompactContinuationContext: 完了したターンが無くても、作業途中のターンだけで組み立てる', () => {
|
|
913
|
+
const db = makeDb();
|
|
914
|
+
const result = buildCompactContinuationContext(db, {
|
|
915
|
+
sessionId: 'S',
|
|
916
|
+
inFlight: { user: { content: '最初の依頼', timestamp: null }, fragments: [] },
|
|
917
|
+
});
|
|
918
|
+
assert.match(result.text, /^## Throughline: 自動圧縮後の継続用コンテキスト\n/);
|
|
919
|
+
assert.match(result.text, /### 現在地 \(作業途中のターン\)\n\*\*作業中のユーザー依頼\*\*: 最初の依頼\n/);
|
|
920
|
+
assert.doesNotMatch(result.text, /圧縮直前のあなたの発言/);
|
|
921
|
+
assert.doesNotMatch(result.text, /### 直前の対話/);
|
|
922
|
+
assert.equal(result.injectedL2Turns, 0);
|
|
923
|
+
assert.equal(buildCompactContinuationContext(db, { sessionId: 'S', inFlight: null }), null);
|
|
924
|
+
});
|
|
925
|
+
|
|
926
|
+
test('buildCompactContinuationContext: 現在地は作業途中のターンで、保存済みのターンはL2に並び、宣言は求めない', () => {
|
|
927
|
+
const db = makeDb();
|
|
928
|
+
insertBody(db, { session: 'S', origin: 'S', turn: 1, role: 'user', text: '前の依頼', createdAt: 1_000 });
|
|
929
|
+
insertBody(db, { session: 'S', origin: 'S', turn: 1, role: 'assistant', text: '前の回答', createdAt: 2_000 });
|
|
930
|
+
const result = buildCompactContinuationContext(db, {
|
|
931
|
+
sessionId: 'S',
|
|
932
|
+
inFlight: {
|
|
933
|
+
user: { content: '今の依頼', timestamp: 3_000 },
|
|
934
|
+
fragments: [{ content: '途中1', timestamp: 4_000 }, { content: '途中2', timestamp: 5_000 }],
|
|
935
|
+
},
|
|
936
|
+
});
|
|
937
|
+
const anchor = result.text.slice(result.text.indexOf('### 現在地'), result.text.indexOf('### さらに前の記憶'));
|
|
938
|
+
assert.match(anchor, /\*\*作業中のユーザー依頼\*\* \[\d\d:\d\d:\d\d\]: 今の依頼\n\*\*圧縮直前のあなたの発言\*\* \[\d\d:\d\d:\d\d\]: 途中2\n/);
|
|
939
|
+
assert.doesNotMatch(anchor, /前の依頼|途中1/);
|
|
940
|
+
assert.match(result.text, /### 直前の対話 \(L2 \/ active work thread, 古い順\)\n\[\d\d:\d\d:\d\d\] \[user\]: 前の依頼\n\[\d\d:\d\d:\d\d\] \[assistant\]: 前の回答/);
|
|
941
|
+
assert.doesNotMatch(result.text, /宣言|\/clear/);
|
|
942
|
+
assert.equal(result.injectedL2Turns, 1);
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
test('buildCompactContinuationContext: 長い依頼と多数のターンでも予算内に収め、依頼は切り詰めを明示する', () => {
|
|
946
|
+
const db = makeDb();
|
|
947
|
+
for (let turn = 1; turn <= 12; turn++) {
|
|
948
|
+
insertBody(db, { session: 'S', origin: 'S', turn, role: 'user', text: `依頼${turn} ` + 'あ'.repeat(600), createdAt: turn * 1_000 });
|
|
949
|
+
insertBody(db, { session: 'S', origin: 'S', turn, role: 'assistant', text: `回答${turn} ` + 'い'.repeat(600), createdAt: turn * 1_000 + 500 });
|
|
950
|
+
}
|
|
951
|
+
const result = buildCompactContinuationContext(db, {
|
|
952
|
+
sessionId: 'S',
|
|
953
|
+
inFlight: {
|
|
954
|
+
user: { content: 'ヰ'.repeat(6_000), timestamp: 20_000 },
|
|
955
|
+
fragments: [{ content: 'ヱ'.repeat(3_000), timestamp: 21_000 }],
|
|
956
|
+
},
|
|
957
|
+
});
|
|
958
|
+
assert.ok(result.totalChars <= INJECTION_BUDGET_CHARS, `${result.totalChars}`);
|
|
959
|
+
assert.equal((result.text.match(/ヰ/g) ?? []).length, 4_000);
|
|
960
|
+
assert.equal((result.text.match(/ヱ/g) ?? []).length, 1_500);
|
|
961
|
+
assert.match(result.text, /ヰ …\(長いため中略 2000 字\)… ヰ/);
|
|
962
|
+
assert.match(result.text, /ヱ …\(長いため中略 1500 字\)… ヱ/);
|
|
963
|
+
assert.ok(result.injectedL2Turns >= 1, '最新の保存済みターンは入る');
|
|
964
|
+
assert.match(result.text, /回答12/);
|
|
965
|
+
assert.equal(result.injectedL2Turns + result.remainingL2Turns, 12);
|
|
966
|
+
assert.match(result.text, /throughline recall --l2 --session S /);
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
test('buildCompactContinuationContext: 長い依頼は先頭と末尾を残す。今の依頼が記録から読めない時は、その旨だけを現在地に書く', () => {
|
|
970
|
+
const db = makeDb();
|
|
971
|
+
const long = buildCompactContinuationContext(db, {
|
|
972
|
+
sessionId: 'S',
|
|
973
|
+
inFlight: { user: { content: '先頭の指示' + 'あ'.repeat(6_000) + '末尾の指示', timestamp: null }, fragments: [] },
|
|
974
|
+
});
|
|
975
|
+
assert.match(long.text, /\*\*作業中のユーザー依頼\*\*: 先頭の指示あ+ …\(長いため中略 2010 字\)… あ+末尾の指示\n/);
|
|
976
|
+
|
|
977
|
+
const unreadable = buildCompactContinuationContext(db, { sessionId: 'S', inFlight: null, inFlightUnreadable: true });
|
|
978
|
+
assert.match(unreadable.text, /### 現在地 \(作業途中のターン\)\n\*\*作業中のユーザー依頼\*\*: (記録からまだ読めません。圧縮後の文脈にある最新のユーザー依頼を続けてください)\n/);
|
|
979
|
+
});
|
|
@@ -47,6 +47,12 @@ const DEFINITIONS = Object.freeze({
|
|
|
47
47
|
severity: 'high',
|
|
48
48
|
template: 'Throughline Claude Stop hook processing failed',
|
|
49
49
|
}),
|
|
50
|
+
// 自動継続の印を残せなかった。圧縮と作業は続く(ADR 0032)。
|
|
51
|
+
HOOK_PRE_COMPACT_FAILED: Object.freeze({
|
|
52
|
+
component: 'claude_pre_compact_hook',
|
|
53
|
+
severity: 'warn',
|
|
54
|
+
template: 'Throughline Claude PreCompact hook processing failed',
|
|
55
|
+
}),
|
|
50
56
|
HOOK_CODEX_FAILED: Object.freeze({
|
|
51
57
|
component: 'codex_hook',
|
|
52
58
|
severity: 'high',
|
package/src/session-start.mjs
CHANGED
|
@@ -21,6 +21,12 @@
|
|
|
21
21
|
*
|
|
22
22
|
* バトンの消費・merge・注入は最初の UserPromptSubmit (= 実体の証明) で行う。
|
|
23
23
|
* 幽霊はプロンプトを発火しないため記憶を奪えない。
|
|
24
|
+
*
|
|
25
|
+
* 【自動圧縮後の継続】 ADR 0032
|
|
26
|
+
*
|
|
27
|
+
* source='compact' は新しい会話ではなく、圧縮を終えた同じ会話。PreCompact hook が
|
|
28
|
+
* 自動圧縮の印を残していた時だけ、作業途中のターンと直近の会話の原文を stdout へ書く。
|
|
29
|
+
* host がそのまま作業を続けるので、次の UserPromptSubmit は待たない。
|
|
24
30
|
*/
|
|
25
31
|
|
|
26
32
|
import { getDb } from './db.mjs';
|
|
@@ -33,8 +39,9 @@ import { existsSync } from 'node:fs';
|
|
|
33
39
|
import { pathToFileURL } from 'node:url';
|
|
34
40
|
import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
|
|
35
41
|
import { logHookFailure } from './hook-failure-log.mjs';
|
|
36
|
-
import { hostAdapterForSessionId, NON_CLAUDE_SESSION_PREFIXES, parseHookPayload } from './hosts/index.mjs';
|
|
42
|
+
import { CLAUDE_HOST, hostAdapterForSessionId, hostOfSessionId, NON_CLAUDE_SESSION_PREFIXES, parseHookPayload } from './hosts/index.mjs';
|
|
37
43
|
import { executeFirstPromptHandoff } from './handoff-executor.mjs';
|
|
44
|
+
import { consumeClaudeCompactRequest, buildClaudeCompactContinuation } from './claude-auto-handoff.mjs';
|
|
38
45
|
|
|
39
46
|
const ENV_DISABLE_AUTO_HANDOFF = 'THROUGHLINE_DISABLE_AUTO_HANDOFF';
|
|
40
47
|
|
|
@@ -163,6 +170,31 @@ export async function run() {
|
|
|
163
170
|
pending_registered: true,
|
|
164
171
|
});
|
|
165
172
|
|
|
173
|
+
// 自動圧縮の直後: 印があれば、圧縮前の記録を同じ会話へ注入する (ADR 0032)。
|
|
174
|
+
// subagent の中で走った hook は、親の会話の印を消費しない。
|
|
175
|
+
if (source === 'compact' && hostOfSessionId(session_id) === CLAUDE_HOST && !payload.agent_id) {
|
|
176
|
+
const request = consumeClaudeCompactRequest({ sessionId: session_id });
|
|
177
|
+
if (request) {
|
|
178
|
+
const continuation = buildClaudeCompactContinuation(db, {
|
|
179
|
+
sessionId: session_id,
|
|
180
|
+
transcriptPath: transcript_path ?? request.transcript_path,
|
|
181
|
+
promptId: payload.prompt_id ?? null,
|
|
182
|
+
now,
|
|
183
|
+
});
|
|
184
|
+
if (continuation) process.stdout.write(continuation.text + '\n');
|
|
185
|
+
logDecision({
|
|
186
|
+
ts: new Date(now).toISOString(),
|
|
187
|
+
phase: 'compact-continuation',
|
|
188
|
+
session_id,
|
|
189
|
+
project_path: request.project_path,
|
|
190
|
+
transcript_path: transcript_path ?? request.transcript_path ?? null,
|
|
191
|
+
requested_at: new Date(request.requested_at).toISOString(),
|
|
192
|
+
injected: Boolean(continuation),
|
|
193
|
+
...(continuation?.stats ?? {}),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
166
198
|
// Cursor は beforeSubmitPrompt が additional_context を持たない。
|
|
167
199
|
// 新規 composer 会話の sessionStart が公式の注入口なので、ここで第二相を実行する。
|
|
168
200
|
// Claude の幽霊 SessionStart 問題はこの host では sessionStart = 会話作成のため適用しない。
|
|
@@ -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
|
+
});
|