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.
- package/CHANGELOG.md +46 -1
- package/README.ja.md +14 -7
- package/README.md +14 -7
- package/bin/throughline.mjs +10 -4
- package/docs/02_clear_auto_handoff_plan.md +2 -2
- package/docs/05_codex_first_roadmap.md +3 -2
- package/docs/adr/0032-claude-compact-continuation.md +2 -0
- package/docs/adr/0033-claude-auto-handoff-new-session.md +104 -0
- package/package.json +2 -2
- package/rag/01-hooks/raw/claude-compact-hooks-extract.md +28 -3
- package/rag/INDEX.md +1 -1
- package/src/claude-auto-handoff.mjs +540 -135
- package/src/claude-auto-handoff.test.mjs +623 -193
- package/src/claude-pre-compact.mjs +21 -7
- package/src/claude-pre-tool-use.mjs +67 -0
- package/src/cli/auto-handoff.mjs +25 -19
- package/src/cli/install.mjs +67 -18
- package/src/cli/install.test.mjs +22 -4
- package/src/handoff-executor.mjs +24 -12
- package/src/hosts/claude.mjs +10 -0
- package/src/prompt-submit.mjs +1 -0
- package/src/resume-context.mjs +108 -40
- package/src/resume-context.test.mjs +88 -31
- package/src/runtime-error-store.mjs +6 -1
- package/src/session-start.mjs +8 -28
- package/src/transcript-reader.mjs +26 -4
- package/src/transcript-reader.test.mjs +43 -0
- package/src/turn-backfill.mjs +82 -8
- package/src/turn-backfill.test.mjs +75 -23
- package/src/turn-processor.mjs +4 -0
package/src/resume-context.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 呼び出し元:
|
|
5
5
|
* - handoff-executor.mjs(最初のUserPromptSubmitでauto path / baton path共通注入)
|
|
6
|
-
* -
|
|
6
|
+
* - handoff-executor.mjs(前任が自動継続で引き継いだ会話の、最初のUserPromptSubmit。ADR 0033)
|
|
7
7
|
*
|
|
8
8
|
* 設計 (docs/02_clear_auto_handoff_plan.md):
|
|
9
9
|
* - 予算付き注入: ヘッダ + 現在地アンカー + pull案内を固定部とし、残りへ
|
|
@@ -58,25 +58,29 @@ const NORMAL_HEADER = '## Throughline: セッション記憶';
|
|
|
58
58
|
|
|
59
59
|
const DEFAULT_ANCHOR_TITLE = '### 現在地 (直前のやりとり)';
|
|
60
60
|
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
const
|
|
64
|
-
`## Throughline:
|
|
61
|
+
// 自動継続で立てた新しい会話へ注入する時のヘッダ (ADR 0033)。/clear の引き継ぎと違い、
|
|
62
|
+
// 前の会話は作業の途中で止めてあり、次のユーザー入力は来ない。宣言はさせず、そのまま続けさせる。
|
|
63
|
+
const AUTO_CONTINUATION_HEADER = (projectPath) =>
|
|
64
|
+
`## Throughline: 自動継続の文脈\n` +
|
|
65
|
+
(projectPath ? `作業ディレクトリ: ${projectPath}\n` : '') +
|
|
65
66
|
`\n` +
|
|
66
67
|
`**読み方 (重要):**\n` +
|
|
67
|
-
`-
|
|
68
|
-
|
|
69
|
-
`-
|
|
70
|
-
`-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
`
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
//
|
|
78
|
-
|
|
68
|
+
`- 前の会話は文脈の上限に近づいたため、Throughline が自動圧縮の前に止めて、この新しい会話へ引き継ぎました。` +
|
|
69
|
+
`下記は、前の会話で **あなた自身とユーザーが交わした会話の原文** です。\n` +
|
|
70
|
+
`- 「現在地」のターンは **作業の途中で止めたもの** です。ユーザーの追加入力を待たず、未完了の作業をそのまま続けてください。\n` +
|
|
71
|
+
`- 「ここまでにしたこと」と「直前の対話」に載っている操作は **完了しています**。繰り返さないでください。` +
|
|
72
|
+
`未実行なのは「実行されなかった道具」だけです。そこから続けてください。\n` +
|
|
73
|
+
`- 直前の実行結果が分からない時は、作業ツリーや詳細を確かめてから続けてください。\n` +
|
|
74
|
+
'- **各ターンの詳細**(止めたターンを含む): **`Bash` ツールで `throughline detail HH:MM:SS` を実行** ' +
|
|
75
|
+
`(該当ターンの本文+道具の入出力を stdout に返します)`;
|
|
76
|
+
|
|
77
|
+
const AUTO_CONTINUATION_ANCHOR_TITLE = '### 現在地 (作業の途中で止めたターン)';
|
|
78
|
+
// 止めたターンは現在地にだけ載せ、L2 の一覧からは外す(同じ依頼を二度載せない)。
|
|
79
|
+
// /clear のアンカー (600 字) より厚く取る。3つの上限を足しても、ヘッダと案内を合わせて予算に収まる。
|
|
80
|
+
const IN_FLIGHT_USER_MAX_CHARS = 3_500;
|
|
79
81
|
const IN_FLIGHT_ASSISTANT_MAX_CHARS = 1_500;
|
|
82
|
+
// 止めたターンでここまでにしたこと(発言と道具の呼び出し)。新しい側から、入るだけ載せる。
|
|
83
|
+
const IN_FLIGHT_STEPS_MAX_CHARS = 2_400;
|
|
80
84
|
|
|
81
85
|
// v0.10.12以前にThroughline自身がassistantへ付与させていた宣言行。
|
|
82
86
|
// 製品由来のメタ情報だけを引き継ぎ材料から除き、会話本文への模倣連鎖を止める。
|
|
@@ -500,7 +504,8 @@ function renderBudgetedSections(db, sections, { sessionId, excludeOriginId, maxC
|
|
|
500
504
|
const older = loadOlderTurnStats(db, {
|
|
501
505
|
sessionId,
|
|
502
506
|
excludeOriginId,
|
|
503
|
-
|
|
507
|
+
// 現在地にだけ載せたターン(自動継続の止めたターン)は、窓の外の古いターンに数えない。
|
|
508
|
+
windowKeys: new Set([...turns.map((t) => t.turnKey), ...(sections.anchorTurnKeys ?? [])]),
|
|
504
509
|
});
|
|
505
510
|
|
|
506
511
|
const guidanceLines = buildGuidanceLines({
|
|
@@ -549,60 +554,123 @@ function truncateInFlight(text, maxChars) {
|
|
|
549
554
|
}
|
|
550
555
|
|
|
551
556
|
/**
|
|
552
|
-
*
|
|
557
|
+
* 止めたターンでここまでにしたことを、現在地の行にする。
|
|
558
|
+
* 新しい側から予算に入るだけ載せ、載せなかった古い側は件数だけを1行にまとめる。
|
|
559
|
+
* @returns {{lines: string[], shownTexts: Set<string>}}
|
|
560
|
+
*/
|
|
561
|
+
function renderInFlightSteps(inFlight, detailClock) {
|
|
562
|
+
const steps = [...(inFlight.steps ?? [])];
|
|
563
|
+
const earlier = inFlight.earlier_steps ?? null;
|
|
564
|
+
// 一覧の最後が止める直前の発言で、1行に詰めると元の文と変わる(改行がある・長い)時は、
|
|
565
|
+
// 一覧から外して、下の「止める直前のあなたの発言」に全文で載せる。
|
|
566
|
+
const last = steps.at(-1);
|
|
567
|
+
const lastFragment = inFlight.last_fragment;
|
|
568
|
+
if (last?.kind === 'text' && lastFragment && last.timestamp === lastFragment.timestamp &&
|
|
569
|
+
last.content !== lastFragment.content.trim()) {
|
|
570
|
+
steps.pop();
|
|
571
|
+
}
|
|
572
|
+
if (steps.length === 0 && !earlier) return { lines: [], shownTexts: new Set() };
|
|
573
|
+
|
|
574
|
+
const rendered = steps.map((step) =>
|
|
575
|
+
step.kind === 'text'
|
|
576
|
+
? `- 発言: ${step.content}`
|
|
577
|
+
: `- 道具: ${step.name}${step.target ? ` ${step.target}` : ''}${step.failed ? ' → 失敗' : ''}`,
|
|
578
|
+
);
|
|
579
|
+
let budget = IN_FLIGHT_STEPS_MAX_CHARS;
|
|
580
|
+
let first = rendered.length;
|
|
581
|
+
while (first > 0 && budget - (rendered[first - 1].length + 1) >= 0) {
|
|
582
|
+
first -= 1;
|
|
583
|
+
budget -= rendered[first].length + 1;
|
|
584
|
+
}
|
|
585
|
+
const omitted = steps.slice(0, first);
|
|
586
|
+
const omittedTexts = (earlier?.texts ?? 0) + omitted.filter((step) => step.kind === 'text').length;
|
|
587
|
+
const omittedTools =
|
|
588
|
+
Object.values(earlier?.tools ?? {}).reduce((sum, count) => sum + count, 0) +
|
|
589
|
+
omitted.filter((step) => step.kind === 'tool').length;
|
|
590
|
+
|
|
591
|
+
const lines = [
|
|
592
|
+
`**このターンでここまでにしたこと**(古い順。どれも完了済み` +
|
|
593
|
+
(detailClock ? `。道具の入出力の全文: \`throughline detail ${detailClock}\`` : '') +
|
|
594
|
+
`):`,
|
|
595
|
+
];
|
|
596
|
+
if (omittedTexts + omittedTools > 0) {
|
|
597
|
+
lines.push(`- (これより前に、発言 ${omittedTexts} 件・道具 ${omittedTools} 回)`);
|
|
598
|
+
}
|
|
599
|
+
lines.push(...rendered.slice(first));
|
|
600
|
+
return {
|
|
601
|
+
lines,
|
|
602
|
+
shownTexts: new Set(steps.slice(first).filter((step) => step.kind === 'text').map((step) => step.content)),
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* 自動継続で立てた新しい会話へ注入するテキスト (ADR 0033)。
|
|
553
608
|
*
|
|
554
609
|
* 構成は /clear の引き継ぎと同じ予算付き(ヘッダ + 現在地 + 案内 + 入るだけの L2)。違いは現在地で、
|
|
555
|
-
* DB
|
|
556
|
-
*
|
|
557
|
-
*
|
|
558
|
-
* 完了したターンが 1 つも無い会話(最初のターンの途中で圧縮)でも、現在地だけを返す。
|
|
610
|
+
* DB の最新ターンではなく、止めた時点に記録した作業途中のターンを載せる。依頼、ここまでにしたこと
|
|
611
|
+
* (発言と道具の呼び出し)、止める直前の発言、実行されなかった道具の順。止めたターンは L2 の一覧から外す。
|
|
559
612
|
*
|
|
560
613
|
* @param {import('node:sqlite').DatabaseSync} db
|
|
561
614
|
* @param {{
|
|
562
|
-
* sessionId: string,
|
|
615
|
+
* sessionId: string, 合流を終えた後継の session id
|
|
563
616
|
* inFlight: {
|
|
564
617
|
* user: {content: string, timestamp: number|null},
|
|
565
|
-
*
|
|
618
|
+
* last_fragment: {content: string, timestamp: number|null} | null,
|
|
619
|
+
* steps?: Array<{kind: 'text', content: string, timestamp: number|null} | {kind: 'tool', name: string, target: string, failed: boolean}>,
|
|
620
|
+
* earlier_steps?: {texts: number, tools: Record<string, number>} | null,
|
|
621
|
+
* stopped_tools?: Array<{name: string, target: string}>,
|
|
622
|
+
* stopped_tools_total?: number,
|
|
623
|
+
* turn?: {origin_session_id: string, turn_number: number, user_at: number, assistant_at: number|null} | null,
|
|
566
624
|
* } | null,
|
|
567
|
-
*
|
|
625
|
+
* projectPath?: string | null, 前の会話の作業ディレクトリ。依頼の中の相対的な場所の基準として渡す
|
|
568
626
|
* maxChars?: number,
|
|
569
627
|
* }} params
|
|
570
628
|
*/
|
|
571
|
-
export function
|
|
629
|
+
export function buildAutoContinuationContext(
|
|
572
630
|
db,
|
|
573
|
-
{ sessionId, inFlight,
|
|
631
|
+
{ sessionId, inFlight, projectPath = null, maxChars = INJECTION_BUDGET_CHARS },
|
|
574
632
|
) {
|
|
575
633
|
const stored = buildResumeSections(db, { sessionId, isInheritance: true, handoffDisclosure: 'silent' });
|
|
576
634
|
const stamp = (timestamp) => (timestamp == null ? '' : ` [${formatClock(timestamp)}]`);
|
|
577
635
|
const anchorLines = [];
|
|
636
|
+
const turn = inFlight?.turn ?? null;
|
|
637
|
+
const turnKey = turn ? `${turn.origin_session_id}\x00${turn.turn_number}` : null;
|
|
578
638
|
if (inFlight) {
|
|
579
639
|
anchorLines.push(
|
|
580
640
|
`**作業中のユーザー依頼**${stamp(inFlight.user.timestamp)}: ` +
|
|
581
641
|
truncateInFlight(inFlight.user.content, IN_FLIGHT_USER_MAX_CHARS),
|
|
582
642
|
);
|
|
583
|
-
const
|
|
584
|
-
|
|
643
|
+
const steps = renderInFlightSteps(inFlight, turn ? formatClock(turn.assistant_at ?? turn.user_at) : null);
|
|
644
|
+
anchorLines.push(...steps.lines);
|
|
645
|
+
// 一覧に同じ文がそのまま載っている時は、重ねて載せない。改行のある発言や長い発言は、ここに全文を載せる。
|
|
646
|
+
if (inFlight.last_fragment && !steps.shownTexts.has(inFlight.last_fragment.content.trim())) {
|
|
585
647
|
anchorLines.push(
|
|
586
|
-
|
|
587
|
-
truncateInFlight(
|
|
648
|
+
`**止める直前のあなたの発言**${stamp(inFlight.last_fragment.timestamp)}: ` +
|
|
649
|
+
truncateInFlight(inFlight.last_fragment.content, IN_FLIGHT_ASSISTANT_MAX_CHARS),
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
const stoppedTools = inFlight.stopped_tools ?? [];
|
|
653
|
+
if (stoppedTools.length > 0) {
|
|
654
|
+
const more = (inFlight.stopped_tools_total ?? stoppedTools.length) - stoppedTools.length;
|
|
655
|
+
anchorLines.push(
|
|
656
|
+
`**止める直前に呼ぼうとして、実行されなかった道具**: ` +
|
|
657
|
+
stoppedTools.map((tool) => `${tool.name}${tool.target ? ` ${tool.target}` : ''}`).join('、') +
|
|
658
|
+
(more > 0 ? `(ほか ${more} 件)` : ''),
|
|
588
659
|
);
|
|
589
660
|
}
|
|
590
|
-
}
|
|
591
|
-
if (!inFlight && inFlightUnreadable) {
|
|
592
|
-
anchorLines.push(
|
|
593
|
-
'**作業中のユーザー依頼**: (記録からまだ読めません。圧縮後の文脈にある最新のユーザー依頼を続けてください)',
|
|
594
|
-
);
|
|
595
661
|
}
|
|
596
662
|
if (!stored && anchorLines.length === 0) return null;
|
|
597
663
|
|
|
598
664
|
return renderBudgetedSections(
|
|
599
665
|
db,
|
|
600
666
|
{
|
|
601
|
-
header:
|
|
602
|
-
anchorTitle:
|
|
667
|
+
header: AUTO_CONTINUATION_HEADER(projectPath),
|
|
668
|
+
anchorTitle: AUTO_CONTINUATION_ANCHOR_TITLE,
|
|
603
669
|
anchorLines,
|
|
670
|
+
anchorTurnKeys: turnKey ? [turnKey] : [],
|
|
604
671
|
l1Lines: [],
|
|
605
|
-
|
|
672
|
+
// 止めたターンは現在地に載せた。L2 の一覧には、それより前のターンだけを載せる。
|
|
673
|
+
l2Lines: (stored?.l2Lines ?? []).filter((line) => line.turnKey !== turnKey),
|
|
606
674
|
},
|
|
607
675
|
{ sessionId, excludeOriginId: null, maxChars },
|
|
608
676
|
);
|
|
@@ -4,7 +4,7 @@ import { DatabaseSync } from 'node:sqlite';
|
|
|
4
4
|
import {
|
|
5
5
|
buildResumeContext,
|
|
6
6
|
buildBudgetedResumeContext,
|
|
7
|
-
|
|
7
|
+
buildAutoContinuationContext,
|
|
8
8
|
INJECTION_BUDGET_CHARS,
|
|
9
9
|
} from './resume-context.mjs';
|
|
10
10
|
|
|
@@ -907,58 +907,59 @@ test('budgeted: header and anchor always survive even under pressure', () => {
|
|
|
907
907
|
assert.ok(budgeted.text.includes('### 現在地 (直前のやりとり)'));
|
|
908
908
|
});
|
|
909
909
|
|
|
910
|
-
// ---
|
|
910
|
+
// --- 自動継続で立てた新しい会話への注入 (ADR 0033) ---
|
|
911
911
|
|
|
912
|
-
test('
|
|
912
|
+
test('buildAutoContinuationContext: 保存済みのターンが無くても、止めた時点の依頼だけで組み立てる', () => {
|
|
913
913
|
const db = makeDb();
|
|
914
|
-
const result =
|
|
914
|
+
const result = buildAutoContinuationContext(db, {
|
|
915
915
|
sessionId: 'S',
|
|
916
|
-
inFlight: { user: { content: '最初の依頼', timestamp: null },
|
|
916
|
+
inFlight: { user: { content: '最初の依頼', timestamp: null }, last_fragment: null },
|
|
917
917
|
});
|
|
918
|
-
assert.match(result.text, /^## Throughline:
|
|
919
|
-
assert.match(result.text, /### 現在地 \(
|
|
920
|
-
assert.doesNotMatch(result.text,
|
|
918
|
+
assert.match(result.text, /^## Throughline: 自動継続の文脈\n/);
|
|
919
|
+
assert.match(result.text, /### 現在地 \(作業の途中で止めたターン\)\n\*\*作業中のユーザー依頼\*\*: 最初の依頼\n/);
|
|
920
|
+
assert.doesNotMatch(result.text, /止める直前のあなたの発言/);
|
|
921
921
|
assert.doesNotMatch(result.text, /### 直前の対話/);
|
|
922
922
|
assert.equal(result.injectedL2Turns, 0);
|
|
923
|
-
assert.equal(
|
|
923
|
+
assert.equal(buildAutoContinuationContext(db, { sessionId: 'S', inFlight: null }), null);
|
|
924
924
|
});
|
|
925
925
|
|
|
926
|
-
test('
|
|
926
|
+
test('buildAutoContinuationContext: 現在地は止めた時点のターンで、保存済みのターンはL2に並び、宣言は求めない', () => {
|
|
927
927
|
const db = makeDb();
|
|
928
|
-
insertBody(db, { session: 'S', origin: '
|
|
929
|
-
insertBody(db, { session: 'S', origin: '
|
|
930
|
-
const result =
|
|
928
|
+
insertBody(db, { session: 'S', origin: 'P', turn: 1, role: 'user', text: '前の依頼', createdAt: 1_000 });
|
|
929
|
+
insertBody(db, { session: 'S', origin: 'P', turn: 1, role: 'assistant', text: '前の回答', createdAt: 2_000 });
|
|
930
|
+
const result = buildAutoContinuationContext(db, {
|
|
931
931
|
sessionId: 'S',
|
|
932
932
|
inFlight: {
|
|
933
933
|
user: { content: '今の依頼', timestamp: 3_000 },
|
|
934
|
-
|
|
934
|
+
last_fragment: { content: '途中2', timestamp: 5_000 },
|
|
935
935
|
},
|
|
936
936
|
});
|
|
937
937
|
const anchor = result.text.slice(result.text.indexOf('### 現在地'), result.text.indexOf('### さらに前の記憶'));
|
|
938
|
-
assert.match(anchor, /\*\*作業中のユーザー依頼\*\* \[\d\d:\d\d:\d\d\]: 今の依頼\n
|
|
939
|
-
assert.doesNotMatch(anchor,
|
|
938
|
+
assert.match(anchor, /\*\*作業中のユーザー依頼\*\* \[\d\d:\d\d:\d\d\]: 今の依頼\n\*\*止める直前のあなたの発言\*\* \[\d\d:\d\d:\d\d\]: 途中2\n/);
|
|
939
|
+
assert.doesNotMatch(anchor, /前の依頼/);
|
|
940
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
|
|
941
|
+
assert.doesNotMatch(result.text, /宣言|\/clear|圧縮の要約/);
|
|
942
|
+
assert.match(result.text, /未実行なのは「実行されなかった道具」だけです/);
|
|
942
943
|
assert.equal(result.injectedL2Turns, 1);
|
|
943
944
|
});
|
|
944
945
|
|
|
945
|
-
test('
|
|
946
|
+
test('buildAutoContinuationContext: 長い依頼は先頭と末尾を残し、多数のターンでも予算内に収める', () => {
|
|
946
947
|
const db = makeDb();
|
|
947
948
|
for (let turn = 1; turn <= 12; turn++) {
|
|
948
|
-
insertBody(db, { session: 'S', origin: '
|
|
949
|
-
insertBody(db, { session: 'S', origin: '
|
|
949
|
+
insertBody(db, { session: 'S', origin: 'P', turn, role: 'user', text: `依頼${turn} ` + 'あ'.repeat(600), createdAt: turn * 1_000 });
|
|
950
|
+
insertBody(db, { session: 'S', origin: 'P', turn, role: 'assistant', text: `回答${turn} ` + 'い'.repeat(600), createdAt: turn * 1_000 + 500 });
|
|
950
951
|
}
|
|
951
|
-
const result =
|
|
952
|
+
const result = buildAutoContinuationContext(db, {
|
|
952
953
|
sessionId: 'S',
|
|
953
954
|
inFlight: {
|
|
954
|
-
user: { content: 'ヰ'.repeat(6_000), timestamp: 20_000 },
|
|
955
|
-
|
|
955
|
+
user: { content: '先頭の指示' + 'ヰ'.repeat(6_000) + '末尾の指示', timestamp: 20_000 },
|
|
956
|
+
last_fragment: { content: 'ヱ'.repeat(3_000), timestamp: 21_000 },
|
|
956
957
|
},
|
|
957
958
|
});
|
|
958
959
|
assert.ok(result.totalChars <= INJECTION_BUDGET_CHARS, `${result.totalChars}`);
|
|
959
|
-
assert.
|
|
960
|
+
assert.match(result.text, /\*\*作業中のユーザー依頼\*\* \[\d\d:\d\d:\d\d\]: 先頭の指示ヰ+ …\(長いため中略 2510 字\)… ヰ+末尾の指示\n/);
|
|
961
|
+
assert.equal((result.text.match(/ヰ/g) ?? []).length, 3_490);
|
|
960
962
|
assert.equal((result.text.match(/ヱ/g) ?? []).length, 1_500);
|
|
961
|
-
assert.match(result.text, /ヰ …\(長いため中略 2000 字\)… ヰ/);
|
|
962
963
|
assert.match(result.text, /ヱ …\(長いため中略 1500 字\)… ヱ/);
|
|
963
964
|
assert.ok(result.injectedL2Turns >= 1, '最新の保存済みターンは入る');
|
|
964
965
|
assert.match(result.text, /回答12/);
|
|
@@ -966,14 +967,70 @@ test('buildCompactContinuationContext: 長い依頼と多数のターンでも
|
|
|
966
967
|
assert.match(result.text, /throughline recall --l2 --session S /);
|
|
967
968
|
});
|
|
968
969
|
|
|
969
|
-
test('
|
|
970
|
+
test('buildAutoContinuationContext: 止めたターンでここまでにしたことと、実行されなかった道具を現在地に載せ、そのターンをL2に重ねない', () => {
|
|
970
971
|
const db = makeDb();
|
|
971
|
-
|
|
972
|
+
insertBody(db, { session: 'S', origin: 'P0', turn: 1, role: 'user', text: '前の依頼', createdAt: 1_000 });
|
|
973
|
+
insertBody(db, { session: 'S', origin: 'P0', turn: 1, role: 'assistant', text: '前の回答', createdAt: 2_000 });
|
|
974
|
+
// worker が取り込んだ止めたターン(発言を全部つないだ本文)
|
|
975
|
+
insertBody(db, { session: 'S', origin: 'P', turn: 7, role: 'user', text: '今の依頼', createdAt: 3_000 });
|
|
976
|
+
insertBody(db, { session: 'S', origin: 'P', turn: 7, role: 'assistant', text: 'a を直しました。\n\nb を読みます。', createdAt: 5_000 });
|
|
977
|
+
const result = buildAutoContinuationContext(db, {
|
|
972
978
|
sessionId: 'S',
|
|
973
|
-
inFlight: {
|
|
979
|
+
inFlight: {
|
|
980
|
+
user: { content: '今の依頼', timestamp: 3_000 },
|
|
981
|
+
last_fragment: { content: 'b を読みます。', timestamp: 5_000 },
|
|
982
|
+
steps: [
|
|
983
|
+
{ kind: 'tool', name: 'Edit', target: '/work/a.mjs', failed: false },
|
|
984
|
+
{ kind: 'tool', name: 'Bash', target: 'npm test', failed: true },
|
|
985
|
+
{ kind: 'text', content: 'a を直しました。', timestamp: 4_000 },
|
|
986
|
+
{ kind: 'text', content: 'b を読みます。', timestamp: 5_000 },
|
|
987
|
+
],
|
|
988
|
+
earlier_steps: null,
|
|
989
|
+
stopped_tools: [{ name: 'Read', target: '/work/b.mjs' }, { name: 'Read', target: '/work/c.mjs' }],
|
|
990
|
+
stopped_tools_total: 3,
|
|
991
|
+
turn: { origin_session_id: 'P', turn_number: 7, user_at: 3_000, assistant_at: 5_000 },
|
|
992
|
+
},
|
|
974
993
|
});
|
|
975
|
-
|
|
994
|
+
const anchor = result.text.slice(result.text.indexOf('### 現在地'), result.text.indexOf('### さらに前の記憶'));
|
|
995
|
+
assert.match(anchor, new RegExp(
|
|
996
|
+
'\\*\\*作業中のユーザー依頼\\*\\* \\[\\d\\d:\\d\\d:\\d\\d\\]: 今の依頼\\n' +
|
|
997
|
+
'\\*\\*このターンでここまでにしたこと\\*\\*(古い順。どれも完了済み。道具の入出力の全文: `throughline detail \\d\\d:\\d\\d:\\d\\d`):\\n' +
|
|
998
|
+
'- 道具: Edit /work/a\\.mjs\\n- 道具: Bash npm test → 失敗\\n- 発言: a を直しました。\\n- 発言: b を読みます。\\n' +
|
|
999
|
+
'\\*\\*止める直前に呼ぼうとして、実行されなかった道具\\*\\*: Read /work/b\\.mjs、Read /work/c\\.mjs(ほか 1 件)\\n'));
|
|
1000
|
+
assert.doesNotMatch(anchor, /止める直前のあなたの発言/, '一覧に同じ文が載っている発言は重ねない');
|
|
1001
|
+
const l2 = result.text.slice(result.text.indexOf('### 直前の対話'));
|
|
1002
|
+
assert.match(l2, /\[user\]: 前の依頼\n\[\d\d:\d\d:\d\d\] \[assistant\]: 前の回答/);
|
|
1003
|
+
assert.doesNotMatch(l2, /今の依頼|a を直しました/, '止めたターンは現在地だけに載せる');
|
|
1004
|
+
assert.equal(result.injectedL2Turns, 1);
|
|
1005
|
+
assert.equal(result.olderTurns, 0, '止めたターンを、窓の外の古いターンに数えない');
|
|
1006
|
+
});
|
|
976
1007
|
|
|
977
|
-
|
|
978
|
-
|
|
1008
|
+
test('buildAutoContinuationContext: 改行のある直前の発言は全文を載せ、多い手順は新しい側から入るだけ載せて残りは件数にする', () => {
|
|
1009
|
+
const db = makeDb();
|
|
1010
|
+
const steps = [];
|
|
1011
|
+
for (let n = 1; n <= 80; n++) {
|
|
1012
|
+
steps.push({ kind: 'tool', name: 'Read', target: `/work/file-${String(n).padStart(3, '0')}-${'x'.repeat(40)}.mjs`, failed: false });
|
|
1013
|
+
}
|
|
1014
|
+
steps.push({ kind: 'text', content: '方針: 1. 型を直す 2. 試験を足す', timestamp: 9_000 });
|
|
1015
|
+
const result = buildAutoContinuationContext(db, {
|
|
1016
|
+
sessionId: 'S',
|
|
1017
|
+
inFlight: {
|
|
1018
|
+
user: { content: '大きな依頼', timestamp: 1_000 },
|
|
1019
|
+
last_fragment: { content: '方針:\n1. 型を直す\n2. 試験を足す', timestamp: 9_000 },
|
|
1020
|
+
steps,
|
|
1021
|
+
earlier_steps: { texts: 4, tools: { Read: 30, Edit: 6 } },
|
|
1022
|
+
stopped_tools: [{ name: 'Edit', target: '/work/types.mjs' }],
|
|
1023
|
+
stopped_tools_total: 1,
|
|
1024
|
+
turn: null,
|
|
1025
|
+
},
|
|
1026
|
+
});
|
|
1027
|
+
assert.ok(result.totalChars <= INJECTION_BUDGET_CHARS, `${result.totalChars}`);
|
|
1028
|
+
assert.match(result.text, /\*\*このターンでここまでにしたこと\*\*(古い順。どれも完了済み):\n- (これより前に、発言 4 件・道具 (\d+) 回)\n- 道具: Read /);
|
|
1029
|
+
const omittedTools = Number(/これより前に、発言 4 件・道具 (\d+) 回/.exec(result.text)[1]);
|
|
1030
|
+
const shownTools = (result.text.match(/^- 道具: Read /gm) ?? []).length;
|
|
1031
|
+
assert.equal(omittedTools + shownTools, 36 + 80, '載せた分と件数にした分で全部');
|
|
1032
|
+
assert.ok(shownTools >= 20 && shownTools < 80, `${shownTools}`);
|
|
1033
|
+
assert.doesNotMatch(result.text, /- 発言: 方針/, '下に全文を載せる発言は、一覧に重ねない');
|
|
1034
|
+
assert.match(result.text, /- 道具: Read \/work\/file-080-x+\.mjs\n\*\*止める直前のあなたの発言\*\* \[\d\d:\d\d:\d\d\]: 方針:\n1\. 型を直す\n2\. 試験を足す\n\*\*止める直前に呼ぼうとして、実行されなかった道具\*\*: Edit \/work\/types\.mjs\n/);
|
|
979
1035
|
});
|
|
1036
|
+
|
|
@@ -47,12 +47,17 @@ const DEFINITIONS = Object.freeze({
|
|
|
47
47
|
severity: 'high',
|
|
48
48
|
template: 'Throughline Claude Stop hook processing failed',
|
|
49
49
|
}),
|
|
50
|
-
//
|
|
50
|
+
// 自動継続の hook が失敗した。圧縮も道具も止まらず、作業は続く(ADR 0033)。
|
|
51
51
|
HOOK_PRE_COMPACT_FAILED: Object.freeze({
|
|
52
52
|
component: 'claude_pre_compact_hook',
|
|
53
53
|
severity: 'warn',
|
|
54
54
|
template: 'Throughline Claude PreCompact hook processing failed',
|
|
55
55
|
}),
|
|
56
|
+
HOOK_PRE_TOOL_USE_FAILED: Object.freeze({
|
|
57
|
+
component: 'claude_pre_tool_use_hook',
|
|
58
|
+
severity: 'warn',
|
|
59
|
+
template: 'Throughline Claude PreToolUse hook processing failed',
|
|
60
|
+
}),
|
|
56
61
|
HOOK_CODEX_FAILED: Object.freeze({
|
|
57
62
|
component: 'codex_hook',
|
|
58
63
|
severity: 'high',
|
package/src/session-start.mjs
CHANGED
|
@@ -22,11 +22,11 @@
|
|
|
22
22
|
* バトンの消費・merge・注入は最初の UserPromptSubmit (= 実体の証明) で行う。
|
|
23
23
|
* 幽霊はプロンプトを発火しないため記憶を奪えない。
|
|
24
24
|
*
|
|
25
|
-
*
|
|
25
|
+
* 【自動継続の後継】 ADR 0033
|
|
26
26
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
27
|
+
* 同じ project で後継の立ち上げ中の引き継ぎがある時だけ、この会話の受け口(inbox socket)を
|
|
28
|
+
* 控える。worker がそれを読んで、継続の指示を1通送る。記憶の注入は、その指示が届いた時の
|
|
29
|
+
* UserPromptSubmit(上の二相ハンドオフの第二相)が行う。
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
32
|
import { getDb } from './db.mjs';
|
|
@@ -41,7 +41,7 @@ import { recordRuntimeErrorBestEffort } from './runtime-error-store.mjs';
|
|
|
41
41
|
import { logHookFailure } from './hook-failure-log.mjs';
|
|
42
42
|
import { CLAUDE_HOST, hostAdapterForSessionId, hostOfSessionId, NON_CLAUDE_SESSION_PREFIXES, parseHookPayload } from './hosts/index.mjs';
|
|
43
43
|
import { executeFirstPromptHandoff } from './handoff-executor.mjs';
|
|
44
|
-
import {
|
|
44
|
+
import { recordClaudeSuccessorTarget } from './claude-auto-handoff.mjs';
|
|
45
45
|
|
|
46
46
|
const ENV_DISABLE_AUTO_HANDOFF = 'THROUGHLINE_DISABLE_AUTO_HANDOFF';
|
|
47
47
|
|
|
@@ -170,29 +170,9 @@ export async function run() {
|
|
|
170
170
|
pending_registered: true,
|
|
171
171
|
});
|
|
172
172
|
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
}
|
|
173
|
+
// 自動継続の後継として立ち上がった会話なら、worker が指示を送れるように受け口を控える (ADR 0033)。
|
|
174
|
+
if (hostOfSessionId(session_id) === CLAUDE_HOST) {
|
|
175
|
+
recordClaudeSuccessorTarget({ payload, env: process.env, now });
|
|
196
176
|
}
|
|
197
177
|
|
|
198
178
|
// Cursor は beforeSubmitPrompt が additional_context を持たない。
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { readFileSync, existsSync } from 'fs';
|
|
12
12
|
import { DETAIL_KIND } from './constants.mjs';
|
|
13
|
-
import { compactClaudeTaskNotification } from './hosts/claude.mjs';
|
|
13
|
+
import { compactClaudePeerMessage, compactClaudeTaskNotification } from './hosts/claude.mjs';
|
|
14
14
|
import { classifyTurnStart } from './turn-start.mjs';
|
|
15
15
|
|
|
16
16
|
function entryKind(entry) {
|
|
@@ -86,10 +86,10 @@ export function readTranscript(transcriptPath) {
|
|
|
86
86
|
if (!role || rawContent == null) continue;
|
|
87
87
|
|
|
88
88
|
const extracted = extractText(rawContent);
|
|
89
|
-
// user 発言には端末の生出力 (貼り付け・Claude の task 通知)
|
|
90
|
-
//
|
|
89
|
+
// user 発言には端末の生出力 (貼り付け・Claude の task 通知) や、別の会話から届いた発言の
|
|
90
|
+
// 定型の注意書きが入る。記憶には制御文字と定型文を落とした本文だけを残す。
|
|
91
91
|
const text = role === 'user'
|
|
92
|
-
? normalizeTerminalText(compactClaudeTaskNotification(extracted))
|
|
92
|
+
? normalizeTerminalText(compactClaudePeerMessage(compactClaudeTaskNotification(extracted)))
|
|
93
93
|
: extracted;
|
|
94
94
|
if (!text) continue;
|
|
95
95
|
|
|
@@ -387,6 +387,28 @@ export function sliceCurrentTurnEntries(entries) {
|
|
|
387
387
|
return entries.slice(userTextIdx, lastAssistantTextIdx + 1);
|
|
388
388
|
}
|
|
389
389
|
|
|
390
|
+
/**
|
|
391
|
+
* 作業途中で止めたターンのエントリ範囲 (ADR 0033)。最後の user 本文の行から、transcript の末尾まで。
|
|
392
|
+
*
|
|
393
|
+
* sliceCurrentTurnEntries は最後の assistant 本文で切るので、その後に呼んだ道具が入らない。
|
|
394
|
+
* hook で止めたターンは、止めた道具の呼び出しと結果が末尾に並ぶ。それも同じターンとして返す。
|
|
395
|
+
*
|
|
396
|
+
* @param {Array<object>} entries readRawEntries の結果
|
|
397
|
+
* @returns {Array<object>}
|
|
398
|
+
*/
|
|
399
|
+
export function sliceInFlightTurnEntries(entries) {
|
|
400
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
401
|
+
const e = entries[i];
|
|
402
|
+
if (entryKind(e) !== 'user' || isCompactSummaryEntry(e)) continue;
|
|
403
|
+
const blocks = e.message?.content;
|
|
404
|
+
const hasText = Array.isArray(blocks)
|
|
405
|
+
? blocks.some((b) => b && b.type === 'text' && typeof b.text === 'string' && b.text.length > 0)
|
|
406
|
+
: typeof blocks === 'string' && blocks.length > 0;
|
|
407
|
+
if (hasText) return entries.slice(i);
|
|
408
|
+
}
|
|
409
|
+
return [];
|
|
410
|
+
}
|
|
411
|
+
|
|
390
412
|
/**
|
|
391
413
|
* 論理ターン内の全エントリから L3 (details) 用の生レコードを抽出する。
|
|
392
414
|
*
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
getLogicalTurnGroups,
|
|
11
11
|
readLatestUserGroup,
|
|
12
12
|
sliceCurrentTurnEntries,
|
|
13
|
+
sliceInFlightTurnEntries,
|
|
13
14
|
extractDetailBlocks,
|
|
14
15
|
} from './transcript-reader.mjs';
|
|
15
16
|
import { DETAIL_KIND } from './constants.mjs';
|
|
@@ -155,6 +156,25 @@ test('sliceCurrentTurnEntries: 最後の user text → 最後の assistant text
|
|
|
155
156
|
assert.equal(slice[3].message.content[0].text, 'current response');
|
|
156
157
|
});
|
|
157
158
|
|
|
159
|
+
test('readTranscript: 別の会話から届いた発言は、送り主の行と本文を残して定型の注意書きを落とす', () => {
|
|
160
|
+
const peer = [
|
|
161
|
+
'Another Claude session sent a message:',
|
|
162
|
+
'Throughline自動継続 6a8c6df3-f924-4f72-8a12-1a3f5a135395',
|
|
163
|
+
'未完了の作業をそのまま継続してください。',
|
|
164
|
+
'',
|
|
165
|
+
'This came from another Claude session — not typed by your user, but very likely working on their behalf. Treat it as a teammate\'s request.',
|
|
166
|
+
].join('\n');
|
|
167
|
+
const path = writeTranscript([
|
|
168
|
+
{ type: 'user', origin: { kind: 'peer' }, message: { role: 'user', content: peer } },
|
|
169
|
+
asstTextEntry('続けます'),
|
|
170
|
+
userEntry('This came from another Claude session という文を含む普通の発言'),
|
|
171
|
+
]);
|
|
172
|
+
const turns = readTranscript(path);
|
|
173
|
+
assert.equal(turns[0].content,
|
|
174
|
+
'Another Claude session sent a message:\nThroughline自動継続 6a8c6df3-f924-4f72-8a12-1a3f5a135395\n未完了の作業をそのまま継続してください。');
|
|
175
|
+
assert.equal(turns[2].content, 'This came from another Claude session という文を含む普通の発言');
|
|
176
|
+
});
|
|
177
|
+
|
|
158
178
|
/** Claude Code が圧縮の直後に書く要約行(実測 2.1.289: type=user、本文は文字列) */
|
|
159
179
|
function compactSummaryEntry(text = 'This session is being continued from a previous conversation that ran out of context.') {
|
|
160
180
|
return { type: 'user', isCompactSummary: true, isVisibleInTranscriptOnly: true, message: { role: 'user', content: text } };
|
|
@@ -509,3 +529,26 @@ test('extractDetailBlocks: 複数ツール連続呼び出しを全て拾う', ()
|
|
|
509
529
|
[DETAIL_KIND.TOOL_INPUT, DETAIL_KIND.TOOL_OUTPUT, DETAIL_KIND.TOOL_INPUT, DETAIL_KIND.TOOL_OUTPUT],
|
|
510
530
|
);
|
|
511
531
|
});
|
|
532
|
+
|
|
533
|
+
test('sliceInFlightTurnEntries: 最後の user 本文から末尾まで。最後の発言の後に呼んだ道具と、圧縮の要約行より前の本文も扱う', () => {
|
|
534
|
+
const entries = [
|
|
535
|
+
{ type: 'user', message: { role: 'user', content: '前の依頼' } },
|
|
536
|
+
{ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: '前の回答' }] } },
|
|
537
|
+
{ type: 'user', message: { role: 'user', content: [{ type: 'text', text: '今の依頼' }] } },
|
|
538
|
+
{ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: '途中です。' }] } },
|
|
539
|
+
{ type: 'assistant', message: { role: 'assistant', content: [{ type: 'tool_use', id: 't1', name: 'Read', input: {} }] } },
|
|
540
|
+
{ type: 'user', message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 't1', content: 'hook error' }] } },
|
|
541
|
+
{ type: 'attachment', attachment: { type: 'hook_stopped_continuation' } },
|
|
542
|
+
];
|
|
543
|
+
const slice = sliceInFlightTurnEntries(entries);
|
|
544
|
+
assert.equal(slice.length, 5);
|
|
545
|
+
assert.equal(slice[0].message.content[0].text, '今の依頼');
|
|
546
|
+
assert.equal(sliceCurrentTurnEntries(entries).length, 2, '完了したターン用の切り出しは、最後の発言で終わる');
|
|
547
|
+
assert.deepEqual(extractDetailBlocks(slice).map((d) => d.source_id), ['t1', 't1:result', null]);
|
|
548
|
+
|
|
549
|
+
// 圧縮の要約行はターンの始まりにしない
|
|
550
|
+
const summary = { type: 'user', isCompactSummary: true, message: { role: 'user', content: '要約…' } };
|
|
551
|
+
assert.equal(sliceInFlightTurnEntries([entries[2], entries[3], summary, entries[4]]).length, 4);
|
|
552
|
+
assert.deepEqual(sliceInFlightTurnEntries([entries[1]]), []);
|
|
553
|
+
});
|
|
554
|
+
|