throughline 0.16.28 → 0.16.29
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 +20 -1
- package/docs/adr/0055-stop-flush-barrier-skips-queued-inputs.md +6 -0
- package/docs/adr/0060-claude-mid-turn-input-and-host-meta-rows.md +123 -0
- package/package.json +1 -1
- package/src/transcript-reader.mjs +94 -8
- package/src/transcript-reader.test.mjs +159 -0
- package/src/turn-backfill.test.mjs +60 -0
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,24 @@ shipped to npm but were not individually tagged on GitHub.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.16.29] — 2026-10-14
|
|
14
|
+
|
|
15
|
+
### 修正
|
|
16
|
+
|
|
17
|
+
- **Claude Codeで、作業中に届いた入力が、会話の記録(L2)に残っていなかった**([ADR 0060](docs/adr/0060-claude-mid-turn-input-and-host-meta-rows.md))。Claude Codeは、ターンが動いている間に届いた入力を、user行ではなく`attachment`の`queued_command`で書く。この製品はその行を読んでいなかった。Linuxの実機の4日ぶんの記録で、人の入力のuser行1,402に対して、途中で届いた人の入力は833通あり、記録に入っていたのは3通だった。
|
|
18
|
+
- 直した後は、途中で届いた入力を、届いた先のターンの依頼へ、届いた順に足す。前に見出しの1行(`(作業の途中で届いた入力)`)を置く。人が打った文と、別の会話から届いた発言が当たる。裏の作業の完了通知は足さない。
|
|
19
|
+
- 引き継ぎの記憶と、自動継続で後継へ運ぶ依頼にも入る。
|
|
20
|
+
- **Claude Codeがskillを読んだ時などに書く行を、利用者の発言として記録していた**(同じADR)。`isMeta: true`で`origin`の無いuser行。この行でターンを区切っていたので、その前に本文が無いと、元の依頼が記録から消え、skillの本文が利用者の発言として残った。道具の入出力(L3)も、その行より前の分が入らなかった。自動継続で止めたターンでは、後継へ運ぶ依頼がskillの本文になっていた。
|
|
21
|
+
- 直した後は、この行をターンの始まりにも本文にもしない。別の会話から届いた発言(`origin`を持つ)は、今までどおり。
|
|
22
|
+
- **Claude Codeで、ターンの終わりに待ち入力が2通以上続けて届いた時、最後の1通だけが記録されていた**(同じADR)。同じ番へ続けて届いたuser行(`turnPosition.promptIndex`が同じ)を、1つの依頼にまとめる。
|
|
23
|
+
|
|
24
|
+
### 変わる動き
|
|
25
|
+
|
|
26
|
+
- Claude Codeの会話で、利用者の発言の行に、作業中に届いた入力が見出しつきで足される。見出しの1行は、この製品が足した文。
|
|
27
|
+
- skillの本文は、利用者の発言として記録されなくなる。道具の入出力には残る。
|
|
28
|
+
- 前の版が記録したターンは書き直さない。直しが効くのは、この版が取り込むターンから。行の番号は変わらないので、前の版の行と重なって増える事は無い。
|
|
29
|
+
- Codex・Grok・Cursorの記録の読み方は変えていない。
|
|
30
|
+
|
|
13
31
|
## [0.16.28] — 2026-10-14
|
|
14
32
|
|
|
15
33
|
### 修正
|
|
@@ -2558,7 +2576,8 @@ two attempts, instrument first instead of patching again.
|
|
|
2558
2576
|
|
|
2559
2577
|
---
|
|
2560
2578
|
|
|
2561
|
-
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.
|
|
2579
|
+
[Unreleased]: https://github.com/kitepon/Throughline/compare/v0.16.29...HEAD
|
|
2580
|
+
[0.16.29]: https://github.com/kitepon/Throughline/compare/v0.16.28...v0.16.29
|
|
2562
2581
|
[0.16.28]: https://github.com/kitepon/Throughline/compare/v0.16.27...v0.16.28
|
|
2563
2582
|
[0.16.27]: https://github.com/kitepon/Throughline/compare/v0.16.26...v0.16.27
|
|
2564
2583
|
[0.16.26]: https://github.com/kitepon/Throughline/compare/v0.16.25...v0.16.26
|
|
@@ -56,3 +56,9 @@ turn の間に入力が2通たまり、turn が終わった時に2通が続け
|
|
|
56
56
|
本物の hook の命令(`process-turn`)へ、合成した transcript を通した(切り離した HOME)。
|
|
57
57
|
- 確かめていない: 本物の Claude Code が、待っていた入力を2通続けて書く場面での、この版の動き。狙って起こすには、
|
|
58
58
|
動いている turn の間に入力を2通ためる必要がある。次に起きた時は、失敗が記録されない事で分かる。
|
|
59
|
+
|
|
60
|
+
## 追記(2026-10-14、ADR 0060)
|
|
61
|
+
|
|
62
|
+
Context の「保存の欠けは無い」は、完了した turn の事だった。続けて届いた入力の1通目は、assistant 断片の無い group として
|
|
63
|
+
保存されていなかった(コンテナの本物の transcript で 5 回)。ADR 0060 で、同じ `turnPosition.promptIndex` で続けて届いた
|
|
64
|
+
user 行を1つの依頼にまとめる様にした。assistant 断片の無い group を飛ばす作りは、そのまま残している。
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# ADR 0060: Claude Code の、ターンの途中で届いた入力は依頼へ足し、host が書く meta の行ではターンを区切らない
|
|
2
|
+
|
|
3
|
+
- Status: Accepted
|
|
4
|
+
- Date: 2026-10-14
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Claude Code の transcript から L2(`bodies`)を作る時、この製品は「本文を持つ user 行1つ + それに続く assistant の本文」を
|
|
9
|
+
1つのターンとして読んでいた(`getLogicalTurnGroups`)。user 行なら何でもターンの始まりにし、`attachment` の行は L3 にだけ使っていた。
|
|
10
|
+
|
|
11
|
+
2026-10-14、BellTeam のコンテナ(Linux、Claude Code 2.1.293・2.1.296、この製品は 0.16.27)の本物の transcript で、
|
|
12
|
+
人の入力が L2 に入っていない形が3つ見つかった(本文は読んでいない。行の種類・印・長さ・時刻だけ)。
|
|
13
|
+
|
|
14
|
+
### 1. ターンの途中で届いた入力
|
|
15
|
+
|
|
16
|
+
Claude Code は、ターンが動いている間に届いた入力を、user 行ではなく `attachment` の行で書く。
|
|
17
|
+
|
|
18
|
+
```text
|
|
19
|
+
{"type":"attachment","attachment":{"type":"queued_command","prompt":…,"commandMode":"prompt","origin":{"kind":"human"},"humanTurn":true,…}}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`prompt` は文字列か、text と image の block の配列。裏の作業の完了通知も同じ形で届く(`commandMode: "task-notification"`、
|
|
23
|
+
`origin.kind: "task-notification"`)。前後に `queue-operation` の `enqueue` と `remove` の行が並ぶ。ターンが終わるまで
|
|
24
|
+
届けられなかった入力は、`dequeue` の後に普通の user 行になる(`promptSource: "queued"`)。
|
|
25
|
+
|
|
26
|
+
10/10 からの 163 本で、人の入力の `queued_command` は 833 通あった(人の入力の user 行は 1,402)。L2 に入っていたのは 3 通
|
|
27
|
+
(同じ文が別の行にもあった物)。作業中に打った文、ほかの席から差し込まれた文が、記憶に残っていなかった。
|
|
28
|
+
|
|
29
|
+
### 2. host が書く meta の user 行
|
|
30
|
+
|
|
31
|
+
Claude Code は、skill を読んだ時の本文や、端末で打った命令の注意書きを、`isMeta: true` の user 行で書く。`origin` は無い。
|
|
32
|
+
skill の行は、直後に `attachment` の `command_permissions` が続く。
|
|
33
|
+
|
|
34
|
+
この行をターンの始まりにしていたので、次の事が起きていた。
|
|
35
|
+
|
|
36
|
+
- その行より前に assistant の本文が無いと、元の依頼は「本文の無い群」になって保存されない。meta の行の本文
|
|
37
|
+
(skill の手順)が、利用者の発言として保存される。
|
|
38
|
+
- 本文があると、1つのターンが2つに割れる。
|
|
39
|
+
- L3 は「最後の user 本文の行」から切り出すので、meta の行より前の道具の入出力が入らない。
|
|
40
|
+
- 自動継続で止めたターンの依頼(`snapshotInFlight`)も「最後の user 本文」を読むので、後継へ運ぶ依頼が skill の本文になる。
|
|
41
|
+
|
|
42
|
+
同じ 163 本で、`origin` の無い meta の行は 61 行(道具の後 27、人の入力の直後 11、meta の行の後 17、端末の命令の注意書き 6)。
|
|
43
|
+
46 行が利用者の発言として L2 の群に入っていた。
|
|
44
|
+
|
|
45
|
+
別の会話から届いた発言も `isMeta: true` だが、`origin.kind: "peer"` を持つ。届いた入力なので、今までどおりターンの始まりにする。
|
|
46
|
+
|
|
47
|
+
### 3. ターンの終わりに続けて届いた待ち入力
|
|
48
|
+
|
|
49
|
+
ターンの間に入力が2通以上たまると、ターンが終わった時に、2通が続けて user 行になる。2行とも `promptSource: "queued"` で、
|
|
50
|
+
同じ `turnPosition.promptIndex` を持つ(同じ番へ届いた印)。
|
|
51
|
+
|
|
52
|
+
1通目は「assistant の本文が無い群」になり、保存されなかった。ADR 0055 は、この形で Stop が失敗する事を直した時に
|
|
53
|
+
「保存の欠けは無い」と書いたが、欠けていたのは完了したターンではなく、1通目の入力だった。同じ 163 本で 5 回。
|
|
54
|
+
|
|
55
|
+
### ほかの host
|
|
56
|
+
|
|
57
|
+
- Codex: ターンを host の開始と完了の印(`task_started`・`task_complete`)で区切り、その間の利用者の発言を全部持つ
|
|
58
|
+
(`codex-rollout-memory.mjs`)。同じ欠けは起きない作り。
|
|
59
|
+
- Grok: `chat_history.jsonl` に、途中で届いた入力を表す別の行は見つからなかった(80 本)。人の入力の行が本文を挟まずに
|
|
60
|
+
続く形は 9 回あり、`prompt_index` は違っていた。止められたターンか、途中の入力かは分けていない。この ADR では扱わない。
|
|
61
|
+
- Cursor: 10 本を見て、同じ形の行は無かった。
|
|
62
|
+
|
|
63
|
+
## Decision
|
|
64
|
+
|
|
65
|
+
1. **ターンの途中で届いた入力は、届いた先のターンの依頼へ、届いた順に足す。** `attachment` の `queued_command` で、
|
|
66
|
+
`origin.kind` が `human` か `peer` の物。直前に返した user の行の本文の後ろへ、空行・見出しの1行
|
|
67
|
+
(`(作業の途中で届いた入力)`)・本文の順で足す。本文は user 行と同じ正規化を通す(端末の制御の除去、別の会話の注意書きの除去)。
|
|
68
|
+
- `turn_number` は進めない。`attachment` は今までも本文行に数えていない。保存済みの番号とずれない。
|
|
69
|
+
- 完了通知(`origin.kind: "task-notification"`)は足さない。そのターン自身の裏の作業の結果で、L3 に残っている。
|
|
70
|
+
- `origin` の無い物、`isSidechain: true` の物、足す先の user 行がまだ無い物は足さない。
|
|
71
|
+
- ターンの始まり方(`start`)は変えない。完了通知で始まったターンへ人の入力が届いても、`self` のまま。
|
|
72
|
+
2. **`isMeta: true` で `origin.kind` の無い user 行は、ターンの始まりにも本文にもしない**(`isClaudeHostWrittenMetaEntry`)。
|
|
73
|
+
圧縮の要約行(ADR 0032)と同じ扱い。`turn_number` は進める(その行を数えていた頃に保存した番号とずれない)。
|
|
74
|
+
L3 の切り出し(`sliceCurrentTurnEntries`・`sliceInFlightTurnEntries`)も、この行で区切らない。
|
|
75
|
+
3. **同じ番へ続けて届いた user 行は、1つの依頼にまとめる。** 直前の user 行との間に assistant の本文が無く、両方が
|
|
76
|
+
`origin.kind` を持ち、`turnPosition.promptIndex` が同じ整数の時。空行でつなぐ。`turn_number`・時刻・`start`・`prompt_id` は
|
|
77
|
+
1通目の物。`turn_number` は2通目の分も進める。
|
|
78
|
+
- `promptIndex` が違う行、印の無い行、`origin` の無い行(端末で打った命令)は、まとめない。止められて本文を書かなかった
|
|
79
|
+
ターンの依頼は、今までどおり保存されない。
|
|
80
|
+
4. 判定は、host が transcript に書いた印(`type`・`isMeta`・`origin.kind`・`turnPosition`)だけで行う。本文は読まない。
|
|
81
|
+
5. 保存済みのターンは書き直さない。直しが効くのは、この版が取り込むターンから。
|
|
82
|
+
|
|
83
|
+
## Consequences
|
|
84
|
+
|
|
85
|
+
- 作業中に打った文と、ほかの席から差し込まれた文が、そのターンの利用者の発言として L2 に残る。引き継ぎの記憶と、
|
|
86
|
+
自動継続で後継へ運ぶ依頼(`in_flight.user`)にも入る。
|
|
87
|
+
- 利用者の発言の行が長くなる。見出しの1行は、この製品が足した文で、利用者の文ではない。
|
|
88
|
+
- skill の本文は L2 に入らなくなる(L3 には `Skill` の道具の入出力として残る)。
|
|
89
|
+
- 前の版が取り込んだ会話では、meta の行で割れていた2つの群が1つにまとまる。`backfillBodies` は「群の断片の番号が1つでも
|
|
90
|
+
保存済みなら回収しない」ので、行は増えない。前の版が落とした依頼と途中の入力は、さかのぼっては入らない。
|
|
91
|
+
- Stop の flush barrier(ADR 0026・0055)は `readTranscript` の上で動く。続けて届いた待ち入力は1つの群になるので、
|
|
92
|
+
「本文の無い群を飛ばす」場面が減る。飛ばす作りは残す(`promptIndex` の無い版の Claude Code、止められたターン)。
|
|
93
|
+
- `auditor-context` と `caveat-context` の鮮度の照合は、transcript の最後の群の依頼と、保存済みの行の hash を比べる。
|
|
94
|
+
前の版が取り込んだ最後のターンに途中の入力や meta の行があると、版を上げた直後の1回だけ合わない(次のターンからは合う)。
|
|
95
|
+
- 途中の入力がターンの最後の本文より後に書かれた時も、そのターンの依頼へ足す。Stop の取り込みがもう済んでいれば、
|
|
96
|
+
その入力は L2 に入らない(保存済みの行は書き直さない)。実物では見ていない。
|
|
97
|
+
|
|
98
|
+
## 確かめた事・確かめていない事
|
|
99
|
+
|
|
100
|
+
- 確かめた:
|
|
101
|
+
- 単体試験(途中の入力の文字列と配列、完了通知・sidechain・印なしを足さない、別の会話からの文、足す先が無い時、meta の行、
|
|
102
|
+
`origin` を持つ meta の行、続けて届いた待ち入力、まとめない3つの形、L3 の切り出し、前の版が保存した行と重ねない事、
|
|
103
|
+
止めたターンの取り込み)。直す前の版では、形を表す5本が落ちる。
|
|
104
|
+
- 本物の transcript へ、前の版と直した版の読み手を当てて比べた(DB は開かない。数だけ)。3つの OS とも、assistant の行の番号は
|
|
105
|
+
全部同じで、直した版の代表の番号に、前の版に無い物は 0。
|
|
106
|
+
|
|
107
|
+
| | 本数 | 群 | 人の入力の user 行が群の依頼にある数 | 途中の人の入力が群の依頼にある数 | 利用者の発言になっていた meta の行 |
|
|
108
|
+
|---|---|---|---|---|---|
|
|
109
|
+
| Linux(コンテナ) | 163 | 1,661 → 1,634 | 1,385 → 1,401(1,402 のうち) | 3 → 832(833 のうち) | 46 → 0 |
|
|
110
|
+
| macOS | 103 | 1,265 → 1,246 | 905 → 910(926 のうち) | 0 → 121(122 のうち) | 36 → 0 |
|
|
111
|
+
| Windows | 108 | 922 → 899 | 324 → 335(369 のうち) | 2 → 111(119 のうち) | 51 → 0 |
|
|
112
|
+
|
|
113
|
+
入らなかった途中の入力は、3つの OS とも全部「依頼へは足されたが、そのターンに assistant の本文が無い」物だった
|
|
114
|
+
(止められたターン。完了したターンの回収には出ない)。
|
|
115
|
+
- 別の会話からの文(`origin.kind: "peer"`)が、ターンの途中に `queued_command` で届く実物は、Windows に 187 通あった
|
|
116
|
+
(`commandMode: "prompt"`。頭は地の文か `<cross-session-message>`)。同じ扱いで依頼へ足される。
|
|
117
|
+
- 本物の Stop の入口(`process-turn`)へ、本物の transcript の写しを、使い捨ての HOME で通した。空の DB から(Linux 3 本、
|
|
118
|
+
macOS 4 本、Windows 2 本): 途中の人の入力の数だけ見出しが入り、2回目の Stop は 0 行。前の版で取り込んだ後に直した版で
|
|
119
|
+
通す(Linux 3 本、macOS 4 本、Windows 1 本): 行は増えない。
|
|
120
|
+
- 確かめていない:
|
|
121
|
+
- 直した版を入れた端末で、本物の Claude Code が、途中の入力のあるターンを終えた時の L2。入れた後の追跡で読む。
|
|
122
|
+
- `turnPosition` を書かない版の Claude Code。その時は 3 が効かず、今までと同じ動きになる。`origin` を書かない版の
|
|
123
|
+
`queued_command`(macOS と Windows の古い行にある)は、足さない。
|
package/package.json
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* 実際のフォーマット(確認済み):
|
|
6
6
|
* {type: "user", message: {role: "user", content: [{type:"text", text:"..."}]}, ...}
|
|
7
7
|
* {type: "assistant", message: {role: "assistant", content: [{type:"text", text:"..."}, {type:"thinking", ...}]}, ...}
|
|
8
|
-
*
|
|
8
|
+
* {type: "attachment", attachment: {type: "queued_command", prompt, origin: {kind}}, ...}
|
|
9
|
+
* ターンの途中で届いた入力。届いた先のターンの依頼へ足す (ADR 0060)
|
|
10
|
+
* 他に queue-operation, file-history-snapshot, ほかの attachment 等があるが、本文としては無視する
|
|
9
11
|
*/
|
|
10
12
|
|
|
11
13
|
import { readFileSync, existsSync } from 'fs';
|
|
@@ -34,6 +36,59 @@ export function isCompactSummaryEntry(entry) {
|
|
|
34
36
|
return entry?.isCompactSummary === true;
|
|
35
37
|
}
|
|
36
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Claude Code が自分で書く meta の user 行か(ADR 0060)。skill を読んだ時の本文、端末で打った命令の注意書きなど。
|
|
41
|
+
* type は user だが、人の発言でも、外から届いた入力でもない。ターンの始まりにも本文にもしない。
|
|
42
|
+
* 別の会話から届いた発言も `isMeta: true` だが、`origin.kind` を持つ。届いた入力なので、ここには入れない。
|
|
43
|
+
* @param {object} entry
|
|
44
|
+
*/
|
|
45
|
+
export function isClaudeHostWrittenMetaEntry(entry) {
|
|
46
|
+
return entry?.type === 'user'
|
|
47
|
+
&& entry.message !== null && typeof entry.message === 'object'
|
|
48
|
+
&& entry.isMeta === true
|
|
49
|
+
&& typeof entry.origin?.kind !== 'string';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** ターンの始まりにも本文にもしない user 行か。圧縮の要約(ADR 0032)と、host が書いた meta の行(ADR 0060)。 */
|
|
53
|
+
function isHostWrittenClaudeUserEntry(entry) {
|
|
54
|
+
return isCompactSummaryEntry(entry) || isClaudeHostWrittenMetaEntry(entry);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ターンの途中で届いた入力のうち、依頼へ足す物。人が打った文と、別の会話から届いた発言。
|
|
58
|
+
// 裏の作業の完了通知(task-notification)は、そのターン自身の作業の結果なので足さない。
|
|
59
|
+
const CLAUDE_MID_TURN_INPUT_ORIGINS = Object.freeze(['human', 'peer']);
|
|
60
|
+
|
|
61
|
+
/** ターンの途中で届いた入力を、依頼の後ろへ足す時の見出し。記憶を読む側が、最初の依頼と見分けられる様にする。 */
|
|
62
|
+
export const MID_TURN_INPUT_MARK = '(作業の途中で届いた入力)';
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Claude Code が、動いているターンへ届けた入力の本文(ADR 0060)。当たらない行は null。
|
|
66
|
+
* 手すきの時に届けば user 行になる入力が、ターンの途中では `attachment` の `queued_command` で書かれる。
|
|
67
|
+
* @param {object} entry transcript JSONL の 1 行
|
|
68
|
+
* @returns {string|null}
|
|
69
|
+
*/
|
|
70
|
+
function claudeMidTurnInputText(entry) {
|
|
71
|
+
if (entry?.type !== 'attachment' || entry.isSidechain === true) return null;
|
|
72
|
+
const attachment = entry.attachment;
|
|
73
|
+
if (attachment?.type !== 'queued_command') return null;
|
|
74
|
+
if (!CLAUDE_MID_TURN_INPUT_ORIGINS.includes(attachment.origin?.kind)) return null;
|
|
75
|
+
if (typeof attachment.prompt !== 'string' && !Array.isArray(attachment.prompt)) return null;
|
|
76
|
+
const text = normalizeTerminalText(compactClaudePeerMessage(extractText(attachment.prompt)));
|
|
77
|
+
return text ? text : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* ターンの終わりに、待っていた入力が続けて user 行になった物か(ADR 0060)。Claude Code は、同じ番へ届けた
|
|
82
|
+
* user 行に同じ `turnPosition.promptIndex` を付ける。両方が `origin.kind` を持つ(届いた入力である)時だけ当たる。
|
|
83
|
+
*/
|
|
84
|
+
function isSameClaudePromptDelivery(previous, entry) {
|
|
85
|
+
const index = entry.turnPosition?.promptIndex;
|
|
86
|
+
return Number.isSafeInteger(index)
|
|
87
|
+
&& previous?.promptIndex === index
|
|
88
|
+
&& typeof entry.origin?.kind === 'string'
|
|
89
|
+
&& previous.hasOrigin === true;
|
|
90
|
+
}
|
|
91
|
+
|
|
37
92
|
const GROK_HOST_WRITTEN_SYNTHETIC_REASONS = Object.freeze(['compaction_meta', 'system_reminder']);
|
|
38
93
|
|
|
39
94
|
/**
|
|
@@ -72,9 +127,14 @@ export function readTranscript(transcriptPath) {
|
|
|
72
127
|
const raw = readFileSync(transcriptPath, 'utf8');
|
|
73
128
|
|
|
74
129
|
const turns = [];
|
|
75
|
-
// turn_number は transcript
|
|
76
|
-
//
|
|
130
|
+
// turn_number は transcript 内の本文行の通し番号。圧縮の要約行と host が書いた meta の行は返さないが、番号は進める。
|
|
131
|
+
// 番号を詰めると、その行を数えていた頃に保存した bodies の turn_number と食い違う。
|
|
77
132
|
let position = 0;
|
|
133
|
+
// 直前に返した user の行。ターンの途中で届いた入力と、続けて届いた待ち入力を、この行の本文へ足す (ADR 0060)。
|
|
134
|
+
let currentUser = null;
|
|
135
|
+
// currentUser の後に assistant の本文が出たか。出た後の user 行は、次のターンの始まり。
|
|
136
|
+
let currentUserAnswered = false;
|
|
137
|
+
let currentUserDelivery = null;
|
|
78
138
|
for (const line of raw.split('\n')) {
|
|
79
139
|
const trimmed = line.trim();
|
|
80
140
|
if (!trimmed) continue;
|
|
@@ -86,6 +146,13 @@ export function readTranscript(transcriptPath) {
|
|
|
86
146
|
continue;
|
|
87
147
|
}
|
|
88
148
|
|
|
149
|
+
// ターンの途中で届いた入力。本文行ではないので、番号は進めない。
|
|
150
|
+
const midTurnInput = claudeMidTurnInputText(entry);
|
|
151
|
+
if (midTurnInput !== null) {
|
|
152
|
+
if (currentUser) currentUser.content += `\n\n${MID_TURN_INPUT_MARK}\n${midTurnInput}`;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
89
156
|
// user / assistant エントリのみ対象。Claude は type、Cursor jsonl は role。
|
|
90
157
|
const kind = entryKind(entry);
|
|
91
158
|
if (kind !== 'user' && kind !== 'assistant') continue;
|
|
@@ -113,10 +180,18 @@ export function readTranscript(transcriptPath) {
|
|
|
113
180
|
if (isCompactSummaryEntry(entry)) continue;
|
|
114
181
|
// Grok の合成行も host が書いた行。番号は進める(数えていた頃に保存した turn_number と食い違わせない)。
|
|
115
182
|
if (isGrokHostWrittenUserEntry(entry)) continue;
|
|
183
|
+
// Claude Code の meta の行も host が書いた行。ここで区切ると、その前の依頼が本文の無い群になって消える。
|
|
184
|
+
if (isClaudeHostWrittenMetaEntry(entry)) continue;
|
|
185
|
+
|
|
186
|
+
if (role === 'user' && currentUser && !currentUserAnswered && isSameClaudePromptDelivery(currentUserDelivery, entry)) {
|
|
187
|
+
// 待っていた入力が、同じ番へ続けて届いた。1つの依頼にまとめる。別の行にすると、手前の入力が本文の無い群になって消える。
|
|
188
|
+
currentUser.content += `\n\n${text}`;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
116
191
|
|
|
117
192
|
const ts = typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : NaN;
|
|
118
193
|
// grok chat_history.jsonl は Claude の message 包みを持たず type/content 直置き。
|
|
119
|
-
|
|
194
|
+
const turn = {
|
|
120
195
|
role,
|
|
121
196
|
content: text,
|
|
122
197
|
turn_number: turnNumber,
|
|
@@ -128,7 +203,18 @@ export function readTranscript(transcriptPath) {
|
|
|
128
203
|
prompt_id: typeof entry.promptId === 'string' ? entry.promptId : null,
|
|
129
204
|
}
|
|
130
205
|
: {}),
|
|
131
|
-
}
|
|
206
|
+
};
|
|
207
|
+
turns.push(turn);
|
|
208
|
+
if (role === 'user') {
|
|
209
|
+
currentUser = turn;
|
|
210
|
+
currentUserAnswered = false;
|
|
211
|
+
currentUserDelivery = {
|
|
212
|
+
promptIndex: entry.turnPosition?.promptIndex,
|
|
213
|
+
hasOrigin: typeof entry.origin?.kind === 'string',
|
|
214
|
+
};
|
|
215
|
+
} else {
|
|
216
|
+
currentUserAnswered = true;
|
|
217
|
+
}
|
|
132
218
|
}
|
|
133
219
|
|
|
134
220
|
return turns;
|
|
@@ -403,8 +489,8 @@ export function sliceCurrentTurnEntries(entries) {
|
|
|
403
489
|
for (let i = lastAssistantTextIdx - 1; i >= 0; i--) {
|
|
404
490
|
const e = entries[i];
|
|
405
491
|
if (entryKind(e) !== 'user') continue;
|
|
406
|
-
//
|
|
407
|
-
if (
|
|
492
|
+
// 圧縮の要約行と host が書いた meta の行は、ターンの始まりではない。その前の tool 入出力も同じターンに含める。
|
|
493
|
+
if (isHostWrittenClaudeUserEntry(e)) continue;
|
|
408
494
|
const blocks = e.message?.content;
|
|
409
495
|
if (Array.isArray(blocks)) {
|
|
410
496
|
if (blocks.some((b) => b && b.type === 'text' && typeof b.text === 'string' && b.text.length > 0)) {
|
|
@@ -433,7 +519,7 @@ export function sliceCurrentTurnEntries(entries) {
|
|
|
433
519
|
export function sliceInFlightTurnEntries(entries) {
|
|
434
520
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
435
521
|
const e = entries[i];
|
|
436
|
-
if (entryKind(e) !== 'user' ||
|
|
522
|
+
if (entryKind(e) !== 'user' || isHostWrittenClaudeUserEntry(e)) continue;
|
|
437
523
|
const blocks = e.message?.content;
|
|
438
524
|
const hasText = Array.isArray(blocks)
|
|
439
525
|
? blocks.some((b) => b && b.type === 'text' && typeof b.text === 'string' && b.text.length > 0)
|
|
@@ -552,3 +552,162 @@ test('sliceInFlightTurnEntries: 最後の user 本文から末尾まで。最後
|
|
|
552
552
|
assert.deepEqual(sliceInFlightTurnEntries([entries[1]]), []);
|
|
553
553
|
});
|
|
554
554
|
|
|
555
|
+
|
|
556
|
+
// --- ADR 0060: Claude Code の、ターンの途中で届いた入力・host が書く meta の行・続けて届いた待ち入力 ---
|
|
557
|
+
|
|
558
|
+
function humanEntry(text, extra = {}) {
|
|
559
|
+
return { type: 'user', origin: { kind: 'human' }, promptSource: 'typed', message: { role: 'user', content: text }, ...extra };
|
|
560
|
+
}
|
|
561
|
+
function queuedCommandEntry(prompt, extra = {}) {
|
|
562
|
+
return {
|
|
563
|
+
type: 'attachment',
|
|
564
|
+
attachment: { type: 'queued_command', prompt, commandMode: 'prompt', origin: { kind: 'human' }, humanTurn: true, ...extra },
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
/** skill を読んだ時などに Claude Code が書く行。origin が無い。 */
|
|
568
|
+
function hostMetaEntry(text) {
|
|
569
|
+
return { type: 'user', isMeta: true, message: { role: 'user', content: [{ type: 'text', text }] } };
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
test('readTranscript: ターンの途中で届いた人の入力は、そのターンの依頼へ届いた順に足す。番号は進めない', () => {
|
|
573
|
+
const path = writeTranscript([
|
|
574
|
+
humanEntry('最初の依頼'),
|
|
575
|
+
asstToolUseEntry('toolu_1', 'Read', { file_path: '/a' }),
|
|
576
|
+
queuedCommandEntry('途中の追加1'),
|
|
577
|
+
userToolResultEntry('toolu_1', 'ok'),
|
|
578
|
+
asstTextEntry('途中の報告'),
|
|
579
|
+
queuedCommandEntry([{ type: 'text', text: '途中の追加2' }, { type: 'image', source: {} }]),
|
|
580
|
+
asstTextEntry('最終回答'),
|
|
581
|
+
humanEntry('次の依頼'),
|
|
582
|
+
asstTextEntry('次の回答'),
|
|
583
|
+
]);
|
|
584
|
+
const turns = readTranscript(path);
|
|
585
|
+
assert.deepEqual(turns.map((t) => [t.role, t.turn_number]), [
|
|
586
|
+
['user', 0], ['assistant', 1], ['assistant', 2], ['user', 3], ['assistant', 4],
|
|
587
|
+
]);
|
|
588
|
+
assert.equal(turns[0].content,
|
|
589
|
+
'最初の依頼\n\n(作業の途中で届いた入力)\n途中の追加1\n\n(作業の途中で届いた入力)\n途中の追加2');
|
|
590
|
+
assert.equal(turns[0].start, 'prompt');
|
|
591
|
+
assert.equal(turns[3].content, '次の依頼');
|
|
592
|
+
const groups = getLogicalTurnGroups(path);
|
|
593
|
+
assert.equal(groups[0].representative.index, 2);
|
|
594
|
+
assert.equal(groups[0].user.content.startsWith('最初の依頼\n\n(作業の途中で届いた入力)\n途中の追加1'), true);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
test('readTranscript: 途中で届いた完了通知と、subagent の中の入力は依頼へ足さない。別の会話からの文は注意書きを落として足す', () => {
|
|
598
|
+
const path = writeTranscript([
|
|
599
|
+
humanEntry('依頼'),
|
|
600
|
+
queuedCommandEntry('<task-notification>\n<status>completed</status>\n</task-notification>',
|
|
601
|
+
{ commandMode: 'task-notification', origin: { kind: 'task-notification' }, humanTurn: undefined }),
|
|
602
|
+
{ ...queuedCommandEntry('subagent への入力'), isSidechain: true },
|
|
603
|
+
queuedCommandEntry('印の無い入力', { origin: undefined }),
|
|
604
|
+
queuedCommandEntry('Another Claude session sent a message:\n本文\n\nThis came from another Claude session, not a human.',
|
|
605
|
+
{ origin: { kind: 'peer' } }),
|
|
606
|
+
asstTextEntry('回答'),
|
|
607
|
+
]);
|
|
608
|
+
assert.equal(readTranscript(path)[0].content,
|
|
609
|
+
'依頼\n\n(作業の途中で届いた入力)\nAnother Claude session sent a message:\n本文');
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
test('readTranscript: 依頼より前に書かれた途中の入力は、足す先が無いので捨てる', () => {
|
|
613
|
+
const path = writeTranscript([queuedCommandEntry('宙に浮いた入力'), asstTextEntry('回答')]);
|
|
614
|
+
assert.deepEqual(readTranscript(path).map((t) => [t.role, t.content]), [['assistant', '回答']]);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
test('readTranscript: host が書いた meta の行(origin なし)は返さず、ターンも区切らない。番号は詰めない', () => {
|
|
618
|
+
const path = writeTranscript([
|
|
619
|
+
humanEntry('依頼'),
|
|
620
|
+
asstToolUseEntry('toolu_1', 'Skill', { skill: 'x' }),
|
|
621
|
+
userToolResultEntry('toolu_1', 'Launching skill: x'),
|
|
622
|
+
hostMetaEntry('Base directory for this skill: /x\n\n手順…'),
|
|
623
|
+
asstTextEntry('最終回答'),
|
|
624
|
+
]);
|
|
625
|
+
assert.deepEqual(readTranscript(path).map((t) => [t.role, t.content, t.turn_number]), [
|
|
626
|
+
['user', '依頼', 0],
|
|
627
|
+
['assistant', '最終回答', 2],
|
|
628
|
+
]);
|
|
629
|
+
const groups = getLogicalTurnGroups(path);
|
|
630
|
+
assert.equal(groups.length, 1);
|
|
631
|
+
assert.equal(groups[0].user.content, '依頼');
|
|
632
|
+
assert.equal(groups[0].representative.index, 2);
|
|
633
|
+
assert.equal(readLatestUserGroup(path).user.content, '依頼');
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
test('readTranscript: origin を持つ meta の行(別の会話から届いた発言)は、今までどおりターンの始まり', () => {
|
|
637
|
+
const path = writeTranscript([
|
|
638
|
+
humanEntry('依頼'),
|
|
639
|
+
asstTextEntry('回答'),
|
|
640
|
+
{ type: 'user', isMeta: true, origin: { kind: 'peer', from: 'x' },
|
|
641
|
+
message: { role: 'user', content: 'Another Claude session sent a message:\n本文\n\nThis came from another Claude session.' } },
|
|
642
|
+
asstTextEntry('返事'),
|
|
643
|
+
]);
|
|
644
|
+
assert.deepEqual(getLogicalTurnGroups(path).map((g) => [g.user.content, g.user.start]), [
|
|
645
|
+
['依頼', 'prompt'],
|
|
646
|
+
['Another Claude session sent a message:\n本文', 'unknown'],
|
|
647
|
+
]);
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
test('readTranscript: ターンの終わりに続けて届いた待ち入力(同じ promptIndex)は、1つの依頼にまとめる', () => {
|
|
651
|
+
const queued = (text, promptIndex, id) => humanEntry(text,
|
|
652
|
+
{ promptSource: 'queued', promptId: id, turnPosition: { promptIndex, turnIndex: promptIndex } });
|
|
653
|
+
const path = writeTranscript([
|
|
654
|
+
humanEntry('前の依頼', { promptId: 'p0', turnPosition: { promptIndex: 8, turnIndex: 8 } }),
|
|
655
|
+
asstTextEntry('前の回答'),
|
|
656
|
+
queued('待っていた1通目', 9, 'p1'),
|
|
657
|
+
{ type: 'attachment', attachment: { type: 'hook_success', hookEvent: 'UserPromptSubmit', content: '' } },
|
|
658
|
+
queued('待っていた2通目', 9, 'p2'),
|
|
659
|
+
asstTextEntry('回答'),
|
|
660
|
+
queued('別の番の入力', 10, 'p3'),
|
|
661
|
+
asstTextEntry('次の回答'),
|
|
662
|
+
]);
|
|
663
|
+
const turns = readTranscript(path);
|
|
664
|
+
assert.deepEqual(turns.map((t) => [t.role, t.content, t.turn_number]), [
|
|
665
|
+
['user', '前の依頼', 0],
|
|
666
|
+
['assistant', '前の回答', 1],
|
|
667
|
+
['user', '待っていた1通目\n\n待っていた2通目', 2],
|
|
668
|
+
['assistant', '回答', 4],
|
|
669
|
+
['user', '別の番の入力', 5],
|
|
670
|
+
['assistant', '次の回答', 6],
|
|
671
|
+
]);
|
|
672
|
+
assert.equal(turns[2].prompt_id, 'p1');
|
|
673
|
+
assert.equal(getLogicalTurnGroups(path)[1].representative.index, 4);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
test('readTranscript: promptIndex が違う・印が無い・間に本文がある user 行は、まとめない', () => {
|
|
677
|
+
const at = (promptIndex) => ({ turnPosition: { promptIndex, turnIndex: promptIndex } });
|
|
678
|
+
assert.deepEqual(readTranscript(writeTranscript([
|
|
679
|
+
humanEntry('止められた依頼', at(1)), humanEntry('打ち直した依頼', at(2)), asstTextEntry('回答'),
|
|
680
|
+
])).map((t) => t.content), ['止められた依頼', '打ち直した依頼', '回答']);
|
|
681
|
+
assert.deepEqual(readTranscript(writeTranscript([
|
|
682
|
+
userEntry('印の無い行'), userEntry('印の無い行2'), asstTextEntry('回答'),
|
|
683
|
+
])).map((t) => t.content), ['印の無い行', '印の無い行2', '回答']);
|
|
684
|
+
// 端末で打った命令の行(origin なし)は、同じ promptIndex を持っていても人の入力とまとめない
|
|
685
|
+
assert.deepEqual(readTranscript(writeTranscript([
|
|
686
|
+
{ type: 'user', message: { role: 'user', content: '<local-command-stdout>x</local-command-stdout>' }, ...at(3) },
|
|
687
|
+
humanEntry('依頼', at(3)), asstTextEntry('回答'),
|
|
688
|
+
])).map((t) => t.content), ['<local-command-stdout>x</local-command-stdout>', '依頼', '回答']);
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
test('sliceCurrentTurnEntries / sliceInFlightTurnEntries: host が書いた meta の行で区切らず、その前の道具も同じターンに含める', () => {
|
|
692
|
+
const entries = [
|
|
693
|
+
humanEntry('前の依頼'),
|
|
694
|
+
asstTextEntry('前の回答'),
|
|
695
|
+
humanEntry('今の依頼'),
|
|
696
|
+
asstToolUseEntry('toolu_1', 'Read', { file_path: '/a' }),
|
|
697
|
+
userToolResultEntry('toolu_1', 'before skill'),
|
|
698
|
+
asstToolUseEntry('toolu_2', 'Skill', { skill: 'x' }),
|
|
699
|
+
userToolResultEntry('toolu_2', 'Launching skill: x'),
|
|
700
|
+
hostMetaEntry('skill の本文'),
|
|
701
|
+
asstToolUseEntry('toolu_3', 'Write', { file_path: '/b' }),
|
|
702
|
+
userToolResultEntry('toolu_3', 'after skill'),
|
|
703
|
+
];
|
|
704
|
+
const inFlight = sliceInFlightTurnEntries(entries);
|
|
705
|
+
assert.equal(inFlight[0].message.content, '今の依頼');
|
|
706
|
+
assert.deepEqual(
|
|
707
|
+
extractDetailBlocks(inFlight).filter((d) => d.kind === DETAIL_KIND.TOOL_INPUT).map((d) => d.tool_name),
|
|
708
|
+
['Read', 'Skill', 'Write'],
|
|
709
|
+
);
|
|
710
|
+
const done = sliceCurrentTurnEntries([...entries, asstTextEntry('最終回答')]);
|
|
711
|
+
assert.equal(done[0].message.content, '今の依頼');
|
|
712
|
+
assert.equal(done.length, 9);
|
|
713
|
+
});
|
|
@@ -504,3 +504,63 @@ test('captureInFlightTurn: 発言が1つも無いターンは、user の行と
|
|
|
504
504
|
);
|
|
505
505
|
});
|
|
506
506
|
|
|
507
|
+
|
|
508
|
+
// --- ADR 0060: host が書いた meta の行でターンを区切っていた頃の行と、途中で届いた入力 ---
|
|
509
|
+
|
|
510
|
+
test('backfillBodies: meta の行で区切っていた頃に保存したターンは、群がまとまっても重ねて入れない', () => {
|
|
511
|
+
const meta = { type: 'user', isMeta: true, message: { role: 'user', content: [{ type: 'text', text: 'skill の本文' }] } };
|
|
512
|
+
const human = (text) => entry('user', text, undefined, { origin: { kind: 'human' } });
|
|
513
|
+
const seeded = (turns) => {
|
|
514
|
+
const db = makeDb();
|
|
515
|
+
const insert = db.prepare(`INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, created_at) VALUES (?, ?, ?, ?, ?, 1)`);
|
|
516
|
+
for (const [turn, user] of turns) {
|
|
517
|
+
insert.run('target', 'origin', turn, 'user', user);
|
|
518
|
+
insert.run('target', 'origin', turn, 'assistant', 'saved');
|
|
519
|
+
}
|
|
520
|
+
return db;
|
|
521
|
+
};
|
|
522
|
+
const run = (db, entries) => withData(entries, (path) =>
|
|
523
|
+
backfillBodies(db, { targetSessionId: 'target', originSessionId: 'origin', transcriptPath: path, now: 999 }));
|
|
524
|
+
|
|
525
|
+
// 前の版: [依頼, 途中の報告] と [skill の本文, 最終回答] の2群。今の版: 1群(断片 1 と 3)。
|
|
526
|
+
const both = seeded([[1, '依頼'], [3, 'skill の本文']]);
|
|
527
|
+
const split = [human('依頼'), entry('assistant', '途中の報告'), meta, entry('assistant', '最終回答')];
|
|
528
|
+
assert.deepEqual(pick(run(both, split)), { groups: 1, insertedTurns: 0, skippedExisting: 1 });
|
|
529
|
+
assert.equal(both.prepare('SELECT COUNT(*) AS n FROM bodies').get().n, 4);
|
|
530
|
+
|
|
531
|
+
// 前の版: 依頼は本文の無い群で消え、[skill の本文, 最終回答] だけが入っていた。
|
|
532
|
+
const lost = seeded([[2, 'skill の本文']]);
|
|
533
|
+
assert.deepEqual(pick(run(lost, [human('依頼'), meta, entry('assistant', '最終回答')])),
|
|
534
|
+
{ groups: 1, insertedTurns: 0, skippedExisting: 1 });
|
|
535
|
+
|
|
536
|
+
// 新しい会話では、依頼が user の行になる。
|
|
537
|
+
const fresh = makeDb();
|
|
538
|
+
assert.deepEqual(pick(run(fresh, [human('依頼'), meta, entry('assistant', '最終回答')])),
|
|
539
|
+
{ groups: 1, insertedTurns: 1, skippedExisting: 0 });
|
|
540
|
+
assert.deepEqual(fresh.prepare('SELECT turn_number, role, text, turn_start FROM bodies ORDER BY role DESC').all().map((r) => ({ ...r })), [
|
|
541
|
+
{ turn_number: 2, role: 'user', text: '依頼', turn_start: 'prompt' },
|
|
542
|
+
{ turn_number: 2, role: 'assistant', text: '最終回答', turn_start: null },
|
|
543
|
+
]);
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
function pick(result) {
|
|
547
|
+
return { groups: result.groups, insertedTurns: result.insertedTurns, skippedExisting: result.skippedExisting };
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
test('backfillBodies / captureInFlightTurn: ターンの途中で届いた入力は、依頼の行へ入る', () => {
|
|
551
|
+
const steer = (prompt) => ({ type: 'attachment', attachment: { type: 'queued_command', prompt, origin: { kind: 'human' } } });
|
|
552
|
+
const entries = [
|
|
553
|
+
entry('user', '依頼', undefined, { origin: { kind: 'human' } }),
|
|
554
|
+
steer('追加の指示'),
|
|
555
|
+
entry('assistant', '回答'),
|
|
556
|
+
];
|
|
557
|
+
const done = makeDb();
|
|
558
|
+
withData(entries, (path) => backfillBodies(done, { targetSessionId: 'target', originSessionId: 'origin', transcriptPath: path, now: 999 }));
|
|
559
|
+
assert.equal(done.prepare(`SELECT text FROM bodies WHERE role = 'user'`).get().text, '依頼\n\n(作業の途中で届いた入力)\n追加の指示');
|
|
560
|
+
|
|
561
|
+
const stopped = makeDb();
|
|
562
|
+
stopped.exec(`CREATE TABLE details (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, origin_session_id TEXT, turn_number INTEGER,
|
|
563
|
+
tool_name TEXT, input_text TEXT, output_text TEXT, token_count INTEGER, created_at INTEGER, kind TEXT, source_id TEXT)`);
|
|
564
|
+
withData(entries.slice(0, 2), (path) => captureInFlightTurn(stopped, { targetSessionId: 'target', originSessionId: 'origin', transcriptPath: path, now: 999 }));
|
|
565
|
+
assert.equal(stopped.prepare(`SELECT text FROM bodies WHERE role = 'user'`).get().text, '依頼\n\n(作業の途中で届いた入力)\n追加の指示');
|
|
566
|
+
});
|