throughline 0.1.0 → 0.3.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/README.md +91 -33
- package/bin/throughline.mjs +15 -7
- package/package.json +1 -1
- package/src/baton.mjs +121 -0
- package/src/baton.test.mjs +144 -0
- package/src/cli/install.mjs +7 -2
- package/src/cli/save-inflight.mjs +81 -0
- package/src/constants.mjs +1 -0
- package/src/db.mjs +26 -1
- package/src/prompt-submit.mjs +87 -0
- package/src/resume-context.mjs +99 -21
- package/src/session-merger.mjs +38 -29
- package/src/session-merger.test.mjs +72 -41
- package/src/session-start.mjs +57 -13
- package/src/transcript-reader.mjs +14 -3
- package/src/transcript-reader.test.mjs +61 -3
package/src/session-start.mjs
CHANGED
|
@@ -1,23 +1,40 @@
|
|
|
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
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* 【引き継ぎ条件 (バトン方式)】
|
|
8
|
+
* ユーザーが旧セッションで /tl スラッシュコマンドを打つと UserPromptSubmit hook が
|
|
9
|
+
* baton テーブルに session_id を書き込む。本 SessionStart hook はそれを TTL 1 時間以内
|
|
10
|
+
* なら消費して merge + 引き継ぎヘッダ付き L1+L2 を stdout 注入する。
|
|
11
|
+
* バトンが無ければ / 期限切れなら何も引き継がない(docs/INHERITANCE_ON_CLEAR_ONLY.md 参照)。
|
|
11
12
|
*
|
|
12
13
|
* 役割:
|
|
13
14
|
* 1. sessions テーブルに新セッションを INSERT OR IGNORE
|
|
14
|
-
* 2.
|
|
15
|
+
* 2. バトン消費 + 指名された前任を merge (session-merger.mjs)
|
|
15
16
|
* 3. 合流成立なら L1+L2 を「引き継ぎヘッダ」付きで stdout 注入
|
|
17
|
+
* 4. 判定結果を ~/.throughline/logs/inheritance-decision.log に記録
|
|
16
18
|
*/
|
|
17
19
|
|
|
18
20
|
import { getDb } from './db.mjs';
|
|
19
|
-
import {
|
|
21
|
+
import { consumeBaton } from './baton.mjs';
|
|
22
|
+
import { mergeSpecificPredecessor, resolveMergeTarget } from './session-merger.mjs';
|
|
20
23
|
import { buildResumeContext } from './resume-context.mjs';
|
|
24
|
+
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
25
|
+
import { join, dirname } from 'node:path';
|
|
26
|
+
import { homedir } from 'node:os';
|
|
27
|
+
|
|
28
|
+
function logDecision(entry) {
|
|
29
|
+
const path = join(homedir(), '.throughline', 'logs', 'inheritance-decision.log');
|
|
30
|
+
try {
|
|
31
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
32
|
+
appendFileSync(path, JSON.stringify(entry) + '\n', 'utf8');
|
|
33
|
+
} catch (err) {
|
|
34
|
+
const msg = err instanceof Error ? err.message : 'unknown';
|
|
35
|
+
process.stderr.write(`[session-start:decision-log] ${msg}\n`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
21
38
|
|
|
22
39
|
async function main() {
|
|
23
40
|
let raw = '';
|
|
@@ -30,7 +47,7 @@ async function main() {
|
|
|
30
47
|
});
|
|
31
48
|
|
|
32
49
|
const payload = JSON.parse(raw);
|
|
33
|
-
const { session_id, cwd } = payload;
|
|
50
|
+
const { session_id, cwd, source } = payload;
|
|
34
51
|
|
|
35
52
|
if (!session_id) throw new Error('Missing session_id in SessionStart payload');
|
|
36
53
|
|
|
@@ -44,15 +61,42 @@ async function main() {
|
|
|
44
61
|
VALUES (?, ?, 'active', ?, ?)`,
|
|
45
62
|
).run(session_id, projectPath, now, now);
|
|
46
63
|
|
|
47
|
-
// 2.
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
64
|
+
// 2. バトン消費
|
|
65
|
+
const baton = consumeBaton(db, { projectPath, now });
|
|
66
|
+
|
|
67
|
+
let mergeResult = { merged: false, skipReason: 'no_baton' };
|
|
68
|
+
if (baton.sessionId) {
|
|
69
|
+
// バトンが指す session が既に他と merge 済みなら、その合流先末端を前任とする
|
|
70
|
+
const { target: predecessorId } = resolveMergeTarget(db, baton.sessionId);
|
|
71
|
+
mergeResult = mergeSpecificPredecessor(db, {
|
|
72
|
+
newSessionId: session_id,
|
|
73
|
+
predecessorId,
|
|
74
|
+
now,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
logDecision({
|
|
79
|
+
ts: new Date(now).toISOString(),
|
|
80
|
+
source: source ?? null,
|
|
81
|
+
session_id,
|
|
82
|
+
project_path: projectPath,
|
|
83
|
+
baton_session_id: baton.sessionId ?? null,
|
|
84
|
+
baton_age_ms: baton.ageMs ?? null,
|
|
85
|
+
baton_skip_reason: baton.skipReason ?? null,
|
|
86
|
+
baton_has_memo: Boolean(baton.memoText),
|
|
87
|
+
merged: mergeResult.merged,
|
|
88
|
+
merge_skip_reason: mergeResult.skipReason ?? null,
|
|
89
|
+
predecessor_id: mergeResult.predecessorId ?? null,
|
|
51
90
|
});
|
|
52
91
|
|
|
53
92
|
// 3. 合流成立なら引き継ぎヘッダ付きで注入
|
|
93
|
+
// バトンに付いていた in-flight メモも併せて先頭セクションに注入する
|
|
54
94
|
if (mergeResult.merged) {
|
|
55
|
-
const text = buildResumeContext(db, {
|
|
95
|
+
const text = buildResumeContext(db, {
|
|
96
|
+
sessionId: session_id,
|
|
97
|
+
isInheritance: true,
|
|
98
|
+
inflightMemo: baton.memoText ?? null,
|
|
99
|
+
});
|
|
56
100
|
if (text) {
|
|
57
101
|
process.stdout.write(text + '\n');
|
|
58
102
|
}
|
|
@@ -197,7 +197,7 @@ export function sliceCurrentTurnEntries(entries) {
|
|
|
197
197
|
* 分類ルール:
|
|
198
198
|
* - assistant の tool_use ブロック → tool_input (name, input を JSON 化して input_text に)
|
|
199
199
|
* - user の tool_result ブロック → tool_output (content を output_text に、ANSI 剥離)
|
|
200
|
-
* - assistant
|
|
200
|
+
* - assistant の thinking ブロック → thinking (b.thinking を output_text に)
|
|
201
201
|
* - assistant/user の text ブロック → 扱わない(L2 bodies 側の責務)
|
|
202
202
|
* - attachment entry (hook_success) → system (hookName + content を出力に)
|
|
203
203
|
* - system entry (stop_hook_summary) → skip(hook タイミング情報で意味なし)
|
|
@@ -215,7 +215,8 @@ export function extractDetailBlocks(turnEntries) {
|
|
|
215
215
|
if (e.type === 'assistant') {
|
|
216
216
|
const blocks = e.message?.content;
|
|
217
217
|
if (!Array.isArray(blocks)) continue;
|
|
218
|
-
for (
|
|
218
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
219
|
+
const b = blocks[i];
|
|
219
220
|
if (!b || !b.type) continue;
|
|
220
221
|
if (b.type === 'tool_use' && typeof b.id === 'string') {
|
|
221
222
|
toolNameById.set(b.id, b.name ?? 'unknown');
|
|
@@ -226,6 +227,16 @@ export function extractDetailBlocks(turnEntries) {
|
|
|
226
227
|
input_text: JSON.stringify(b.input ?? null),
|
|
227
228
|
output_text: null,
|
|
228
229
|
});
|
|
230
|
+
} else if (b.type === 'thinking' && typeof b.thinking === 'string') {
|
|
231
|
+
// 固有 id が無いため entry uuid + block index で冪等キーを合成
|
|
232
|
+
const sourceId = e.uuid ? `${e.uuid}:thinking:${i}` : null;
|
|
233
|
+
out.push({
|
|
234
|
+
kind: DETAIL_KIND.THINKING,
|
|
235
|
+
tool_name: 'thinking',
|
|
236
|
+
source_id: sourceId,
|
|
237
|
+
input_text: null,
|
|
238
|
+
output_text: b.thinking,
|
|
239
|
+
});
|
|
229
240
|
} else if (b.type === 'image') {
|
|
230
241
|
out.push({
|
|
231
242
|
kind: DETAIL_KIND.IMAGE,
|
|
@@ -235,7 +246,7 @@ export function extractDetailBlocks(turnEntries) {
|
|
|
235
246
|
output_text: '[image]',
|
|
236
247
|
});
|
|
237
248
|
}
|
|
238
|
-
// text
|
|
249
|
+
// text は扱わない
|
|
239
250
|
}
|
|
240
251
|
} else if (e.type === 'user') {
|
|
241
252
|
const blocks = e.message?.content;
|
|
@@ -114,22 +114,80 @@ test('extractDetailBlocks: tool_use と tool_result をペアで抽出', () => {
|
|
|
114
114
|
assert.equal(output.output_text, 'hi\n');
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
-
test('extractDetailBlocks: thinking
|
|
117
|
+
test('extractDetailBlocks: assistant の thinking ブロックを kind=thinking で抽出、text は無視', () => {
|
|
118
118
|
const entries = [
|
|
119
119
|
userEntry('prompt'),
|
|
120
120
|
{
|
|
121
121
|
type: 'assistant',
|
|
122
|
+
uuid: 'asst-1',
|
|
122
123
|
message: {
|
|
123
124
|
role: 'assistant',
|
|
124
125
|
content: [
|
|
125
|
-
{ type: 'thinking', thinking: 'internal thoughts' },
|
|
126
|
+
{ type: 'thinking', thinking: 'internal thoughts', signature: 'sig' },
|
|
126
127
|
{ type: 'text', text: 'response' },
|
|
127
128
|
],
|
|
128
129
|
},
|
|
129
130
|
},
|
|
130
131
|
];
|
|
131
132
|
const details = extractDetailBlocks(entries);
|
|
132
|
-
assert.equal(details.length,
|
|
133
|
+
assert.equal(details.length, 1);
|
|
134
|
+
assert.equal(details[0].kind, DETAIL_KIND.THINKING);
|
|
135
|
+
assert.equal(details[0].tool_name, 'thinking');
|
|
136
|
+
assert.equal(details[0].source_id, 'asst-1:thinking:0');
|
|
137
|
+
assert.equal(details[0].input_text, null);
|
|
138
|
+
assert.equal(details[0].output_text, 'internal thoughts');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('extractDetailBlocks: 同 entry 内で thinking + tool_use + image が混在しても全て抽出', () => {
|
|
142
|
+
const entries = [
|
|
143
|
+
userEntry('prompt'),
|
|
144
|
+
{
|
|
145
|
+
type: 'assistant',
|
|
146
|
+
uuid: 'asst-2',
|
|
147
|
+
message: {
|
|
148
|
+
role: 'assistant',
|
|
149
|
+
content: [
|
|
150
|
+
{ type: 'thinking', thinking: 'first thought' },
|
|
151
|
+
{ type: 'tool_use', id: 'toolu_1', name: 'Read', input: { path: '/x' } },
|
|
152
|
+
{ type: 'thinking', thinking: 'second thought' },
|
|
153
|
+
{ type: 'image', source: {} },
|
|
154
|
+
{ type: 'text', text: 'done' },
|
|
155
|
+
],
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
asstTextEntry('wrap'),
|
|
159
|
+
];
|
|
160
|
+
const details = extractDetailBlocks(entries);
|
|
161
|
+
// thinking x2, tool_input x1, image x1 = 4
|
|
162
|
+
assert.equal(details.length, 4);
|
|
163
|
+
const thinkings = details.filter((d) => d.kind === DETAIL_KIND.THINKING);
|
|
164
|
+
assert.equal(thinkings.length, 2);
|
|
165
|
+
assert.equal(thinkings[0].source_id, 'asst-2:thinking:0');
|
|
166
|
+
assert.equal(thinkings[1].source_id, 'asst-2:thinking:2');
|
|
167
|
+
assert.equal(thinkings[0].output_text, 'first thought');
|
|
168
|
+
assert.equal(thinkings[1].output_text, 'second thought');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('extractDetailBlocks: thinking エントリに uuid が無くても source_id=null で通過する', () => {
|
|
172
|
+
const entries = [
|
|
173
|
+
userEntry('prompt'),
|
|
174
|
+
{
|
|
175
|
+
type: 'assistant',
|
|
176
|
+
// uuid 欠損
|
|
177
|
+
message: {
|
|
178
|
+
role: 'assistant',
|
|
179
|
+
content: [
|
|
180
|
+
{ type: 'thinking', thinking: 'thought without uuid' },
|
|
181
|
+
{ type: 'text', text: 'reply' },
|
|
182
|
+
],
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
];
|
|
186
|
+
const details = extractDetailBlocks(entries);
|
|
187
|
+
const thinking = details.find((d) => d.kind === DETAIL_KIND.THINKING);
|
|
188
|
+
assert.ok(thinking);
|
|
189
|
+
assert.equal(thinking.source_id, null);
|
|
190
|
+
assert.equal(thinking.output_text, 'thought without uuid');
|
|
133
191
|
});
|
|
134
192
|
|
|
135
193
|
test('extractDetailBlocks: attachment (hook_success) を system として抽出', () => {
|