throughline 0.4.8 → 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.
- package/CHANGELOG.md +50 -0
- package/README.ja.md +1 -1
- package/README.md +3 -3
- 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_FIRST_ROADMAP.md +3 -3
- package/docs/THROUGHLINE_CODEX_TRIM_IMPLEMENTATION_PLAN.md +2 -2
- package/docs/THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md +4 -4
- package/docs/throughline-rollback-context-trim-insight.md +1 -1
- 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/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
|
@@ -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
|
+
}
|
package/src/resume-context.mjs
CHANGED
|
@@ -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
|
-
* - 注入順: ヘッダ +
|
|
9
|
-
* -
|
|
10
|
-
* -
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
*
|
|
17
|
+
* - フレーミング: 「報告してください」のメタ命令は出さない。直前の対話の
|
|
18
|
+
* 自然な続きとして応答させる
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { buildHandoffRecord
|
|
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
|
-
|
|
27
|
-
`-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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('###
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
99
|
+
const key = `${r.originSessionId}\x00${r.turnNumber}`;
|
|
100
|
+
lastIdxPerTurn.set(key, i);
|
|
96
101
|
}
|
|
97
|
-
}
|
|
98
102
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
|
|
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
|
|
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 (?, ?, ?,
|
|
65
|
-
).run(
|
|
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:
|
|
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:
|
|
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
|
-
|
|
96
|
-
|
|
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:
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
assert.ok(
|
|
119
|
-
|
|
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
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
128
|
-
assert.ok(
|
|
129
|
-
assert.ok(
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
+
});
|