throughline 0.4.8 → 0.4.10

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.
@@ -6,6 +6,8 @@
6
6
  * can consume it.
7
7
  */
8
8
 
9
+ import { groupL3ByTurn, buildPartsSummary } from './l3-summary.mjs';
10
+
9
11
  export const THROUGHLINE_HANDOFF_SCHEMA_VERSION = 1;
10
12
  export const DEFAULT_CODEX_HANDOFF_DETAIL_REF_LIMIT = 20;
11
13
  export const DEFAULT_CODEX_HANDOFF_RECENT_BODY_LIMIT = 8;
@@ -76,14 +78,46 @@ function truncateText(text, maxChars) {
76
78
  };
77
79
  }
78
80
 
79
- function uniqueDetailRefsByCommand(refs) {
80
- const seen = new Set();
81
+ /**
82
+ * 追加: Codex 用 inline detail suffix の組み立て補助。
83
+ * - L1: bodyTime (= 元ターン時刻) を行頭に出して `本文` 起点の詳細を案内
84
+ * - L2: ターン内の最終 role 行 (通常 user→assistant の assistant) にだけ suffix を貼る
85
+ * (同じ turn_number に紐付く L3 を user / assistant 両方に貼る冗長を回避)
86
+ */
87
+ function appendL1Lines(lines, l1Summaries, l3ByTurn) {
88
+ const l1Lines = [];
89
+ for (const row of l1Summaries) {
90
+ if (!row.summary || row.summary === '(no content)') continue;
91
+ const summary = singleLine(row.summary);
92
+ const key = `${row.originSessionId}\x00${row.turnNumber}`;
93
+ const displayTime = row.bodyTime ?? row.time;
94
+ const partCounts = l3ByTurn.get(key)?.partCounts ?? new Map();
95
+ // bodyTime が無い defensive ケース (元 body が消えている等) は detail 解決できないので
96
+ // suffix を付けない (誤誘導しない)。
97
+ const suffix = row.bodyTime != null
98
+ ? buildPartsSummary(partCounts, { includeBody: true })
99
+ : '';
100
+ l1Lines.push(`[${displayTime}] [${row.role}] ${summary}${suffix}`);
101
+ }
102
+ return l1Lines;
103
+ }
104
+
105
+ function appendL2Lines(bodies, l3ByTurn) {
106
+ const lastIdxPerTurn = new Map();
107
+ for (let i = 0; i < bodies.length; i += 1) {
108
+ const r = bodies[i];
109
+ if (!r.text) continue;
110
+ lastIdxPerTurn.set(`${r.originSessionId}\x00${r.turnNumber}`, i);
111
+ }
81
112
  const out = [];
82
- for (const ref of refs) {
83
- const key = ref.detailCommand;
84
- if (seen.has(key)) continue;
85
- seen.add(key);
86
- out.push(ref);
113
+ for (let i = 0; i < bodies.length; i += 1) {
114
+ const r = bodies[i];
115
+ if (!r.text) continue;
116
+ const key = `${r.originSessionId}\x00${r.turnNumber}`;
117
+ const isLastOfTurn = lastIdxPerTurn.get(key) === i;
118
+ const partCounts = isLastOfTurn ? (l3ByTurn.get(key)?.partCounts ?? new Map()) : new Map();
119
+ const suffix = buildPartsSummary(partCounts);
120
+ out.push({ row: r, suffix });
87
121
  }
88
122
  return out;
89
123
  }
@@ -93,6 +127,8 @@ export function renderCodexActiveWorkContext(record) {
93
127
  throw new Error('renderCodexActiveWorkContext: record is required');
94
128
  }
95
129
 
130
+ const l3ByTurn = groupL3ByTurn(record.references.l3);
131
+
96
132
  const lines = [];
97
133
  lines.push('## Throughline: Active Work Context');
98
134
  lines.push('');
@@ -109,6 +145,10 @@ export function renderCodexActiveWorkContext(record) {
109
145
  lines.push(
110
146
  'Do not treat every older line as still-current truth. Prefer the latest actionable state.',
111
147
  );
148
+ lines.push(
149
+ 'For each L1/L2 entry, the time prefix `[HH:MM:SS]` can be passed to ' +
150
+ '`throughline detail HH:MM:SS` (run via shell) to retrieve the full body and L3 detail of that turn.',
151
+ );
112
152
  lines.push('');
113
153
  lines.push('### Source');
114
154
  lines.push(`Throughline session: ${record.session.id}`);
@@ -130,32 +170,23 @@ export function renderCodexActiveWorkContext(record) {
130
170
  }
131
171
 
132
172
  if (record.memory.l1Summaries.length > 0) {
133
- lines.push('');
134
- lines.push('### L1 Summaries');
135
- for (const row of record.memory.l1Summaries) {
136
- if (!row.summary || row.summary === '(no content)') continue;
137
- lines.push(`[${row.time}] [${row.role}] ${row.summary.replace(/\n+/g, ' ').trim()}`);
173
+ const l1Lines = appendL1Lines(lines, record.memory.l1Summaries, l3ByTurn);
174
+ if (l1Lines.length > 0) {
175
+ lines.push('');
176
+ lines.push('### L1 Summaries');
177
+ lines.push(...l1Lines);
138
178
  }
139
179
  }
140
180
 
141
181
  if (record.memory.recentBodies.length > 0) {
142
- lines.push('');
143
- lines.push('### Active Work Thread (L2)');
144
- lines.push('Entries are oldest-to-newest; later entries may supersede earlier hypotheses.');
145
- for (const row of record.memory.recentBodies) {
146
- if (!row.text) continue;
147
- lines.push(`[${row.time}] [${row.role}] ${row.text}`);
148
- }
149
- }
150
-
151
- if (record.references.l3.length > 0) {
152
- lines.push('');
153
- lines.push('### Detail References');
154
- lines.push(
155
- 'Use these only when L1/L2 are insufficient. Run the command locally; do not guess missing tool output.',
156
- );
157
- for (const ref of record.references.l3) {
158
- lines.push(`- ${ref.kind}:${ref.toolName} turn ${ref.turnNumber}: ${ref.detailCommand}`);
182
+ const l2 = appendL2Lines(record.memory.recentBodies, l3ByTurn);
183
+ if (l2.length > 0) {
184
+ lines.push('');
185
+ lines.push('### Active Work Thread (L2)');
186
+ lines.push('Entries are oldest-to-newest; later entries may supersede earlier hypotheses.');
187
+ for (const { row, suffix } of l2) {
188
+ lines.push(`[${row.time}] [${row.role}] ${row.text}${suffix}`);
189
+ }
159
190
  }
160
191
  }
161
192
 
@@ -163,7 +194,7 @@ export function renderCodexActiveWorkContext(record) {
163
194
  lines.push('### Continuation Instruction');
164
195
  lines.push(
165
196
  'Continue from the latest actionable state represented above. Preserve user instructions and repository constraints. ' +
166
- 'If details are missing, inspect local files or Throughline detail references before acting.',
197
+ 'If details are missing, run `throughline detail HH:MM:SS` on the relevant entry before acting.',
167
198
  );
168
199
 
169
200
  return lines.join('\n');
@@ -172,6 +203,9 @@ export function renderCodexActiveWorkContext(record) {
172
203
  export function renderCodexNewThreadHandoff(
173
204
  record,
174
205
  {
206
+ // 旧 Detail References セクション用の制限。L3 が各 L1/L2 行末尾の
207
+ // `(詳細:…)` suffix に集約された後は描画には影響しないが、CLI flags との
208
+ // 互換維持のためバリデーションだけ残す。
175
209
  maxDetailRefs = DEFAULT_CODEX_HANDOFF_DETAIL_REF_LIMIT,
176
210
  maxRecentBodies = DEFAULT_CODEX_HANDOFF_RECENT_BODY_LIMIT,
177
211
  maxBodyChars = DEFAULT_CODEX_HANDOFF_BODY_MAX_CHARS,
@@ -222,13 +256,15 @@ export function renderCodexNewThreadHandoff(
222
256
  }
223
257
  }
224
258
 
259
+ const l3ByTurn = groupL3ByTurn(record.references.l3);
260
+
225
261
  if (record.memory.l1Summaries.length > 0) {
226
- lines.push('');
227
- lines.push('### L1 Memory Summaries');
228
- lines.push('Oldest-to-newest; use later entries when summaries disagree.');
229
- for (const row of record.memory.l1Summaries) {
230
- if (!row.summary || row.summary === '(no content)') continue;
231
- lines.push(`[${row.time}] [${row.role}] ${singleLine(row.summary)}`);
262
+ const l1Lines = appendL1Lines(lines, record.memory.l1Summaries, l3ByTurn);
263
+ if (l1Lines.length > 0) {
264
+ lines.push('');
265
+ lines.push('### L1 Memory Summaries');
266
+ lines.push('Oldest-to-newest; use later entries when summaries disagree.');
267
+ lines.push(...l1Lines);
232
268
  }
233
269
  }
234
270
 
@@ -242,45 +278,33 @@ export function renderCodexNewThreadHandoff(
242
278
  lines.push(
243
279
  `Long entries are truncated for handoff; full context: throughline codex-resume --session ${record.session.id}`,
244
280
  );
281
+ lines.push(
282
+ 'For each entry, the `[HH:MM:SS]` time prefix can be passed to ' +
283
+ '`throughline detail HH:MM:SS` (run via shell) to retrieve full body + L3 of that turn.',
284
+ );
245
285
  if (shownBodies.length === 0) {
246
286
  lines.push(`${bodies.length} active L2 entries available; omitted from this fresh-thread handoff.`);
247
287
  } else if (omittedBodies > 0) {
248
288
  lines.push(`Showing latest ${shownBodies.length} of ${bodies.length} active L2 entries; ${omittedBodies} older omitted.`);
249
289
  }
250
- for (const row of shownBodies) {
251
- if (!row.text) continue;
290
+ const l2 = appendL2Lines(shownBodies, l3ByTurn);
291
+ for (const { row, suffix } of l2) {
252
292
  const body = truncateText(row.text, maxBodyChars);
253
- lines.push(`[${row.time}] [${row.role}] ${body.text}`);
293
+ lines.push(`[${row.time}] [${row.role}] ${body.text}${suffix}`);
254
294
  if (body.truncated) {
255
295
  lines.push(`[entry truncated to ${maxBodyChars} chars]`);
256
296
  }
257
297
  }
258
298
  }
259
299
 
260
- if (record.references.l3.length > 0) {
261
- const refs = uniqueDetailRefsByCommand(record.references.l3);
262
- const shown = maxDetailRefs === 0 ? [] : refs.slice(-maxDetailRefs);
263
- const omitted = refs.length - shown.length;
264
- lines.push('');
265
- lines.push('### Detail References');
266
- lines.push('L3 bodies are not pasted here. Use local detail commands only when L1/L2 are insufficient.');
267
- if (shown.length === 0) {
268
- lines.push(`${refs.length} detail commands available; omitted from this fresh-thread handoff.`);
269
- } else {
270
- if (omitted > 0) {
271
- lines.push(`Showing latest ${shown.length} of ${refs.length} detail commands; ${omitted} older omitted.`);
272
- }
273
- for (const ref of shown) {
274
- lines.push(`- ${ref.kind}:${ref.toolName} turn ${ref.turnNumber}: ${ref.detailCommand}`);
275
- }
276
- }
277
- }
300
+ // Detail References セクションは廃止 ( L1/L2 行末尾の `(詳細:…)` suffix で
301
+ // 同じ情報が turn 単位に集約されるため重複)
278
302
 
279
303
  lines.push('');
280
304
  lines.push('### Start Instruction');
281
305
  lines.push(
282
306
  'Continue from the latest actionable state above. Preserve user instructions and repository constraints. ' +
283
- 'Do not mutate the original Codex thread; inspect local files or detail references before acting when context is missing.',
307
+ 'Do not mutate the original Codex thread; run `throughline detail HH:MM:SS` on the relevant entry before acting when context is missing.',
284
308
  );
285
309
 
286
310
  return lines.join('\n');
@@ -28,8 +28,28 @@ function makeRecord() {
28
28
  memory: {
29
29
  inflightMemo: 'Next: implement projection',
30
30
  latestThinking: [{ time: '12:00:03', text: 'latest hidden reasoning note' }],
31
- l1Summaries: [{ time: '12:00:01', role: 'assistant', summary: 'old summary' }],
32
- recentBodies: [{ time: '12:00:02', role: 'assistant', text: 'recent body' }],
31
+ l1Summaries: [
32
+ {
33
+ time: '12:00:01',
34
+ role: 'assistant',
35
+ summary: 'old summary',
36
+ originSessionId: 'old',
37
+ turnNumber: 1,
38
+ // bodyTime は handoff-record が bodies テーブル MIN(created_at) から付ける。
39
+ // test では原ターン時刻として固定値を入れる。
40
+ bodyTime: '11:59:50',
41
+ bodyTimeMs: null,
42
+ },
43
+ ],
44
+ recentBodies: [
45
+ {
46
+ time: '12:00:02',
47
+ role: 'assistant',
48
+ text: 'recent body',
49
+ originSessionId: 'old',
50
+ turnNumber: 2,
51
+ },
52
+ ],
33
53
  },
34
54
  references: {
35
55
  l3: [
@@ -93,28 +113,32 @@ test('toThroughlineHandoffBlock: rejects missing record', () => {
93
113
  assert.throws(() => toThroughlineHandoffBlock(null), /record is required/);
94
114
  });
95
115
 
96
- test('renderCodexActiveWorkContext: renders persisted memory as active work context', () => {
116
+ test('renderCodexActiveWorkContext: renders persisted memory with inline (詳細:…) suffix', () => {
97
117
  const text = renderCodexActiveWorkContext(makeRecord());
98
118
 
99
119
  assert.match(text, /## Throughline: Active Work Context/);
100
120
  assert.match(text, /### Reading Contract/);
101
121
  assert.match(text, /current-task context for continuation/);
122
+ // 詳細取得方法 (throughline detail HH:MM:SS) はヘッダーで announce 済み
123
+ assert.match(text, /throughline detail HH:MM:SS/);
102
124
  assert.match(text, /Throughline session: sess-1/);
103
125
  assert.match(text, /Source agent: claude/);
104
126
  assert.match(text, /### In-flight Memo\nNext: implement projection/);
105
127
  assert.match(text, /### Latest Thinking/);
106
128
  assert.match(text, /latest hidden reasoning note/);
107
129
  assert.match(text, /### L1 Summaries/);
108
- assert.match(text, /old summary/);
130
+ // L1 行頭は body 時刻 (bodyTime)、suffix `本文` を含む (元 body が引ける案内)
131
+ assert.match(text, /\[11:59:50\] \[assistant\] old summary \(詳細:本文\)/);
109
132
  assert.match(text, /### Active Work Thread \(L2\)/);
110
- assert.match(text, /\[12:00:02\] \[assistant\] recent body/);
111
- assert.match(text, /### Detail References/);
112
- assert.match(text, /throughline detail 12:00:01/);
133
+ // L2 末尾には L3 集約 suffix (turn 2 Bash 入力 1 件 → "Bash")
134
+ assert.match(text, /\[12:00:02\] \[assistant\] recent body \(詳細:Bash\)/);
135
+ // `### Detail References` セクションは廃止
136
+ assert.ok(!text.includes('### Detail References'));
113
137
  assert.match(text, /### Continuation Instruction/);
114
138
  assert.match(text, /Continue from the latest actionable state/);
115
139
  });
116
140
 
117
- test('renderCodexNewThreadHandoff: renders concise fresh-thread handoff context', () => {
141
+ test('renderCodexNewThreadHandoff: renders concise fresh-thread handoff context with inline suffix', () => {
118
142
  const record = makeRecord();
119
143
  const text = renderCodexNewThreadHandoff(record);
120
144
 
@@ -126,11 +150,19 @@ test('renderCodexNewThreadHandoff: renders concise fresh-thread handoff context'
126
150
  assert.match(text, /### In-flight Memo\nNext: implement projection/);
127
151
  assert.match(text, /### L1 Memory Summaries/);
128
152
  assert.match(text, /### Recent Active Thread \(L2\)/);
129
- assert.match(text, /throughline detail 12:00:01/);
153
+ // 詳細取得方法はヘッダーで announce
154
+ assert.match(text, /throughline detail HH:MM:SS/);
155
+ // L2 行に inline suffix
156
+ assert.match(text, /\[12:00:02\] \[assistant\] recent body \(詳細:Bash\)/);
157
+ // 旧 `### Detail References` セクションは廃止
158
+ assert.ok(!text.includes('### Detail References'));
130
159
  assert.match(text, /Do not mutate the original Codex thread/);
131
160
  });
132
161
 
133
- test('renderCodexNewThreadHandoff: caps detail references for pasteable new-thread prompts', () => {
162
+ test('renderCodexNewThreadHandoff: maxDetailRefs option is accepted but no longer renders a Detail References section', () => {
163
+ // 旧 ### Detail References セクションは廃止された。CLI flag 互換のため
164
+ // maxDetailRefs はバリデーションだけ通り、描画には影響しない (per-line suffix で
165
+ // turn 単位に集約済みのため)。
134
166
  const record = makeRecord();
135
167
  record.references.l3 = [
136
168
  {
@@ -138,26 +170,20 @@ test('renderCodexNewThreadHandoff: caps detail references for pasteable new-thre
138
170
  toolName: 'Bash',
139
171
  sourceId: 'toolu_1',
140
172
  originSessionId: 'old',
141
- turnNumber: 1,
142
- createdAt: 1000,
143
- detailCommand: 'throughline detail 12:00:01',
144
- },
145
- {
146
- kind: 'tool_output',
147
- toolName: 'Bash',
148
- sourceId: 'toolu_2',
149
- originSessionId: 'old',
150
173
  turnNumber: 2,
151
- createdAt: 2000,
174
+ createdAt: 1000,
152
175
  detailCommand: 'throughline detail 12:00:02',
153
176
  },
154
177
  ];
155
178
 
156
179
  const text = renderCodexNewThreadHandoff(record, { maxDetailRefs: 1 });
157
180
 
158
- assert.match(text, /Showing latest 1 of 2 detail commands; 1 older omitted/);
159
- assert.doesNotMatch(text, /throughline detail 12:00:01/);
160
- assert.match(text, /throughline detail 12:00:02/);
181
+ assert.ok(!text.includes('### Detail References'));
182
+ assert.ok(!text.includes('detail commands; '));
183
+ // ヘッダーの placeholder のみで、行末に固有時刻の throughline detail は出ない
184
+ assert.ok(!text.includes('throughline detail 12:00:02'));
185
+ // 代わりに inline suffix で turn 集約された情報が出る
186
+ assert.match(text, /\(詳細:Bash\)/);
161
187
  });
162
188
 
163
189
  test('renderCodexNewThreadHandoff: caps recent L2 entries and long bodies', () => {
@@ -180,15 +206,22 @@ test('renderCodexNewThreadHandoff: caps recent L2 entries and long bodies', () =
180
206
  assert.doesNotMatch(text, /long tail/);
181
207
  });
182
208
 
183
- test('renderCodexNewThreadHandoff: deduplicates repeated detail commands', () => {
209
+ test('renderCodexNewThreadHandoff: aggregates same-turn L3 into a single (詳細:…) suffix on the assistant line', () => {
210
+ // 旧版は同一 detail command の dedup (uniqueDetailRefsByCommand) を独立 Detail
211
+ // References セクションで行っていた。新版は groupL3ByTurn が turn 単位に集約し、
212
+ // L2 ターンの最終 role 行 (assistant) にだけ inline suffix を付ける。
184
213
  const record = makeRecord();
214
+ record.memory.recentBodies = [
215
+ { time: '12:00:02', role: 'user', text: 'user body', originSessionId: 'old', turnNumber: 2 },
216
+ { time: '12:00:02', role: 'assistant', text: 'assistant body', originSessionId: 'old', turnNumber: 2 },
217
+ ];
185
218
  record.references.l3 = [
186
219
  {
187
220
  kind: 'tool_input',
188
221
  toolName: 'Bash',
189
222
  sourceId: 'toolu_1',
190
223
  originSessionId: 'old',
191
- turnNumber: 1,
224
+ turnNumber: 2,
192
225
  createdAt: 1000,
193
226
  detailCommand: 'throughline detail 12:00:01',
194
227
  },
@@ -197,7 +230,7 @@ test('renderCodexNewThreadHandoff: deduplicates repeated detail commands', () =>
197
230
  toolName: 'Bash',
198
231
  sourceId: 'toolu_2',
199
232
  originSessionId: 'old',
200
- turnNumber: 1,
233
+ turnNumber: 2,
201
234
  createdAt: 1000,
202
235
  detailCommand: 'throughline detail 12:00:01',
203
236
  },
@@ -205,8 +238,16 @@ test('renderCodexNewThreadHandoff: deduplicates repeated detail commands', () =>
205
238
 
206
239
  const text = renderCodexNewThreadHandoff(record);
207
240
 
208
- assert.equal(text.match(/throughline detail 12:00:01/g)?.length, 1);
209
- assert.doesNotMatch(text, /Showing latest 1 of 2/);
241
+ // user 行には suffix が出ない (重複排除)
242
+ const userLine = text.split('\n').find((l) => l.includes('user body'));
243
+ assert.ok(userLine);
244
+ assert.ok(!userLine.includes('詳細:'));
245
+
246
+ // assistant 行 (turn 内最終 role) に suffix が 1 つだけ
247
+ const assistantLine = text.split('\n').find((l) => l.includes('assistant body'));
248
+ assert.ok(assistantLine);
249
+ // tool_input + tool_output の 1:1 ペアは tool 名 (Bash) で 1 つに集約される
250
+ assert.match(assistantLine, /\(詳細:Bash\)$/);
210
251
  });
211
252
 
212
253
  test('toCodexDeveloperMessageItem: wraps active work context as a developer message item', () => {
@@ -85,7 +85,12 @@ test('codex-resume prints active-work context text for explicit Codex session',
85
85
  assert.match(result.stdout, /Source agent: codex/);
86
86
  assert.match(result.stdout, /older codex summary/);
87
87
  assert.match(result.stdout, /latest codex body/);
88
- assert.match(result.stdout, /throughline detail \d\d:\d\d:\d\d/);
88
+ // 新仕様: 詳細取得方法は header の placeholder で announce
89
+ assert.match(result.stdout, /throughline detail HH:MM:SS/);
90
+ // 新仕様: L2 行末尾に inline `(詳細:…)` suffix (turn 2 の tool_input=exec_command 1 件)
91
+ assert.match(result.stdout, /latest codex body \(詳細:exec_command\)/);
92
+ // 旧 `### Detail References` セクションは廃止されている
93
+ assert.ok(!result.stdout.includes('### Detail References'));
89
94
  } finally {
90
95
  rmSync(project, { recursive: true, force: true });
91
96
  rmSync(home, { recursive: true, force: true });
@@ -142,7 +147,11 @@ test('codex-resume can print a fresh-thread handoff prompt', async () => {
142
147
  assert.match(result.stdout, /latest/);
143
148
  assert.match(result.stdout, /\[entry truncated to 6 chars\]/);
144
149
  assert.doesNotMatch(result.stdout, /codex body/);
145
- assert.match(result.stdout, /1 detail commands available; omitted from this fresh-thread handoff/);
150
+ // 新仕様: Detail References セクションは廃止されたので「N detail commands
151
+ // available; omitted」メッセージは出ない。--max-detail-refs はバリデーションだけ
152
+ // 残る no-op フラグになっている。
153
+ assert.ok(!result.stdout.includes('### Detail References'));
154
+ assert.ok(!result.stdout.includes('detail commands available; omitted'));
146
155
  assert.doesNotMatch(result.stdout, /^\{/);
147
156
  } finally {
148
157
  rmSync(project, { recursive: true, force: true });
@@ -123,6 +123,41 @@ function loadL1Summaries(db, { sessionId, excludeOriginId, bodyRows }) {
123
123
  return all.filter((s) => !bodySet.has(`${s.origin_session_id}\x00${s.turn_number}`));
124
124
  }
125
125
 
126
+ /**
127
+ * L1 ターンの元 body 時刻 (created_at MIN) を batch lookup する。
128
+ * skeletons.created_at は要約実行時刻なので `throughline detail HH:MM:SS` 解決に
129
+ * 使えない。元 body は trim 後も bodies テーブルに残っているのが通常で、
130
+ * (session_id, origin_session_id, turn_number) で MIN を引けば原ターンの時刻が得られる。
131
+ */
132
+ function loadL1BodyTimes(db, sessionId, l1Rows) {
133
+ if (!l1Rows || l1Rows.length === 0) return new Map();
134
+ const tuples = l1Rows
135
+ .filter((r) => r.origin_session_id != null && r.turn_number != null)
136
+ .map((r) => [r.origin_session_id, Number(r.turn_number)]);
137
+ if (tuples.length === 0) return new Map();
138
+
139
+ const placeholders = tuples.map(() => '(?, ?, ?)').join(', ');
140
+ const params = tuples.flatMap(([origin, turn]) => [sessionId, origin, turn]);
141
+
142
+ const out = new Map();
143
+ try {
144
+ const rows = db
145
+ .prepare(
146
+ `SELECT origin_session_id, turn_number, MIN(created_at) AS created_at
147
+ FROM bodies
148
+ WHERE (session_id, origin_session_id, turn_number) IN (VALUES ${placeholders})
149
+ GROUP BY origin_session_id, turn_number`,
150
+ )
151
+ .all(...params);
152
+ for (const r of rows) {
153
+ out.set(`${r.origin_session_id}\x00${r.turn_number}`, r.created_at);
154
+ }
155
+ } catch {
156
+ // body が無い defensive ケースでは bodyTime null のまま (renderer 側で skeleton 時刻 fallback)
157
+ }
158
+ return out;
159
+ }
160
+
126
161
  function loadLatestThinking(db, { sessionId, excludeOriginId }) {
127
162
  const hasExclude = Boolean(excludeOriginId);
128
163
  const latestQuery = hasExclude
@@ -234,6 +269,7 @@ export function buildHandoffRecord(
234
269
  }
235
270
 
236
271
  const l3References = loadL3References(db, { sessionId, bodyRows });
272
+ const l1BodyTimes = loadL1BodyTimes(db, sessionId, l1Rows);
237
273
  const originSessionIds = distinctOriginSessionIds(bodyRows, l1Rows, thinkingRows);
238
274
 
239
275
  return {
@@ -263,14 +299,21 @@ export function buildHandoffRecord(
263
299
  time: formatTime(r.created_at),
264
300
  sourceId: r.source_id ?? null,
265
301
  })),
266
- l1Summaries: l1Rows.map((r) => ({
267
- originSessionId: r.origin_session_id,
268
- turnNumber: r.turn_number,
269
- role: r.role,
270
- summary: r.summary,
271
- createdAt: r.created_at,
272
- time: formatTime(r.created_at),
273
- })),
302
+ l1Summaries: l1Rows.map((r) => {
303
+ const bodyTimeMs = l1BodyTimes.get(`${r.origin_session_id}\x00${r.turn_number}`);
304
+ return {
305
+ originSessionId: r.origin_session_id,
306
+ turnNumber: r.turn_number,
307
+ role: r.role,
308
+ summary: r.summary,
309
+ createdAt: r.created_at,
310
+ time: formatTime(r.created_at),
311
+ // 元ターンの body 時刻。`throughline detail HH:MM:SS` 解決に使える時刻。
312
+ // body が無い defensive ケースでは null。
313
+ bodyTimeMs: bodyTimeMs ?? null,
314
+ bodyTime: bodyTimeMs != null ? formatTime(bodyTimeMs) : null,
315
+ };
316
+ }),
274
317
  recentBodies: bodyRows.map((r) => ({
275
318
  originSessionId: r.origin_session_id,
276
319
  turnNumber: r.turn_number,
@@ -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
+ }