throughline 0.4.7 → 0.4.9

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.
@@ -0,0 +1,72 @@
1
+ /**
2
+ * L3 reference の表示集約ヘルパー (resume-context / codex-handoff 共通)
3
+ *
4
+ * 各 L1 / L2 行末尾に付ける `(詳細:…)` suffix の組み立てを担当する。
5
+ * - hook 出力 (system) は noise なので suffix から除外する
6
+ * - tool_input + tool_output は 1:1 なので tool 名 (例: Bash) で 1 つに集約する
7
+ * (count は tool_input 側だけで数える)
8
+ * - mcp__ ツール名は末尾の関数名だけに短縮する (フルパスは namespace noise)
9
+ * - 件数は >1 のときだけ ` ×N` で表示
10
+ */
11
+
12
+ /**
13
+ * MCP ツール名 (`mcp__plugin_..._playwright__browser_navigate`) を末尾の関数名
14
+ * (`browser_navigate`) だけに短縮する。最後の `__` 以降を返す。
15
+ */
16
+ export function shortenMcpToolName(toolName) {
17
+ if (typeof toolName !== 'string') return toolName ?? 'tool';
18
+ if (!toolName.startsWith('mcp__')) return toolName;
19
+ const idx = toolName.lastIndexOf('__');
20
+ return idx >= 0 && idx + 2 < toolName.length ? toolName.slice(idx + 2) : toolName;
21
+ }
22
+
23
+ /**
24
+ * L3 kind + tool_name を AI が読みやすい日本語ラベルにする。
25
+ * null を返した kind は suffix からスキップする (noise / 二重カウント回避)。
26
+ */
27
+ export function localizeL3Part(kind, toolName) {
28
+ if (kind === 'thinking') return '思考';
29
+ if (kind === 'tool_input') return shortenMcpToolName(toolName);
30
+ if (kind === 'tool_output') return null;
31
+ if (kind === 'system') return null;
32
+ if (kind === 'image') return '画像';
33
+ return kind;
34
+ }
35
+
36
+ /**
37
+ * L3 references を `(originSessionId, turnNumber)` でグルーピングし、
38
+ * 表示ラベル (Bash / 思考 / 画像 など) ごとの件数を保つ。
39
+ * Map の挿入順は created_at ASC のままなので、自然な発生順に並ぶ。
40
+ */
41
+ export function groupL3ByTurn(l3Refs) {
42
+ const map = new Map();
43
+ for (const ref of l3Refs) {
44
+ if (ref.originSessionId == null || ref.turnNumber == null) continue;
45
+ const turnKey = `${ref.originSessionId}\x00${ref.turnNumber}`;
46
+ let entry = map.get(turnKey);
47
+ if (!entry) {
48
+ entry = { partCounts: new Map() };
49
+ map.set(turnKey, entry);
50
+ }
51
+ const label = localizeL3Part(ref.kind, ref.toolName);
52
+ if (label == null) continue;
53
+ entry.partCounts.set(label, (entry.partCounts.get(label) ?? 0) + 1);
54
+ }
55
+ return map;
56
+ }
57
+
58
+ /**
59
+ * 1 ターン分の `(詳細:…)` suffix 文字列を組み立てる。
60
+ * - L1 の場合は `本文` を先頭に置き「summary を超えた full body が引ける」ことを示す
61
+ * - L2 の場合は body 自体は行内にあるので L3 部品だけ列挙
62
+ * - 何も無いなら空文字 (suffix 自体を出さない)
63
+ */
64
+ export function buildPartsSummary(partCounts, { includeBody = false } = {}) {
65
+ const parts = [];
66
+ if (includeBody) parts.push('本文');
67
+ for (const [label, count] of partCounts) {
68
+ parts.push(count > 1 ? `${label} ×${count}` : label);
69
+ }
70
+ if (parts.length === 0) return '';
71
+ return ` (詳細:${parts.join(', ')})`;
72
+ }
@@ -5,56 +5,42 @@
5
5
  * - session-start.mjs (auto path / baton path どちらでも同じ注入)
