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.
@@ -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
- * 【実機確認 (2026-04-15)】
8
- * SessionStart /clear 後も source="startup" で発火する。
9
- * (Windows + VSCode 拡張では source="clear" は来ないが hook 自体は発火)
10
- * source に依存せず、毎回「前任の張り替え候補」を探して合流させる。
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. 同プロジェクト内の最新非合流セッションを新セッションに張り替え (session-merger)
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 { mergePredecessorInto } from './session-merger.mjs';
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 mergeResult = mergePredecessorInto(db, {
49
- newSessionId: session_id,
50
- projectPath,
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, { sessionId: session_id, isInheritance: true });
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/user の thinking ブロック → 破棄
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 (const b of blocks) {
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 / thinking は扱わない
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 / text ブロックは L3 に入れない', () => {
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, 0);
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 として抽出', () => {