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.
- package/CHANGELOG.md +64 -0
- package/README.ja.md +1 -1
- package/README.md +9 -6
- package/bin/throughline.mjs +3 -3
- package/docs/PUBLIC_RELEASE_PLAN.md +4 -4
- package/docs/THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md +1 -1
- package/docs/THROUGHLINE_CODEX_DUAL_SUPPORT.md +1 -1
- package/docs/THROUGHLINE_CODEX_FIRST_ROADMAP.md +14 -14
- package/docs/THROUGHLINE_CODEX_TRIM_IMPLEMENTATION_PLAN.md +7 -7
- package/docs/THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md +18 -10
- package/docs/throughline-rollback-context-trim-insight.md +7 -5
- package/package.json +1 -1
- package/src/cli/codex-handoff-smoke.mjs +1 -1
- package/src/cli/codex-hook.test.mjs +4 -4
- package/src/cli/doctor.mjs +117 -3
- package/src/cli/doctor.test.mjs +25 -1
- package/src/cli/install.mjs +2 -2
- package/src/cli/trim.mjs +0 -9
- package/src/codex-app-server.mjs +60 -19
- package/src/codex-app-server.test.mjs +45 -0
- package/src/codex-auto-refresh.mjs +1 -1
- package/src/codex-auto-refresh.test.mjs +4 -4
- package/src/codex-handoff-smoke.mjs +15 -12
- package/src/codex-handoff-smoke.test.mjs +25 -6
- package/src/codex-handoff.mjs +82 -58
- package/src/codex-handoff.test.mjs +69 -28
- package/src/codex-resume.test.mjs +11 -2
- package/src/handoff-record.mjs +51 -8
- package/src/l3-summary.mjs +72 -0
- package/src/resume-context.mjs +58 -56
- package/src/resume-context.test.mjs +332 -36
- package/src/trim-cli.test.mjs +19 -11
package/src/codex-handoff.mjs
CHANGED
|
@@ -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
|
-
|
|
80
|
-
|
|
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 (
|
|
83
|
-
const
|
|
84
|
-
if (
|
|
85
|
-
|
|
86
|
-
|
|
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.
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
lines.push(
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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,
|
|
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.
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
lines.push(
|
|
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
|
-
|
|
251
|
-
|
|
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
|
-
|
|
261
|
-
|
|
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;
|
|
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: [
|
|
32
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
assert.match(text,
|
|
112
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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.
|
|
159
|
-
assert.
|
|
160
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 });
|
package/src/handoff-record.mjs
CHANGED
|
@@ -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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
+
}
|