6
6
  *
7
7
  * 設計 (docs/THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md):
8
- * - 注入順: ヘッダ + Reading Contract → L1 要約 → L2 本文 → L3 refs 一覧 → Continuation Instruction
9
- * - 直近 N=20 ターンは bodies から L2 全文を注入
10
- * - それ以前は skeletons から L1 要約のみ注入
11
- * - L3 references は具体的な `throughline detail <時刻>` コマンドの一覧として注入
12
- * (本文は埋め込まず、参照のみ)
13
- * - memo / thinking は注入しない (= L2 全文があれば最後の assistant turn 自体に
14
- * 「次に何をしようとしていたか」が含まれる)
15
- * - 各行頭に [HH:MM:SS] 時刻プレフィックス(created_at ベース、DB 永続)
8
+ * - 注入順: ヘッダ + 読み方 → L1 要約 → L2 本文(一番下)
9
+ * - 直前の発話に Claude attention が向くよう、L2 を末尾に置く
10
+ * - L3 は別セクションを設けず、対応する L1 / L2 行にインラインで
11
+ * `[→ throughline detail HH:MM:SS (kind …)]` ヒントを付ける
12
+ * - L2 全文があれば最後の assistant turn 自体に「次に何をしようとしていたか」が
13
+ * 含まれるため、memo / thinking / 末尾の再開指示は注入しない
14
+ * - 各行頭に [HH:MM:SS] 時刻プレフィックス(L2 は body の created_at、
15
+ * L1 は元ターンの body 時刻が取れればそれ、なければ skeleton 時刻)
16
16
  * - 現セッションのターンは注入しない(Claude Code 本体のコンテキストに既にあるため)
17
- * - フレーミング: 「過去の記憶」ではなく「現在進行中の作業」として読ませる
18
- * (Codex 側 renderCodexRolloutMemoryPreview の写像)
17
+ * - フレーミング: 「報告してください」のメタ命令は出さない。直前の対話の
18
+ * 自然な続きとして応答させる
19
19
  */
20
20
 
21
- import { buildHandoffRecord, formatTime, N_RECENT_L2 } from './handoff-record.mjs';
21
+ import { buildHandoffRecord } from './handoff-record.mjs';
22
+ import { groupL3ByTurn, buildPartsSummary } from './l3-summary.mjs';
22
23
 
23
24
  const RESUME_HEADER_TEMPLATE = (turnCount) =>
24
25
  `## Throughline: 中断した作業の再開(${turnCount} ターン分の文脈を保持)\n` +
25
26
  `\n` +
26
- `**前セッションで進行中だった作業を、この新セッションで引き継いでいます。以下が中断時点の状態です:**\n` +
27
- `- 直近 ${N_RECENT_L2} ターンの会話本文 (L2)\n` +
28
- `- それ以前の要約 (L1)\n` +
29
- `- L3 (ツール入出力・思考) の参照一覧 (本文は別途取り出す)\n` +
30
- `\n` +
31
- `応答の冒頭でユーザーに「前の作業を ${turnCount} ターン分引き継ぎました」と報告してください。` +
32
- `作業方針は前セッションのものを踏襲し、中断地点から自然に続行してください。`;
33
-
34
- const ACTIVE_WORK_READING_CONTRACT =
35
- `\n` +
36
- `**読み方の契約:**\n` +
37
- `- これは単なる過去ログではなく、現在進行中の作業を再開するための active work context です。\n` +
38
- `- L2 は古い順に並んだ作業履歴です。後の発言・判断・TODO は前の仮説や作業方針を上書きし得ます。\n` +
39
- `- すべての L2 行を現在も正しい事実として扱わず、最新の L2 を優先して現在状態を推定してください。\n` +
40
- `- 不足している tool output / 詳細根拠が必要なときだけ、L3 references の \`throughline detail <時刻>\` を使って取得してください。`;
41
-
42
- const CONTINUATION_REMINDER =
43
- '**再開指示:** 上記の L1 / L2 を、現在タスクに使う作業コンテキストとして扱ってください。' +
44
- '最新の L2 から次の一手を決め、中断地点から続行してください。' +
45
- '古い仮説や未完了 TODO は、後続の判断で上書きされ得ます。';
27
+ `**読み方:**\n` +
28
+ `- 直前の対話の自然な続きとして応答してください。\n` +
29
+ '- **各ターンの詳細の取得方法**: **`Bash` ツールで `throughline detail HH:MM:SS` を実行** ' +
30
+ `(該当ターンの本文+詳細を stdout に返します)`;
46
31
 
