throughline 0.3.24 → 0.4.0
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/.claude/commands/tl.md +6 -21
- package/.codex-sidecar.yml +62 -0
- package/CHANGELOG.md +632 -0
- package/README.ja.md +71 -46
- package/README.md +420 -76
- package/bin/throughline.mjs +169 -7
- package/codex/skills/throughline/SKILL.md +157 -0
- package/codex/skills/throughline/agents/openai.yaml +7 -0
- package/docs/INHERITANCE_ON_CLEAR_ONLY.md +159 -0
- package/docs/L1_L2_L3_REDESIGN.md +415 -0
- package/docs/PUBLIC_RELEASE_PLAN.md +185 -0
- package/docs/THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md +286 -0
- package/docs/THROUGHLINE_CODEX_DUAL_SUPPORT.md +249 -0
- package/docs/THROUGHLINE_CODEX_FIRST_ROADMAP.md +555 -0
- package/docs/THROUGHLINE_CODEX_MONITOR_IMPLEMENTATION_PLAN.md +220 -0
- package/docs/THROUGHLINE_CODEX_TRIM_IMPLEMENTATION_PLAN.md +528 -0
- package/docs/THROUGHLINE_CODEX_TRIM_ROLLBACK_FIX_PLAN.md +672 -0
- package/docs/archive/CONCEPT.md +476 -0
- package/docs/archive/EXPERIMENT.md +371 -0
- package/docs/archive/README.md +22 -0
- package/docs/archive/SESSION_LINKING_DESIGN.md +231 -0
- package/docs/archive/THROUGHLINE_NEXT_STEPS.md +134 -0
- package/docs/throughline-codex-trim-rollback-incident-report.md +306 -0
- package/docs/throughline-handoff-context.example.json +57 -0
- package/docs/throughline-rollback-context-trim-insight.md +455 -0
- package/package.json +6 -2
- package/src/baton.mjs +17 -45
- package/src/baton.test.mjs +4 -41
- package/src/cli/codex-capture.mjs +95 -0
- package/src/cli/codex-handoff-model-smoke.mjs +292 -0
- package/src/cli/codex-handoff-model-smoke.test.mjs +262 -0
- package/src/cli/codex-handoff-smoke.mjs +163 -0
- package/src/cli/codex-handoff-smoke.test.mjs +149 -0
- package/src/cli/codex-handoff-start.mjs +291 -0
- package/src/cli/codex-handoff-start.test.mjs +194 -0
- package/src/cli/codex-hook.mjs +276 -0
- package/src/cli/codex-hook.test.mjs +293 -0
- package/src/cli/codex-host-primitive-audit.mjs +110 -0
- package/src/cli/codex-host-primitive-audit.test.mjs +75 -0
- package/src/cli/codex-restore-smoke.mjs +357 -0
- package/src/cli/codex-restore-source-audit.mjs +304 -0
- package/src/cli/codex-resume.mjs +138 -0
- package/src/cli/codex-rollback-model-visible-smoke.mjs +373 -0
- package/src/cli/codex-rollback-model-visible-smoke.test.mjs +255 -0
- package/src/cli/codex-sidecar-diagnostics.mjs +48 -0
- package/src/cli/codex-sidecar-dry-run.mjs +85 -0
- package/src/cli/codex-summarize.mjs +224 -0
- package/src/cli/codex-threads.mjs +89 -0
- package/src/cli/codex-visibility-smoke.mjs +196 -0
- package/src/cli/codex-vscode-restore-smoke.mjs +226 -0
- package/src/cli/codex-vscode-rollback-smoke.mjs +114 -0
- package/src/cli/doctor.mjs +503 -1
- package/src/cli/doctor.test.mjs +542 -3
- package/src/cli/handoff-preview.mjs +78 -0
- package/src/cli/help.test.mjs +64 -0
- package/src/cli/install.mjs +226 -3
- package/src/cli/install.test.mjs +205 -4
- package/src/cli/trim.mjs +564 -0
- package/src/codex-app-server.mjs +1816 -0
- package/src/codex-app-server.test.mjs +512 -0
- package/src/codex-auto-refresh.mjs +194 -0
- package/src/codex-auto-refresh.test.mjs +182 -0
- package/src/codex-capture.mjs +235 -0
- package/src/codex-capture.test.mjs +393 -0
- package/src/codex-handoff-model-smoke.mjs +114 -0
- package/src/codex-handoff-model-smoke.test.mjs +89 -0
- package/src/codex-handoff-smoke.mjs +124 -0
- package/src/codex-handoff-smoke.test.mjs +103 -0
- package/src/codex-handoff.mjs +331 -0
- package/src/codex-handoff.test.mjs +220 -0
- package/src/codex-host-primitive-audit.mjs +374 -0
- package/src/codex-host-primitive-audit.test.mjs +208 -0
- package/src/codex-restore-smoke.test.mjs +639 -0
- package/src/codex-restore-source-audit.mjs +1348 -0
- package/src/codex-restore-source-audit.test.mjs +623 -0
- package/src/codex-resume.test.mjs +242 -0
- package/src/codex-rollout-memory.mjs +711 -0
- package/src/codex-rollout-memory.test.mjs +610 -0
- package/src/codex-sidecar-cli.test.mjs +75 -0
- package/src/codex-sidecar.mjs +246 -0
- package/src/codex-sidecar.test.mjs +172 -0
- package/src/codex-summarize.test.mjs +143 -0
- package/src/codex-thread-identity.mjs +23 -0
- package/src/codex-thread-index.mjs +173 -0
- package/src/codex-thread-index.test.mjs +164 -0
- package/src/codex-usage.mjs +110 -0
- package/src/codex-usage.test.mjs +140 -0
- package/src/codex-visibility-smoke.test.mjs +222 -0
- package/src/codex-vscode-restore-smoke.mjs +206 -0
- package/src/codex-vscode-restore-smoke.test.mjs +325 -0
- package/src/codex-vscode-rollback-smoke.mjs +90 -0
- package/src/codex-vscode-rollback-smoke.test.mjs +290 -0
- package/src/db-schema.test.mjs +96 -0
- package/src/db.mjs +14 -1
- package/src/haiku-summarizer.mjs +267 -26
- package/src/haiku-summarizer.test.mjs +282 -0
- package/src/handoff-preview.test.mjs +108 -0
- package/src/handoff-record.mjs +294 -0
- package/src/handoff-record.test.mjs +226 -0
- package/src/hook-entrypoints.test.mjs +286 -0
- package/src/package-files.test.mjs +19 -0
- package/src/prompt-submit.mjs +9 -6
- package/src/resume-context.mjs +58 -171
- package/src/resume-context.test.mjs +177 -0
- package/src/session-start.mjs +85 -26
- package/src/state-file.mjs +50 -6
- package/src/state-file.test.mjs +50 -0
- package/src/token-monitor.mjs +14 -10
- package/src/token-monitor.test.mjs +27 -0
- package/src/trim-cli.test.mjs +1584 -0
- package/src/trim-model.mjs +584 -0
- package/src/trim-model.test.mjs +568 -0
- package/src/turn-processor.mjs +17 -10
- package/src/vscode-task.mjs +33 -10
- package/src/vscode-task.test.mjs +19 -9
- package/src/cli/save-inflight.mjs +0 -81
package/src/resume-context.mjs
CHANGED
|
@@ -1,226 +1,113 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* resume-context.mjs —
|
|
2
|
+
* resume-context.mjs — 引継ぎ注入テキストを組み立てる共有モジュール
|
|
3
3
|
*
|
|
4
4
|
* 呼び出し元:
|
|
5
|
-
* - session-start.mjs (
|
|
5
|
+
* - session-start.mjs (auto path / baton path どちらでも同じ注入)
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
* - 注入順: ヘッダ
|
|
9
|
-
* - in-flight メモ: /tl 発動時に現行 Claude が書いた「次の一手 / 方針 / 未解決 / TODO」
|
|
10
|
-
* - 中断直前の思考: 最終ターンの assistant extended thinking (details kind='thinking')
|
|
7
|
+
* 設計 (docs/THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md):
|
|
8
|
+
* - 注入順: ヘッダ + Reading Contract → L1 要約 → L2 本文 → L3 refs 一覧 → Continuation Instruction
|
|
11
9
|
* - 直近 N=20 ターンは bodies から L2 全文を注入
|
|
12
10
|
* - それ以前は skeletons から L1 要約のみ注入
|
|
11
|
+
* - L3 references は具体的な `throughline detail <時刻>` コマンドの一覧として注入
|
|
12
|
+
* (本文は埋め込まず、参照のみ)
|
|
13
|
+
* - memo / thinking は注入しない (= L2 全文があれば最後の assistant turn 自体に
|
|
14
|
+
* 「次に何をしようとしていたか」が含まれる)
|
|
13
15
|
* - 各行頭に [HH:MM:SS] 時刻プレフィックス(created_at ベース、DB 永続)
|
|
14
|
-
* - 末尾に /sc-detail <時刻> ガイドを追記
|
|
15
16
|
* - 現セッションのターンは注入しない(Claude Code 本体のコンテキストに既にあるため)
|
|
16
|
-
* -
|
|
17
|
+
* - フレーミング: 「過去の記憶」ではなく「現在進行中の作業」として読ませる
|
|
18
|
+
* (Codex 側 renderCodexRolloutMemoryPreview の写像)
|
|
17
19
|
*/
|
|
18
20
|
|
|
19
|
-
|
|
21
|
+
import { buildHandoffRecord, formatTime, N_RECENT_L2 } from './handoff-record.mjs';
|
|
20
22
|
|
|
21
23
|
const RESUME_HEADER_TEMPLATE = (turnCount) =>
|
|
22
24
|
`## Throughline: 中断した作業の再開(${turnCount} ターン分の文脈を保持)\n` +
|
|
23
25
|
`\n` +
|
|
24
26
|
`**前セッションで進行中だった作業を、この新セッションで引き継いでいます。以下が中断時点の状態です:**\n` +
|
|
25
|
-
`- 中断直前の in-flight メモ(前セッション末尾で Claude 自身が書いた「次の一手・方針・未解決・TODO」)\n` +
|
|
26
|
-
`- 中断直前の思考 (最終ターンの extended thinking)\n` +
|
|
27
27
|
`- 直近 ${N_RECENT_L2} ターンの会話本文 (L2)\n` +
|
|
28
28
|
`- それ以前の要約 (L1)\n` +
|
|
29
|
+
`- L3 (ツール入出力・思考) の参照一覧 (本文は別途取り出す)\n` +
|
|
29
30
|
`\n` +
|
|
30
31
|
`応答の冒頭でユーザーに「前の作業を ${turnCount} ターン分引き継ぎました」と報告してください。` +
|
|
31
32
|
`作業方針は前セッションのものを踏襲し、中断地点から自然に続行してください。`;
|
|
32
33
|
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
'- 単一時刻: `throughline detail 14:23:05`\n' +
|
|
41
|
-
'- 時刻範囲: `throughline detail 14:23-14:30`\n' +
|
|
42
|
-
'\n' +
|
|
43
|
-
'返る内容: 指定ターンの L2 会話本文 + L3(tool_input / tool_output / system / thinking 別にグループ化)。\n' +
|
|
44
|
-
'ユーザーに「詳細を見せて」と言われた時だけでなく、**ユーザー発言の文脈が過去ターンに依存しているのに L1/L2 だけでは情報不足だと判断した時**に、Claude 自身の判断で呼び出して構いません。';
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Unix ms を HH:MM:SS 形式に変換する。
|
|
48
|
-
*/
|
|
49
|
-
function formatTime(unixMs) {
|
|
50
|
-
const d = new Date(unixMs);
|
|
51
|
-
const hh = String(d.getHours()).padStart(2, '0');
|
|
52
|
-
const mm = String(d.getMinutes()).padStart(2, '0');
|
|
53
|
-
const ss = String(d.getSeconds()).padStart(2, '0');
|
|
54
|
-
return `${hh}:${mm}:${ss}`;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* 最新ターン番号 (= 中断直前) の thinking ブロックを details から取り出す。
|
|
59
|
-
* origin 除外がある場合はそれも考慮する。
|
|
60
|
-
*
|
|
61
|
-
* @param {import('node:sqlite').DatabaseSync} db
|
|
62
|
-
* @param {string} sessionId
|
|
63
|
-
* @param {string | null} excludeOriginId
|
|
64
|
-
* @returns {Array<{ output_text: string, created_at: number }>}
|
|
65
|
-
*/
|
|
66
|
-
function loadLatestThinking(db, sessionId, excludeOriginId) {
|
|
67
|
-
const hasExclude = Boolean(excludeOriginId);
|
|
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 <時刻>\` を使って取得してください。`;
|
|
68
41
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
WHERE session_id = ? AND origin_session_id != ? AND role = 'assistant'
|
|
74
|
-
ORDER BY created_at DESC
|
|
75
|
-
LIMIT 1`
|
|
76
|
-
: `SELECT origin_session_id, turn_number, created_at
|
|
77
|
-
FROM bodies
|
|
78
|
-
WHERE session_id = ? AND role = 'assistant'
|
|
79
|
-
ORDER BY created_at DESC
|
|
80
|
-
LIMIT 1`;
|
|
42
|
+
const CONTINUATION_REMINDER =
|
|
43
|
+
'**再開指示:** 上記の L1 / L2 を、現在タスクに使う作業コンテキストとして扱ってください。' +
|
|
44
|
+
'最新の L2 から次の一手を決め、中断地点から続行してください。' +
|
|
45
|
+
'古い仮説や未完了 TODO は、後続の判断で上書きされ得ます。';
|
|
81
46
|
|
|
82
|
-
|
|
83
|
-
try {
|
|
84
|
-
latest = hasExclude
|
|
85
|
-
? db.prepare(latestQuery).get(sessionId, excludeOriginId)
|
|
86
|
-
: db.prepare(latestQuery).get(sessionId);
|
|
87
|
-
} catch {
|
|
88
|
-
return [];
|
|
89
|
-
}
|
|
90
|
-
if (!latest) return [];
|
|
91
|
-
|
|
92
|
-
// その (origin_session_id, turn_number) に紐づく kind='thinking' を取り出す
|
|
93
|
-
try {
|
|
94
|
-
const rows = db
|
|
95
|
-
.prepare(
|
|
96
|
-
`SELECT output_text, created_at FROM details
|
|
97
|
-
WHERE session_id = ? AND origin_session_id = ? AND turn_number = ? AND kind = 'thinking'
|
|
98
|
-
ORDER BY created_at ASC`,
|
|
99
|
-
)
|
|
100
|
-
.all(sessionId, latest.origin_session_id, latest.turn_number);
|
|
101
|
-
return rows.filter((r) => typeof r.output_text === 'string' && r.output_text.length > 0);
|
|
102
|
-
} catch {
|
|
103
|
-
return [];
|
|
104
|
-
}
|
|
105
|
-
}
|
|
47
|
+
const NORMAL_HEADER = '## Throughline: セッション記憶';
|
|
106
48
|
|
|
107
49
|
/**
|
|
108
|
-
* L1+L2 注入テキストを組み立てる。
|
|
50
|
+
* L1 + L2 + L3 references 注入テキストを組み立てる。
|
|
109
51
|
*
|
|
110
52
|
* @param {import('node:sqlite').DatabaseSync} db
|
|
111
53
|
* @param {{
|
|
112
54
|
* sessionId: string,
|
|
113
55
|
* isInheritance: boolean,
|
|
114
56
|
* excludeOriginId?: string | null,
|
|
115
|
-
* inflightMemo?: string | null,
|
|
57
|
+
* inflightMemo?: string | null, // 互換のため受け取るが新仕様では使用しない
|
|
116
58
|
* }} params
|
|
117
59
|
* @returns {string | null}
|
|
118
60
|
*/
|
|
119
61
|
export function buildResumeContext(
|
|
120
62
|
db,
|
|
121
|
-
{ sessionId, isInheritance, excludeOriginId = null, inflightMemo = null },
|
|
63
|
+
{ sessionId, isInheritance, excludeOriginId = null, inflightMemo: _ignoredMemo = null },
|
|
122
64
|
) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
ORDER BY created_at DESC
|
|
137
|
-
LIMIT ?`;
|
|
138
|
-
|
|
139
|
-
const limitRows = N_RECENT_L2 * 2; // user/assistant の 2 ロール分
|
|
140
|
-
|
|
141
|
-
let bodyRowsDesc = [];
|
|
142
|
-
try {
|
|
143
|
-
bodyRowsDesc = hasExclude
|
|
144
|
-
? db.prepare(bodiesQuery).all(sessionId, excludeOriginId, limitRows)
|
|
145
|
-
: db.prepare(bodiesQuery).all(sessionId, limitRows);
|
|
146
|
-
} catch {
|
|
147
|
-
// bodies テーブル未作成(v3 DB)の場合は空
|
|
148
|
-
bodyRowsDesc = [];
|
|
149
|
-
}
|
|
150
|
-
const bodyRows = bodyRowsDesc.reverse(); // ASC に戻す
|
|
151
|
-
|
|
152
|
-
// 古い側の L1(bodies に既に含まれるターンを除いたもの)
|
|
153
|
-
const bodySet = new Set(
|
|
154
|
-
bodyRows.map((r) => `${r.origin_session_id}\x00${r.turn_number}`),
|
|
155
|
-
);
|
|
156
|
-
|
|
157
|
-
const skelQuery = hasExclude
|
|
158
|
-
? `SELECT origin_session_id, turn_number, role, summary, created_at
|
|
159
|
-
FROM skeletons
|
|
160
|
-
WHERE session_id = ? AND origin_session_id != ?
|
|
161
|
-
ORDER BY created_at ASC`
|
|
162
|
-
: `SELECT origin_session_id, turn_number, role, summary, created_at
|
|
163
|
-
FROM skeletons
|
|
164
|
-
WHERE session_id = ?
|
|
165
|
-
ORDER BY created_at ASC`;
|
|
166
|
-
|
|
167
|
-
const allSkel = hasExclude
|
|
168
|
-
? db.prepare(skelQuery).all(sessionId, excludeOriginId)
|
|
169
|
-
: db.prepare(skelQuery).all(sessionId);
|
|
170
|
-
|
|
171
|
-
const l1Rows = allSkel.filter(
|
|
172
|
-
(s) => !bodySet.has(`${s.origin_session_id}\x00${s.turn_number}`),
|
|
173
|
-
);
|
|
174
|
-
|
|
175
|
-
const thinkingRows = loadLatestThinking(db, sessionId, excludeOriginId);
|
|
176
|
-
|
|
177
|
-
if (
|
|
178
|
-
bodyRows.length === 0 &&
|
|
179
|
-
l1Rows.length === 0 &&
|
|
180
|
-
thinkingRows.length === 0 &&
|
|
181
|
-
!inflightMemo
|
|
182
|
-
) {
|
|
183
|
-
return null;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const turnCount = bodyRows.length + l1Rows.length;
|
|
187
|
-
const header = isInheritance ? RESUME_HEADER_TEMPLATE(turnCount) : NORMAL_HEADER;
|
|
65
|
+
// handoff-record は Codex 側でも使うので signature 維持。inflightMemo / latestThinking は
|
|
66
|
+
// 新仕様の注入テキストには使わない (L2 全文で十分という判断)。
|
|
67
|
+
const record = buildHandoffRecord(db, {
|
|
68
|
+
sessionId,
|
|
69
|
+
isInheritance,
|
|
70
|
+
excludeOriginId,
|
|
71
|
+
});
|
|
72
|
+
if (!record) return null;
|
|
73
|
+
|
|
74
|
+
const turnCount = record.stats.preservedContextRows;
|
|
75
|
+
const header = isInheritance
|
|
76
|
+
? RESUME_HEADER_TEMPLATE(turnCount) + ACTIVE_WORK_READING_CONTRACT
|
|
77
|
+
: NORMAL_HEADER;
|
|
188
78
|
const lines = [header];
|
|
189
79
|
|
|
190
|
-
if (
|
|
80
|
+
if (record.memory.l1Summaries.length > 0) {
|
|
191
81
|
lines.push('');
|
|
192
|
-
lines.push('###
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
if (thinkingRows.length > 0) {
|
|
197
|
-
lines.push('');
|
|
198
|
-
lines.push('### 中断直前の思考 (最終ターンの extended thinking)');
|
|
199
|
-
for (const r of thinkingRows) {
|
|
200
|
-
lines.push(`[${formatTime(r.created_at)}] ${r.output_text}`);
|
|
82
|
+
lines.push('### それ以前の要約 (L1)');
|
|
83
|
+
for (const r of record.memory.l1Summaries) {
|
|
84
|
+
if (!r.summary || r.summary === '(no content)') continue;
|
|
85
|
+
lines.push(`[${r.time}] ${r.summary.replace(/\n+/g, ' ').trim()}`);
|
|
201
86
|
}
|
|
202
87
|
}
|
|
203
88
|
|
|
204
|
-
if (
|
|
89
|
+
if (record.memory.recentBodies.length > 0) {
|
|
205
90
|
lines.push('');
|
|
206
|
-
lines.push('###
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
91
|
+
lines.push('### 現在進行中の作業履歴 (L2 / active work thread)');
|
|
92
|
+
lines.push('以下は古い順です。後の行ほど現在状態に近く、前の仮説を上書きし得ます。');
|
|
93
|
+
for (const r of record.memory.recentBodies) {
|
|
94
|
+
if (!r.text) continue;
|
|
95
|
+
lines.push(`[${r.time}] [${r.role}]: ${r.text}`);
|
|
210
96
|
}
|
|
211
97
|
}
|
|
212
98
|
|
|
213
|
-
if (
|
|
99
|
+
if (record.references.l3.length > 0) {
|
|
214
100
|
lines.push('');
|
|
215
|
-
lines.push('###
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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}\``);
|
|
219
106
|
}
|
|
220
107
|
}
|
|
221
108
|
|
|
222
109
|
lines.push('');
|
|
223
|
-
lines.push(
|
|
110
|
+
lines.push(CONTINUATION_REMINDER);
|
|
224
111
|
|
|
225
112
|
return lines.join('\n');
|
|
226
113
|
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { buildResumeContext } from './resume-context.mjs';
|
|
5
|
+
|
|
6
|
+
function makeDb() {
|
|
7
|
+
const db = new DatabaseSync(':memory:');
|
|
8
|
+
db.exec(`
|
|
9
|
+
CREATE TABLE skeletons (
|
|
10
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
11
|
+
session_id TEXT NOT NULL,
|
|
12
|
+
origin_session_id TEXT,
|
|
13
|
+
turn_number INTEGER NOT NULL,
|
|
14
|
+
role TEXT NOT NULL,
|
|
15
|
+
summary TEXT NOT NULL,
|
|
16
|
+
created_at INTEGER NOT NULL
|
|
17
|
+
);
|
|
18
|
+
CREATE TABLE bodies (
|
|
19
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
20
|
+
session_id TEXT NOT NULL,
|
|
21
|
+
origin_session_id TEXT NOT NULL,
|
|
22
|
+
turn_number INTEGER NOT NULL,
|
|
23
|
+
role TEXT NOT NULL,
|
|
24
|
+
text TEXT NOT NULL,
|
|
25
|
+
token_count INTEGER,
|
|
26
|
+
created_at INTEGER NOT NULL
|
|
27
|
+
);
|
|
28
|
+
CREATE TABLE details (
|
|
29
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
30
|
+
session_id TEXT NOT NULL,
|
|
31
|
+
origin_session_id TEXT,
|
|
32
|
+
turn_number INTEGER,
|
|
33
|
+
tool_name TEXT NOT NULL,
|
|
34
|
+
input_text TEXT,
|
|
35
|
+
output_text TEXT,
|
|
36
|
+
token_count INTEGER NOT NULL DEFAULT 0,
|
|
37
|
+
created_at INTEGER NOT NULL,
|
|
38
|
+
kind TEXT,
|
|
39
|
+
source_id TEXT
|
|
40
|
+
);
|
|
41
|
+
`);
|
|
42
|
+
return db;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function insertSkeleton(db, row) {
|
|
46
|
+
db.prepare(
|
|
47
|
+
`INSERT INTO skeletons (session_id, origin_session_id, turn_number, role, summary, created_at)
|
|
48
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
49
|
+
).run(row.session, row.origin, row.turn, row.role, row.summary, row.createdAt);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function insertBody(db, row) {
|
|
53
|
+
db.prepare(
|
|
54
|
+
`INSERT INTO bodies (session_id, origin_session_id, turn_number, role, text, token_count, created_at)
|
|
55
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
56
|
+
).run(row.session, row.origin, row.turn, row.role, row.text, 1, row.createdAt);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function insertThinking(db, row) {
|
|
60
|
+
db.prepare(
|
|
61
|
+
`INSERT INTO details
|
|
62
|
+
(session_id, origin_session_id, turn_number, tool_name, input_text, output_text,
|
|
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);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
test('buildResumeContext: inheritance output order is L1 -> L2 -> L3 refs -> reminder (no memo / no thinking)', () => {
|
|
69
|
+
const db = makeDb();
|
|
70
|
+
|
|
71
|
+
insertSkeleton(db, {
|
|
72
|
+
session: 'new',
|
|
73
|
+
origin: 'old',
|
|
74
|
+
turn: 1,
|
|
75
|
+
role: 'assistant',
|
|
76
|
+
summary: 'older L1 summary',
|
|
77
|
+
createdAt: 1000,
|
|
78
|
+
});
|
|
79
|
+
insertBody(db, {
|
|
80
|
+
session: 'new',
|
|
81
|
+
origin: 'old',
|
|
82
|
+
turn: 2,
|
|
83
|
+
role: 'user',
|
|
84
|
+
text: 'recent user body',
|
|
85
|
+
createdAt: 2000,
|
|
86
|
+
});
|
|
87
|
+
insertBody(db, {
|
|
88
|
+
session: 'new',
|
|
89
|
+
origin: 'old',
|
|
90
|
+
turn: 2,
|
|
91
|
+
role: 'assistant',
|
|
92
|
+
text: 'recent assistant body',
|
|
93
|
+
createdAt: 2100,
|
|
94
|
+
});
|
|
95
|
+
// thinking は DB に書かれても、新仕様では注入テキストに出ない
|
|
96
|
+
insertThinking(db, {
|
|
97
|
+
session: 'new',
|
|
98
|
+
origin: 'old',
|
|
99
|
+
turn: 2,
|
|
100
|
+
text: 'latest thinking block',
|
|
101
|
+
createdAt: 2200,
|
|
102
|
+
sourceId: 'asst:thinking:0',
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const text = buildResumeContext(db, {
|
|
106
|
+
sessionId: 'new',
|
|
107
|
+
isInheritance: true,
|
|
108
|
+
// inflightMemo は互換のため受け取れるが、新仕様では注入テキストに使わない
|
|
109
|
+
inflightMemo: '**Next**: keep going (should NOT appear)',
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
assert.ok(text);
|
|
113
|
+
assert.match(text, /^## Throughline: 中断した作業の再開/);
|
|
114
|
+
|
|
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');
|
|
120
|
+
|
|
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('**再開指示:**');
|
|
126
|
+
|
|
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, /単なる過去ログではなく、現在進行中の作業/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('buildResumeContext: returns null when no memory rows or inflight memo exist', () => {
|
|
141
|
+
const db = makeDb();
|
|
142
|
+
assert.equal(
|
|
143
|
+
buildResumeContext(db, { sessionId: 'empty', isInheritance: true }),
|
|
144
|
+
null,
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('buildResumeContext: excludeOriginId omits rows from the current origin', () => {
|
|
149
|
+
const db = makeDb();
|
|
150
|
+
|
|
151
|
+
insertBody(db, {
|
|
152
|
+
session: 'new',
|
|
153
|
+
origin: 'old',
|
|
154
|
+
turn: 1,
|
|
155
|
+
role: 'assistant',
|
|
156
|
+
text: 'old origin body',
|
|
157
|
+
createdAt: 1000,
|
|
158
|
+
});
|
|
159
|
+
insertBody(db, {
|
|
160
|
+
session: 'new',
|
|
161
|
+
origin: 'new',
|
|
162
|
+
turn: 1,
|
|
163
|
+
role: 'assistant',
|
|
164
|
+
text: 'current origin body',
|
|
165
|
+
createdAt: 2000,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const text = buildResumeContext(db, {
|
|
169
|
+
sessionId: 'new',
|
|
170
|
+
isInheritance: false,
|
|
171
|
+
excludeOriginId: 'new',
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
assert.ok(text);
|
|
175
|
+
assert.ok(text.includes('old origin body'));
|
|
176
|
+
assert.ok(!text.includes('current origin body'));
|
|
177
|
+
});
|
package/src/session-start.mjs
CHANGED
|
@@ -1,20 +1,28 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* SessionStart hook — セッション登録 +
|
|
3
|
+
* SessionStart hook — セッション登録 + 引き継ぎ判定 + 注入
|
|
4
4
|
*
|
|
5
5
|
* stdin: { session_id, source, cwd, transcript_path, hook_event_name }
|
|
6
6
|
*
|
|
7
|
-
* 【引き継ぎ条件 (
|
|
8
|
-
*
|
|
9
|
-
* baton
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
* 【引き継ぎ条件 (2 経路)】 docs/THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md
|
|
8
|
+
*
|
|
9
|
+
* 1. baton path: ユーザーが旧セッションで `/tl` を打つと UserPromptSubmit hook が
|
|
10
|
+
* handoff_batons に session_id を書く。本 hook が TTL 1 時間以内に消費して
|
|
11
|
+
* 前任を merge + 引継ぎ stdout 注入。`source` 値関係なく発火。
|
|
12
|
+
* 2. auto path: `source='clear'` かつ env `THROUGHLINE_DISABLE_AUTO_HANDOFF` が
|
|
13
|
+
* `'1'` でない場合、同 project_path の最新 Claude unmerged session を
|
|
14
|
+
* 自動 merge して注入。
|
|
15
|
+
*
|
|
16
|
+
* 両方同時成立はしない (consumeBaton が先発、baton ありなら baton path、
|
|
17
|
+
* なければ source 判定)。env で OFF にしたユーザーは `/tl` を打ってから
|
|
18
|
+
* 新セッションスタートで baton path を使う。
|
|
12
19
|
*
|
|
13
20
|
* 役割:
|
|
14
21
|
* 1. sessions テーブルに新セッションを INSERT OR IGNORE
|
|
15
|
-
* 2.
|
|
16
|
-
* 3.
|
|
17
|
-
* 4.
|
|
22
|
+
* 2. baton path 判定 (consumeBaton + mergeSpecificPredecessor)
|
|
23
|
+
* 3. baton 無し かつ source='clear' かつ env disable 無し → auto path 判定
|
|
24
|
+
* 4. 合流成立なら curated memory (L1+L2+L3 refs) を「引き継ぎヘッダ」付きで stdout 注入
|
|
25
|
+
* 5. 判定結果を ~/.throughline/logs/inheritance-decision.log に記録
|
|
18
26
|
*/
|
|
19
27
|
|
|
20
28
|
import { getDb } from './db.mjs';
|
|
@@ -25,6 +33,38 @@ import { ensureMonitorTaskFile } from './vscode-task.mjs';
|
|
|
25
33
|
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
26
34
|
import { join, dirname } from 'node:path';
|
|
27
35
|
import { homedir } from 'node:os';
|
|
36
|
+
import { pathToFileURL } from 'node:url';
|
|
37
|
+
|
|
38
|
+
const ENV_DISABLE_AUTO_HANDOFF = 'THROUGHLINE_DISABLE_AUTO_HANDOFF';
|
|
39
|
+
|
|
40
|
+
function isAutoHandoffDisabled(env) {
|
|
41
|
+
return env[ENV_DISABLE_AUTO_HANDOFF] === '1';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 同 project_path の最新 Claude unmerged session を返す (auto path 用 predecessor)。
|
|
46
|
+
* Codex session (`codex:*`) と現セッション自身は除外。
|
|
47
|
+
*
|
|
48
|
+
* @param {import('node:sqlite').DatabaseSync} db
|
|
49
|
+
* @param {string} projectPath
|
|
50
|
+
* @param {string} currentSessionId
|
|
51
|
+
* @returns {{ session_id: string } | null}
|
|
52
|
+
*/
|
|
53
|
+
function findLatestClaudePredecessor(db, projectPath, currentSessionId) {
|
|
54
|
+
return (
|
|
55
|
+
db
|
|
56
|
+
.prepare(
|
|
57
|
+
`SELECT session_id FROM sessions
|
|
58
|
+
WHERE lower(project_path) = lower(?)
|
|
59
|
+
AND merged_into IS NULL
|
|
60
|
+
AND session_id != ?
|
|
61
|
+
AND session_id NOT LIKE 'codex:%'
|
|
62
|
+
ORDER BY updated_at DESC
|
|
63
|
+
LIMIT 1`,
|
|
64
|
+
)
|
|
65
|
+
.get(projectPath, currentSessionId) ?? null
|
|
66
|
+
);
|
|
67
|
+
}
|
|
28
68
|
|
|
29
69
|
function logDecision(entry) {
|
|
30
70
|
const path = join(homedir(), '.throughline', 'logs', 'inheritance-decision.log');
|
|
@@ -37,7 +77,7 @@ function logDecision(entry) {
|
|
|
37
77
|
}
|
|
38
78
|
}
|
|
39
79
|
|
|
40
|
-
async function
|
|
80
|
+
export async function run() {
|
|
41
81
|
let raw = '';
|
|
42
82
|
await new Promise((resolve) => {
|
|
43
83
|
process.stdin.setEncoding('utf8');
|
|
@@ -56,11 +96,7 @@ async function main() {
|
|
|
56
96
|
const db = getDb();
|
|
57
97
|
const now = Date.now();
|
|
58
98
|
|
|
59
|
-
// 0. VSCode
|
|
60
|
-
// Stop hook 側にも同じ呼び出しがあるが、Stop が発火しない環境(応答中断・IDE 挙動差)
|
|
61
|
-
// でも SessionStart は必ず走るので、新規プロジェクトでの自動起動を確実化する保険。
|
|
62
|
-
// 冪等性は ensureMonitorTaskFile 側で保証されており、Stop/UserPromptSubmit と重複呼び
|
|
63
|
-
// 出しされても安全。
|
|
99
|
+
// 0. VSCode 用 tasks.json 自動プロビジョニング (冪等)
|
|
64
100
|
try {
|
|
65
101
|
ensureMonitorTaskFile({ cwd: projectPath, env: process.env });
|
|
66
102
|
} catch (err) {
|
|
@@ -74,18 +110,40 @@ async function main() {
|
|
|
74
110
|
VALUES (?, ?, 'active', ?, ?)`,
|
|
75
111
|
).run(session_id, projectPath, now, now);
|
|
76
112
|
|
|
77
|
-
// 2.
|
|
113
|
+
// 2. baton 消費
|
|
78
114
|
const baton = consumeBaton(db, { projectPath, now });
|
|
79
115
|
|
|
80
|
-
|
|
116
|
+
// 3. 引継ぎ判定
|
|
117
|
+
let mergeResult = { merged: false, skipReason: 'no_trigger' };
|
|
118
|
+
let triggeredPath = null;
|
|
119
|
+
const autoDisabled = isAutoHandoffDisabled(process.env);
|
|
120
|
+
|
|
81
121
|
if (baton.sessionId) {
|
|
82
|
-
//
|
|
122
|
+
// baton path
|
|
123
|
+
triggeredPath = 'baton';
|
|
83
124
|
const { target: predecessorId } = resolveMergeTarget(db, baton.sessionId);
|
|
84
125
|
mergeResult = mergeSpecificPredecessor(db, {
|
|
85
126
|
newSessionId: session_id,
|
|
86
127
|
predecessorId,
|
|
87
128
|
now,
|
|
88
129
|
});
|
|
130
|
+
} else if (source === 'clear' && !autoDisabled) {
|
|
131
|
+
// auto path: 同 project の最新 Claude unmerged session を自動 predecessor にする
|
|
132
|
+
triggeredPath = 'auto';
|
|
133
|
+
const predRow = findLatestClaudePredecessor(db, projectPath, session_id);
|
|
134
|
+
if (predRow?.session_id) {
|
|
135
|
+
const { target: predecessorId } = resolveMergeTarget(db, predRow.session_id);
|
|
136
|
+
mergeResult = mergeSpecificPredecessor(db, {
|
|
137
|
+
newSessionId: session_id,
|
|
138
|
+
predecessorId,
|
|
139
|
+
now,
|
|
140
|
+
});
|
|
141
|
+
} else {
|
|
142
|
+
mergeResult = { merged: false, skipReason: 'no_predecessor' };
|
|
143
|
+
}
|
|
144
|
+
} else if (source === 'clear' && autoDisabled) {
|
|
145
|
+
triggeredPath = 'auto-disabled';
|
|
146
|
+
mergeResult = { merged: false, skipReason: 'auto_handoff_disabled' };
|
|
89
147
|
}
|
|
90
148
|
|
|
91
149
|
logDecision({
|
|
@@ -93,22 +151,21 @@ async function main() {
|
|
|
93
151
|
source: source ?? null,
|
|
94
152
|
session_id,
|
|
95
153
|
project_path: projectPath,
|
|
154
|
+
triggered_path: triggeredPath,
|
|
155
|
+
auto_handoff_disabled: autoDisabled,
|
|
96
156
|
baton_session_id: baton.sessionId ?? null,
|
|
97
157
|
baton_age_ms: baton.ageMs ?? null,
|
|
98
158
|
baton_skip_reason: baton.skipReason ?? null,
|
|
99
|
-
baton_has_memo: Boolean(baton.memoText),
|
|
100
159
|
merged: mergeResult.merged,
|
|
101
160
|
merge_skip_reason: mergeResult.skipReason ?? null,
|
|
102
161
|
predecessor_id: mergeResult.predecessorId ?? null,
|
|
103
162
|
});
|
|
104
163
|
|
|
105
|
-
//
|
|
106
|
-
// バトンに付いていた in-flight メモも併せて先頭セクションに注入する
|
|
164
|
+
// 4. 合流成立なら curated memory を stdout 注入 (L1 + L2 + L3 refs)
|
|
107
165
|
if (mergeResult.merged) {
|
|
108
166
|
const text = buildResumeContext(db, {
|
|
109
167
|
sessionId: session_id,
|
|
110
168
|
isInheritance: true,
|
|
111
|
-
inflightMemo: baton.memoText ?? null,
|
|
112
169
|
});
|
|
113
170
|
if (text) {
|
|
114
171
|
process.stdout.write(text + '\n');
|
|
@@ -118,7 +175,9 @@ async function main() {
|
|
|
118
175
|
process.exit(0);
|
|
119
176
|
}
|
|
120
177
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
178
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
179
|
+
run().catch((err) => {
|
|
180
|
+
process.stderr.write(`[session-start] error: ${err.message}\n`);
|
|
181
|
+
process.exit(1);
|
|
182
|
+
});
|
|
183
|
+
}
|