47
32
  const NORMAL_HEADER = '## Throughline: セッション記憶';
48
33
 
49
34
  /**
50
- * L1 + L2 + L3 references 注入テキストを組み立てる。
35
+ * L1 + L2 注入テキストを組み立てる。L3 は本文ではなく
36
+ * 各 L1 / L2 行末尾の inline hint として付与する。
51
37
  *
52
38
  * @param {import('node:sqlite').DatabaseSync} db
53
39
  * @param {{
54
40
  * sessionId: string,
55
41
  * isInheritance: boolean,
56
42
  * excludeOriginId?: string | null,
57
- * inflightMemo?: string | null, // 互換のため受け取るが新仕様では使用しない
43
+ * inflightMemo?: string | null,
58
44
  * }} params
59
45
  * @returns {string | null}
60
46
  */
@@ -62,8 +48,6 @@ export function buildResumeContext(
62
48
  db,
63
49
  { sessionId, isInheritance, excludeOriginId = null, inflightMemo: _ignoredMemo = null },
64
50
  ) {
65
- // handoff-record は Codex 側でも使うので signature 維持。inflightMemo / latestThinking は
66
- // 新仕様の注入テキストには使わない (L2 全文で十分という判断)。
67
51
  const record = buildHandoffRecord(db, {
68
52
  sessionId,
69
53
  isInheritance,
@@ -72,42 +56,60 @@ export function buildResumeContext(
72
56
  if (!record) return null;
73
57
 
74
58
  const turnCount = record.stats.preservedContextRows;
75
- const header = isInheritance
76
- ? RESUME_HEADER_TEMPLATE(turnCount) + ACTIVE_WORK_READING_CONTRACT
77
- : NORMAL_HEADER;
59
+ const header = isInheritance ? RESUME_HEADER_TEMPLATE(turnCount) : NORMAL_HEADER;
78
60
  const lines = [header];
79
61
 
62
+ const l3ByTurn = groupL3ByTurn(record.references.l3);
63
+
80
64
  if (record.memory.l1Summaries.length > 0) {
81
- lines.push('');
82
- lines.push('### それ以前の要約 (L1)');
65
+ const l1Lines = [];
83
66
  for (const r of record.memory.l1Summaries) {
84
67
  if (!r.summary || r.summary === '(no content)') continue;
85
- lines.push(`[${r.time}] ${r.summary.replace(/\n+/g, ' ').trim()}`);
68
+ const summary = r.summary.replace(/\n+/g, ' ').trim();
69
+ const key = `${r.originSessionId}\x00${r.turnNumber}`;
70
+
71
+ // body 時刻が引けた行だけ詳細呼び出しを案内する。引けない場合は
72
+ // `[skeleton 時刻]` のままだと throughline detail が解決しないので suffix を出さない。
73
+ const displayTime = r.bodyTime ?? r.time;
74
+ const partCounts = l3ByTurn.get(key)?.partCounts ?? new Map();
75
+ const suffix = r.bodyTime != null
76
+ ? buildPartsSummary(partCounts, { includeBody: true })
77
+ : '';
78
+
79
+ l1Lines.push(`[${displayTime}] ${summary}${suffix}`);
80
+ }
81
+ if (l1Lines.length > 0) {
82
+ lines.push('');
83
+ lines.push('### それ以前の要約 (L1)');
84
+ lines.push(...l1Lines);
86
85
  }
87
86
  }
88
87
 
89
88
  if (record.memory.recentBodies.length > 0) {
90
89
  lines.push('');
91
- lines.push('### 現在進行中の作業履歴 (L2 / active work thread)');
92
- lines.push('以下は古い順です。後の行ほど現在状態に近く、前の仮説を上書きし得ます。');
93
- for (const r of record.memory.recentBodies) {
90
+ lines.push('### 直前の対話 (L2 / active work thread, 古い順)');
91
+
92
+ // ターン内の最終 role 行 (通常 user→assistant 順なら assistant) にだけ suffix を出す。
93
+ // L3 (思考 / ツール / hook 出力 / 画像) は turn_number 単位でしか紐付いていないので
94
+ // 同じターンの user 行と assistant 行の両方に貼ると同じ内容が二度出て紛らわしい。
95
+ const lastIdxPerTurn = new Map();
96
+ for (let i = 0; i < record.memory.recentBodies.length; i += 1) {
97
+ const r = record.memory.recentBodies[i];
94
98
  if (!r.text) continue;
95
- lines.push(`[${r.time}] [${r.role}]: ${r.text}`);
99
+ const key = `${r.originSessionId}\x00${r.turnNumber}`;
100
+ lastIdxPerTurn.set(key, i);
96
101
  }
97
- }
98
102
 
99
- if (record.references.l3.length > 0) {
100
- lines.push('');
101
- lines.push('### L3 詳細参照 (本文は注入されていません)');
102
- lines.push('必要なときだけ以下のコマンドで取得してください:');
103
- for (const ref of record.references.l3) {
104
- const time = formatTime(ref.createdAt);
105
- lines.push(`- [${time}] ${ref.kind}: \`throughline detail ${time}\``);
103
+ for (let i = 0; i < record.memory.recentBodies.length; i += 1) {
104
+ const r = record.memory.recentBodies[i];
105
+ if (!r.text) continue;
106
+ const key = `${r.originSessionId}\x00${r.turnNumber}`;
107
+ const isLastOfTurn = lastIdxPerTurn.get(key) === i;
108
+ const partCounts = isLastOfTurn ? (l3ByTurn.get(key)?.partCounts ?? new Map()) : new Map();
109
+ const suffix = buildPartsSummary(partCounts);
110
+ lines.push(`[${r.time}] [${r.role}]: ${r.text}${suffix}`);
106
111
  }
107
112
  }
108
113
 
109
- lines.push('');
110
- lines.push(CONTINUATION_REMINDER);
111
-
112
114
  return lines.join('\n');
113
115
  }
@@ -56,25 +56,68 @@ function insertBody(db, row) {
56
56
  ).run(row.session, row.origin, row.turn, row.role, row.text, 1, row.createdAt);
57
57
  }
58
58
 
59
- function insertThinking(db, row) {
59
+ function insertDetail(db, row) {
60
60
  db.prepare(
61
61
  `INSERT INTO details
62
62
  (session_id, origin_session_id, turn_number, tool_name, input_text, output_text,
63
63
  token_count, created_at, kind, source_id)
64
- VALUES (?, ?, ?, 'thinking', NULL, ?, 1, ?, 'thinking', ?)`,
65
- ).run(row.session, row.origin, row.turn, row.text, row.createdAt, row.sourceId);
64
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
65
+ ).run(
66
+ row.session,
67
+ row.origin,
68
+ row.turn,
69
+ row.toolName ?? row.kind,
70
+ row.input ?? null,
71
+ row.output ?? null,
72
+ row.tokenCount ?? 1,
73
+ row.createdAt,
74
+ row.kind,
75
+ row.sourceId ?? null,
76
+ );
66
77
  }
67
78
 
68
- test('buildResumeContext: inheritance output order is L1 -> L2 -> L3 refs -> reminder (no memo / no thinking)', () => {
79
+ test('buildResumeContext: header is terse and announces the Bash invocation contract', () => {
69
80
  const db = makeDb();
81
+ insertBody(db, {
82
+ session: 'new',
83
+ origin: 'old',
84
+ turn: 1,
85
+ role: 'user',
86
+ text: 'hi',
87
+ createdAt: 1000,
88
+ });
89
+
90
+ const text = buildResumeContext(db, {
91
+ sessionId: 'new',
92
+ isInheritance: true,
93
+ });
94
+
95
+ assert.ok(text);
96
+ assert.match(text, /^## Throughline: 中断した作業の再開/);
97
+
98
+ // 旧版の冗長な行は全部削除
99
+ assert.ok(!text.includes('と報告してください'), 'meta-report instruction must be gone');
100
+ assert.ok(!text.includes('一番下の'), 'redundant ordering hint must be gone');
101
+ assert.ok(!text.includes('内訳の読み方'), 'glossary block must be gone');
102
+ assert.ok(!text.includes('現在進行中の作業の active work context'), 'verbose framing must be gone');
103
+
104
+ // 残るのは 2 行: 自然な続き + Bash 呼び出し方法
105
+ assert.match(text, /直前の対話の自然な続きとして応答してください/);
106
+ assert.match(
107
+ text,
108
+ /\*\*各ターンの詳細の取得方法\*\*: \*\*`Bash` ツールで `throughline detail HH:MM:SS` を実行\*\* \(該当ターンの本文+詳細を stdout に返します\)/,
109
+ );
110
+ });
70
111
 
112
+ test('buildResumeContext: L2 is the very last section (anchored at bottom for attention)', () => {
113
+ const db = makeDb();
71
114
  insertSkeleton(db, {
72
115
  session: 'new',
73
116
  origin: 'old',
74
117
  turn: 1,
75
118
  role: 'assistant',
76
119
  summary: 'older L1 summary',
77
- createdAt: 1000,
120
+ createdAt: 800,
78
121
  });
79
122
  insertBody(db, {
80
123
  session: 'new',
@@ -89,52 +132,284 @@ test('buildResumeContext: inheritance output order is L1 -> L2 -> L3 refs -> rem
89
132
  origin: 'old',
90
133
  turn: 2,
91
134
  role: 'assistant',
92
- text: 'recent assistant body',
135
+ text: 'recent assistant body — this should be the last line',
93
136
  createdAt: 2100,
94
137
  });
95
- // thinking は DB に書かれても、新仕様では注入テキストに出ない
96
- insertThinking(db, {
138
+
139
+ const text = buildResumeContext(db, {
140
+ sessionId: 'new',
141
+ isInheritance: true,
142
+ });
143
+
144
+ assert.ok(text);
145
+
146
+ assert.ok(!text.includes('**再開指示:**'), 'continuation reminder should be removed');
147
+ assert.ok(!text.includes('### L3 詳細参照'), 'standalone L3 section should be removed');
148
+
149
+ const lines = text.split('\n').filter((l) => l.length > 0);
150
+ assert.match(
151
+ lines[lines.length - 1],
152
+ /\[assistant\]: recent assistant body — this should be the last line/,
153
+ );
154
+
155
+ const l1Idx = text.indexOf('### それ以前の要約 (L1)');
156
+ const l2Idx = text.indexOf('### 直前の対話 (L2 / active work thread, 古い順)');
157
+ assert.ok(l1Idx > 0, 'L1 section should be present');
158
+ assert.ok(l2Idx > l1Idx, 'L2 section should follow L1');
159
+ });
160
+
161
+ test('buildResumeContext: L2 entries get inline (詳細:…) suffixes with tool-name-aware labels', () => {
162
+ const db = makeDb();
163
+ insertBody(db, {
164
+ session: 'new',
165
+ origin: 'old',
166
+ turn: 5,
167
+ role: 'assistant',
168
+ text: 'turn with tools',
169
+ createdAt: 5000,
170
+ });
171
+ insertDetail(db, {
97
172
  session: 'new',
98
173
  origin: 'old',
99
- turn: 2,
100
- text: 'latest thinking block',
101
- createdAt: 2200,
102
- sourceId: 'asst:thinking:0',
174
+ turn: 5,
175
+ kind: 'thinking',
176
+ toolName: 'thinking',
177
+ output: 'thinking text',
178
+ createdAt: 5010,
179
+ });
180
+ insertDetail(db, {
181
+ session: 'new',
182
+ origin: 'old',
183
+ turn: 5,
184
+ kind: 'tool_input',
185
+ toolName: 'Bash',
186
+ input: 'ls',
187
+ createdAt: 5020,
188
+ });
189
+ insertDetail(db, {
190
+ session: 'new',
191
+ origin: 'old',
192
+ turn: 5,
193
+ kind: 'tool_input',
194
+ toolName: 'Bash',
195
+ input: 'pwd',
196
+ createdAt: 5030,
197
+ });
198
+ insertDetail(db, {
199
+ session: 'new',
200
+ origin: 'old',
201
+ turn: 5,
202
+ kind: 'tool_output',
203
+ toolName: 'Bash',
204
+ output: 'home',
205
+ createdAt: 5040,
206
+ });
207
+ insertDetail(db, {
208
+ session: 'new',
209
+ origin: 'old',
210
+ turn: 5,
211
+ kind: 'system',
212
+ toolName: 'UserPromptSubmit',
213
+ output: 'hook ran',
214
+ createdAt: 5050,
215
+ });
216
+ // MCP tool: 末尾の関数名だけにすべき
217
+ insertDetail(db, {
218
+ session: 'new',
219
+ origin: 'old',
220
+ turn: 5,
221
+ kind: 'tool_input',
222
+ toolName: 'mcp__plugin_everything-claude-code_playwright__browser_navigate',
223
+ input: '{"url":"http://example.com"}',
224
+ createdAt: 5060,
225
+ });
226
+ insertBody(db, {
227
+ session: 'new',
228
+ origin: 'old',
229
+ turn: 6,
230
+ role: 'user',
231
+ text: 'plain user message',
232
+ createdAt: 6000,
103
233
  });
104
234
 
105
235
  const text = buildResumeContext(db, {
106
236
  sessionId: 'new',
107
237
  isInheritance: true,
108
- // inflightMemo は互換のため受け取れるが、新仕様では注入テキストに使わない
109
- inflightMemo: '**Next**: keep going (should NOT appear)',
110
238
  });
111
239
 
112
240
  assert.ok(text);
113
- assert.match(text, /^## Throughline: 中断した作業の再開/);
114
241
 
115
- // 新仕様: memo / thinking セクションは注入されない
116
- assert.ok(text.indexOf('### 中断直前の in-flight メモ') < 0, 'memo section should not be injected');
117
- assert.ok(text.indexOf('### 中断直前の思考') < 0, 'thinking section should not be injected');
118
- assert.ok(!text.includes('**Next**: keep going'), 'inflightMemo content should be ignored');
119
- assert.ok(!text.includes('latest thinking block'), 'thinking text should not appear');
242
+ const turnWithToolsLine = text
243
+ .split('\n')
244
+ .find((l) => l.includes('turn with tools'));
245
+ assert.ok(turnWithToolsLine, 'L2 line for turn 5 should exist');
246
+ // - tool_input + tool_output tool 名で集約 (Bash ×2)
247
+ // - hook 出力 (system) は suffix から除外
248
+ // - MCP ツール名は末尾の関数名 (browser_navigate) だけ
249
+ assert.match(
250
+ turnWithToolsLine,
251
+ /\(詳細:思考, Bash ×2, browser_navigate\)$/,
252
+ );
253
+ assert.ok(
254
+ !turnWithToolsLine.includes('hook 出力'),
255
+ 'hook 出力 (system) must be excluded from the suffix',
256
+ );
257
+ assert.ok(
258
+ !turnWithToolsLine.includes('mcp__'),
259
+ 'MCP full path must be shortened to function name only',
260
+ );
120
261
 
121
- // 注入される順序: L1 L2 L3 refs 再開指示
122
- const l1Idx = text.indexOf('### それ以前の要約 (L1)');
123
- const l2Idx = text.indexOf('### 現在進行中の作業履歴 (L2 / active work thread)');
124
- const l3Idx = text.indexOf('### L3 詳細参照');
125
- const reminderIdx = text.indexOf('**再開指示:**');
262
+ // 旧版にあった `[throughline detail HH:MM:SS]` のリンク表記は per-line には出さない
263
+ assert.ok(
264
+ !turnWithToolsLine.includes('throughline detail'),
265
+ 'per-line should not repeat the throughline detail command (the header announces it)',
266
+ );
126
267
 
127
- assert.ok(l1Idx > 0, 'L1 section should be present');
128
- assert.ok(l2Idx > l1Idx, 'L2 should follow L1');
129
- assert.ok(l3Idx > l2Idx, 'L3 references should follow L2');
130
- assert.ok(reminderIdx > l3Idx, 'continuation reminder should follow L3 refs');
131
-
132
- assert.ok(text.includes('older L1 summary'));
133
- assert.ok(text.includes('[user]: recent user body'));
134
- assert.ok(text.includes('[assistant]: recent assistant body'));
135
- // L3 refs は detail コマンドのみ (本文は注入しない)
136
- assert.match(text, /throughline detail \d{2}:\d{2}:\d{2}/);
137
- assert.match(text, /単なる過去ログではなく、現在進行中の作業/);
268
+ const plainLine = text.split('\n').find((l) => l.includes('plain user message'));
269
+ assert.ok(plainLine, 'L2 line for turn 6 should exist');
270
+ assert.ok(
271
+ !plainLine.includes('詳細:'),
272
+ 'L2 turns without L3 should not carry a (詳細:…) suffix',
273
+ );
274
+
275
+ // 旧版にあった独立 `### Detail References` セクションも出ない
276
+ assert.ok(!text.includes('### L3 詳細参照'));
277
+ });
278
+
279
+ test('buildResumeContext: (詳細:…) suffix appears only on the last role row of each turn (no duplication)', () => {
280
+ const db = makeDb();
281
+ // Turn 5: both user and assistant rows. L3 attached at turn level.
282
+ insertBody(db, {
283
+ session: 'new',
284
+ origin: 'old',
285
+ turn: 5,
286
+ role: 'user',
287
+ text: 'user side of turn 5',
288
+ createdAt: 5000,
289
+ });
290
+ insertBody(db, {
291
+ session: 'new',
292
+ origin: 'old',
293
+ turn: 5,
294
+ role: 'assistant',
295
+ text: 'assistant side of turn 5',
296
+ createdAt: 5100,
297
+ });
298
+ insertDetail(db, {
299
+ session: 'new',
300
+ origin: 'old',
301
+ turn: 5,
302
+ kind: 'thinking',
303
+ toolName: 'thinking',
304
+ output: 'thinking',
305
+ createdAt: 5050,
306
+ });
307
+ insertDetail(db, {
308
+ session: 'new',
309
+ origin: 'old',
310
+ turn: 5,
311
+ kind: 'tool_input',
312
+ toolName: 'Bash',
313
+ input: 'ls',
314
+ createdAt: 5060,
315
+ });
316
+
317
+ // Turn 6: only user row (e.g. compact session ending on user). suffix should
318
+ // attach to the user row since it's the last role of the turn.
319
+ insertBody(db, {
320
+ session: 'new',
321
+ origin: 'old',
322
+ turn: 6,
323
+ role: 'user',
324
+ text: 'lone user turn',
325
+ createdAt: 6000,
326
+ });
327
+ insertDetail(db, {
328
+ session: 'new',
329
+ origin: 'old',
330
+ turn: 6,
331
+ kind: 'image',
332
+ toolName: 'image',
333
+ output: '[img]',
334
+ createdAt: 6010,
335
+ });
336
+
337
+ const text = buildResumeContext(db, {
338
+ sessionId: 'new',
339
+ isInheritance: true,
340
+ });
341
+
342
+ assert.ok(text);
343
+
344
+ const userTurn5 = text.split('\n').find((l) => l.includes('user side of turn 5'));
345
+ const assistantTurn5 = text.split('\n').find((l) => l.includes('assistant side of turn 5'));
346
+ const userTurn6 = text.split('\n').find((l) => l.includes('lone user turn'));
347
+
348
+ assert.ok(userTurn5 && assistantTurn5 && userTurn6);
349
+ // Turn 5: only assistant (last role of the turn) gets the suffix
350
+ assert.ok(!userTurn5.includes('詳細:'), 'user row should not duplicate the turn suffix');
351
+ assert.match(assistantTurn5, /\(詳細:思考, Bash\)$/);
352
+ // Turn 6: user is the only role, so it gets the suffix
353
+ assert.match(userTurn6, /\(詳細:画像\)$/);
354
+ });
355
+
356
+ test('buildResumeContext: L1 entries display the body time at the start and prepend "本文" to the suffix', () => {
357
+ const db = makeDb();
358
+ // L1 summary was created at turn-processor run time (8000), but the original
359
+ // body was written at 1500. The line prefix [HH:MM:SS] must be the body time
360
+ // so `Bash で throughline detail HH:MM:SS` resolves correctly.
361
+ insertSkeleton(db, {
362
+ session: 'new',
363
+ origin: 'old',
364
+ turn: 1,
365
+ role: 'assistant',
366
+ summary: 'old turn summary',
367
+ createdAt: 8000,
368
+ });
369
+ insertBody(db, {
370
+ session: 'new',
371
+ origin: 'old',
372
+ turn: 1,
373
+ role: 'assistant',
374
+ text: 'original body text from turn 1',
375
+ createdAt: 1500,
376
+ });
377
+ // Push turn 1 out of L2 window
378
+ for (let t = 2; t <= 25; t += 1) {
379
+ insertBody(db, {
380
+ session: 'new',
381
+ origin: 'old',
382
+ turn: t,
383
+ role: 'user',
384
+ text: `filler turn ${t}`,
385
+ createdAt: 9000 + t,
386
+ });
387
+ }
388
+
389
+ const text = buildResumeContext(db, {
390
+ sessionId: 'new',
391
+ isInheritance: true,
392
+ });
393
+
394
+ assert.ok(text);
395
+
396
+ const l1Line = text.split('\n').find((l) => l.includes('old turn summary'));
397
+ assert.ok(l1Line, 'L1 line should be present');
398
+
399
+ // 行頭 [HH:MM:SS] が body 時刻を指している (skeleton 時刻ではない)
400
+ const bodyTime = new Date(1500).toTimeString().slice(0, 8);
401
+ const skeletonTime = new Date(8000).toTimeString().slice(0, 8);
402
+ assert.ok(
403
+ l1Line.startsWith(`[${bodyTime}] `),
404
+ `L1 line should start with body time [${bodyTime}], got: ${l1Line}`,
405
+ );
406
+ assert.ok(
407
+ !l1Line.startsWith(`[${skeletonTime}] `),
408
+ 'L1 line must not start with skeleton (summarization) time',
409
+ );
410
+
411
+ // (詳細:本文) suffix が付く (body が引けるという案内)
412
+ assert.match(l1Line, /\(詳細:本文\)$/);
138
413
  });
139
414
 
140
415
  test('buildResumeContext: returns null when no memory rows or inflight memo exist', () => {
@@ -175,3 +450,24 @@ test('buildResumeContext: excludeOriginId omits rows from the current origin', (
175
450
  assert.ok(text.includes('old origin body'));
176
451
  assert.ok(!text.includes('current origin body'));
177
452
  });
453
+
454
+ test('buildResumeContext: ignores inflightMemo (kept only for signature compatibility)', () => {
455
+ const db = makeDb();
456
+ insertBody(db, {
457
+ session: 'new',
458
+ origin: 'old',
459
+ turn: 1,
460
+ role: 'user',
461
+ text: 'hi',
462
+ createdAt: 1000,
463
+ });
464
+
465
+ const text = buildResumeContext(db, {
466
+ sessionId: 'new',
467
+ isInheritance: true,
468
+ inflightMemo: '**Next**: keep going (should NOT appear)',
469
+ });
470
+
471
+ assert.ok(text);
472
+ assert.ok(!text.includes('**Next**: keep going'));
473
+ });