throughline 0.4.11 → 0.5.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/CHANGELOG.md +89 -0
- package/README.md +9 -2
- package/docs/RAG/INDEX.md +160 -0
- package/docs/RAG/_raw/01-hooks/hooks-reference-extract.md +250 -0
- package/docs/RAG/_raw/02-messages-api/messages-api-extract.md +126 -0
- package/docs/RAG/_raw/03-settings/sessions-extract.md +64 -0
- package/docs/RAG/_raw/04-skills/initialUserMessage-investigation.md +101 -0
- package/docs/THROUGHLINE_CLEAR_AUTO_HANDOFF_PLAN.md +7 -4
- package/docs/THROUGHLINE_TRANSCRIPT_INJECTION_PLAN.md +446 -0
- package/package.json +1 -1
- package/src/hook-entrypoints.test.mjs +288 -0
- package/src/prompt-submit.mjs +131 -4
- package/src/resume-context.mjs +79 -6
- package/src/resume-context.test.mjs +161 -9
- package/src/session-start.mjs +95 -13
- package/src/spike-transcript-writer.mjs +196 -0
- package/src/spike-transcript-writer.test.mjs +298 -0
|
@@ -93,7 +93,9 @@ test('buildResumeContext: header is terse and announces the Bash invocation cont
|
|
|
93
93
|
});
|
|
94
94
|
|
|
95
95
|
assert.ok(text);
|
|
96
|
-
|
|
96
|
+
// A 経路: 「直前スレッドの継続応答用コンテキスト」 framing (元の「中断した作業の再開」よりも
|
|
97
|
+
// 強い directive。モデルが /clear 後の短い prompt を新規依頼として扱うのを抑止する目的)
|
|
98
|
+
assert.match(text, /^## Throughline: 直前スレッドの継続応答用コンテキスト/);
|
|
97
99
|
|
|
98
100
|
// 旧版の冗長な行は全部削除
|
|
99
101
|
assert.ok(!text.includes('と報告してください'), 'meta-report instruction must be gone');
|
|
@@ -101,11 +103,152 @@ test('buildResumeContext: header is terse and announces the Bash invocation cont
|
|
|
101
103
|
assert.ok(!text.includes('内訳の読み方'), 'glossary block must be gone');
|
|
102
104
|
assert.ok(!text.includes('現在進行中の作業の active work context'), 'verbose framing must be gone');
|
|
103
105
|
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
+
// A 経路の必須シグナル: 「あなた自身が直前にユーザーと交わした会話」 + 「新規依頼ではなく続き」
|
|
107
|
+
// + 短い指示の扱い + 「新規会話ではない」明示
|
|
108
|
+
assert.match(text, /あなた自身が直前にユーザーと交わした会話/);
|
|
109
|
+
assert.match(text, /新規依頼ではなく、上記スレッドの \*\*続き\*\*/);
|
|
110
|
+
assert.match(text, /続きよろしく.*OK.*次は?/s);
|
|
111
|
+
assert.match(text, /新規会話ではない/);
|
|
112
|
+
// β 経路 (early-style explicit report-back instruction): モデルが冒頭で
|
|
113
|
+
// 「引き継いだ状態で続けます」と明示的に表明することで、user が体感する継続感を強める
|
|
114
|
+
assert.match(text, /応答の冒頭で必ず以下を 1 行宣言/);
|
|
115
|
+
assert.match(text, /Throughline で前のセッションから .* ターン分の記憶を引き継いだ状態で続けます/);
|
|
106
116
|
assert.match(
|
|
107
117
|
text,
|
|
108
|
-
|
|
118
|
+
/\*\*各ターンの詳細\*\*: \*\*`Bash` ツールで `throughline detail HH:MM:SS` を実行\*\* \(該当ターンの本文+詳細を stdout に返します\)/,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// v2.1: 古い番号リスト (1/2/3) を最新ユーザーが「2 をやれ」のように参照しても、
|
|
122
|
+
// 直前アシスタントで既に実行済みなら再実行ではなく結果確認に回るというガード。
|
|
123
|
+
// (このセッションで実際にハマった misread の再発防止)
|
|
124
|
+
assert.match(text, /古い番号リストの再実行禁止/);
|
|
125
|
+
assert.match(text, /既に直前アシスタントターンで実装\/実行済み/);
|
|
126
|
+
assert.match(text, /最新アシスタント発話の指示が、過去ターンのリストへの参照より上位/);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('buildResumeContext: 現在地 anchor surfaces the latest user/assistant exchange above L1/L2', () => {
|
|
130
|
+
const db = makeDb();
|
|
131
|
+
// 25 turns to exercise an L2 window edge and ensure the anchor picks the newest.
|
|
132
|
+
for (let t = 1; t <= 25; t += 1) {
|
|
133
|
+
insertBody(db, {
|
|
134
|
+
session: 'new',
|
|
135
|
+
origin: 'old',
|
|
136
|
+
turn: t,
|
|
137
|
+
role: 'user',
|
|
138
|
+
text: `user turn ${t}`,
|
|
139
|
+
createdAt: 1000 + t * 10,
|
|
140
|
+
});
|
|
141
|
+
insertBody(db, {
|
|
142
|
+
session: 'new',
|
|
143
|
+
origin: 'old',
|
|
144
|
+
turn: t,
|
|
145
|
+
role: 'assistant',
|
|
146
|
+
text: `assistant turn ${t}`,
|
|
147
|
+
createdAt: 1000 + t * 10 + 1,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const text = buildResumeContext(db, {
|
|
152
|
+
sessionId: 'new',
|
|
153
|
+
isInheritance: true,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
assert.ok(text);
|
|
157
|
+
|
|
158
|
+
const anchorIdx = text.indexOf('### 現在地 (直前のやりとり)');
|
|
159
|
+
const l2Idx = text.indexOf('### 直前の対話 (L2 / active work thread, 古い順)');
|
|
160
|
+
assert.ok(anchorIdx > 0, '現在地 anchor section should be present');
|
|
161
|
+
assert.ok(l2Idx > anchorIdx, '現在地 anchor must appear before the L2 section');
|
|
162
|
+
|
|
163
|
+
// The anchor must point to turn 25 (the latest), not any earlier turn.
|
|
164
|
+
assert.match(text, /\*\*最新ユーザー指示\*\* \[\d\d:\d\d:\d\d\]: user turn 25$/m);
|
|
165
|
+
assert.match(text, /\*\*直前のアシスタント\*\* \[\d\d:\d\d:\d\d\]: assistant turn 25$/m);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('buildResumeContext: 現在地 anchor truncates long bodies but full body still appears in L2', () => {
|
|
169
|
+
const db = makeDb();
|
|
170
|
+
const longText = 'a'.repeat(1200);
|
|
171
|
+
insertBody(db, {
|
|
172
|
+
session: 'new',
|
|
173
|
+
origin: 'old',
|
|
174
|
+
turn: 1,
|
|
175
|
+
role: 'assistant',
|
|
176
|
+
text: longText,
|
|
177
|
+
createdAt: 1000,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const text = buildResumeContext(db, {
|
|
181
|
+
sessionId: 'new',
|
|
182
|
+
isInheritance: true,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
assert.ok(text);
|
|
186
|
+
|
|
187
|
+
const anchorLine = text
|
|
188
|
+
.split('\n')
|
|
189
|
+
.find((l) => l.startsWith('**直前のアシスタント**'));
|
|
190
|
+
assert.ok(anchorLine, '直前のアシスタント anchor line should be present');
|
|
191
|
+
// Anchor must be truncated with ellipsis (originally 1200 chars > 600 cap).
|
|
192
|
+
assert.ok(anchorLine.endsWith(' …'), 'long anchor body must end with the ellipsis marker');
|
|
193
|
+
assert.ok(
|
|
194
|
+
anchorLine.length < longText.length,
|
|
195
|
+
'anchor line should be shorter than the original body',
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
// Full body must still appear in the L2 section below.
|
|
199
|
+
assert.match(text, new RegExp(`\\[assistant\\]: ${longText}`));
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test('buildResumeContext: 現在地 anchor is omitted for non-inheritance sessions', () => {
|
|
203
|
+
const db = makeDb();
|
|
204
|
+
insertBody(db, {
|
|
205
|
+
session: 'new',
|
|
206
|
+
origin: 'old',
|
|
207
|
+
turn: 1,
|
|
208
|
+
role: 'assistant',
|
|
209
|
+
text: 'a body',
|
|
210
|
+
createdAt: 1000,
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const text = buildResumeContext(db, {
|
|
214
|
+
sessionId: 'new',
|
|
215
|
+
isInheritance: false,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
assert.ok(text);
|
|
219
|
+
assert.ok(
|
|
220
|
+
!text.includes('現在地'),
|
|
221
|
+
'normal sessions (isInheritance=false) must not include the 現在地 anchor',
|
|
222
|
+
);
|
|
223
|
+
assert.ok(
|
|
224
|
+
!text.includes('最新ユーザー指示'),
|
|
225
|
+
'normal sessions must not surface a latest-user pointer',
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('buildResumeContext: 現在地 anchor handles a single-role recent window', () => {
|
|
230
|
+
const db = makeDb();
|
|
231
|
+
// Only user rows (no assistant) — anchor should still render with just the user line.
|
|
232
|
+
insertBody(db, {
|
|
233
|
+
session: 'new',
|
|
234
|
+
origin: 'old',
|
|
235
|
+
turn: 1,
|
|
236
|
+
role: 'user',
|
|
237
|
+
text: 'lone user message',
|
|
238
|
+
createdAt: 1000,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const text = buildResumeContext(db, {
|
|
242
|
+
sessionId: 'new',
|
|
243
|
+
isInheritance: true,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
assert.ok(text);
|
|
247
|
+
assert.ok(text.includes('### 現在地 (直前のやりとり)'));
|
|
248
|
+
assert.match(text, /\*\*最新ユーザー指示\*\* \[\d\d:\d\d:\d\d\]: lone user message/);
|
|
249
|
+
assert.ok(
|
|
250
|
+
!text.includes('**直前のアシスタント**'),
|
|
251
|
+
'no assistant body present → no 直前のアシスタント line',
|
|
109
252
|
);
|
|
110
253
|
});
|
|
111
254
|
|
|
@@ -239,9 +382,11 @@ test('buildResumeContext: L2 entries get inline (詳細:…) suffixes with too
|
|
|
239
382
|
|
|
240
383
|
assert.ok(text);
|
|
241
384
|
|
|
385
|
+
// Look for the L2 body line specifically (not the 現在地 anchor line which also
|
|
386
|
+
// contains the latest assistant body).
|
|
242
387
|
const turnWithToolsLine = text
|
|
243
388
|
.split('\n')
|
|
244
|
-
.find((l) =>
|
|
389
|
+
.find((l) => /^\[\d\d:\d\d:\d\d\] \[assistant\]: turn with tools/.test(l));
|
|
245
390
|
assert.ok(turnWithToolsLine, 'L2 line for turn 5 should exist');
|
|
246
391
|
// - tool_input + tool_output は tool 名で集約 (Bash ×2)
|
|
247
392
|
// - hook 出力 (system) は suffix から除外
|
|
@@ -265,7 +410,9 @@ test('buildResumeContext: L2 entries get inline (詳細:…) suffixes with too
|
|
|
265
410
|
'per-line should not repeat the throughline detail command (the header announces it)',
|
|
266
411
|
);
|
|
267
412
|
|
|
268
|
-
const plainLine = text
|
|
413
|
+
const plainLine = text
|
|
414
|
+
.split('\n')
|
|
415
|
+
.find((l) => /^\[\d\d:\d\d:\d\d\] \[user\]: plain user message/.test(l));
|
|
269
416
|
assert.ok(plainLine, 'L2 line for turn 6 should exist');
|
|
270
417
|
assert.ok(
|
|
271
418
|
!plainLine.includes('詳細:'),
|
|
@@ -341,9 +488,14 @@ test('buildResumeContext: (詳細:…) suffix appears only on the last role ro
|
|
|
341
488
|
|
|
342
489
|
assert.ok(text);
|
|
343
490
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
const
|
|
491
|
+
// Match L2 body lines specifically (`[HH:MM:SS] [role]: ...`) to avoid colliding
|
|
492
|
+
// with the 現在地 anchor lines (`**最新ユーザー指示** [HH:MM:SS]: ...`).
|
|
493
|
+
const lines = text.split('\n');
|
|
494
|
+
const userTurn5 = lines.find((l) => /^\[\d\d:\d\d:\d\d\] \[user\]: user side of turn 5/.test(l));
|
|
495
|
+
const assistantTurn5 = lines.find(
|
|
496
|
+
(l) => /^\[\d\d:\d\d:\d\d\] \[assistant\]: assistant side of turn 5/.test(l),
|
|
497
|
+
);
|
|
498
|
+
const userTurn6 = lines.find((l) => /^\[\d\d:\d\d:\d\d\] \[user\]: lone user turn/.test(l));
|
|
347
499
|
|
|
348
500
|
assert.ok(userTurn5 && assistantTurn5 && userTurn6);
|
|
349
501
|
// Turn 5: only assistant (last role of the turn) gets the suffix
|
package/src/session-start.mjs
CHANGED
|
@@ -30,11 +30,33 @@ import { consumeBaton } from './baton.mjs';
|
|
|
30
30
|
import { mergeSpecificPredecessor, resolveMergeTarget } from './session-merger.mjs';
|
|
31
31
|
import { buildResumeContext } from './resume-context.mjs';
|
|
32
32
|
import { ensureMonitorTaskFile } from './vscode-task.mjs';
|
|
33
|
-
import { appendFileSync, mkdirSync } from 'node:fs';
|
|
33
|
+
import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
34
|
+
import { randomBytes } from 'node:crypto';
|
|
34
35
|
import { join, dirname } from 'node:path';
|
|
35
36
|
import { homedir } from 'node:os';
|
|
36
37
|
import { pathToFileURL } from 'node:url';
|
|
37
38
|
|
|
39
|
+
// SPIKE ONLY — Phase 0-2 / 0-4 検証用。marker file 削除で無効化される。
|
|
40
|
+
// docs/THROUGHLINE_TRANSCRIPT_INJECTION_PLAN.md §3 Phase 0-2 参照。
|
|
41
|
+
const SPIKE_MARKER_PATH = join(homedir(), '.throughline', 'spike-inject.flag');
|
|
42
|
+
|
|
43
|
+
// Phase 0-6: initialUserMessage が interactive モードで効くか実機検証する experimental switch。
|
|
44
|
+
// flag 存在時、SessionStart hook は plain stdout の代わりに JSON 出力に切り替わり、
|
|
45
|
+
// hookSpecificOutput.initialUserMessage に tracer 入りメッセージを乗せる。
|
|
46
|
+
// openclaude の OSS 実装では「headless 専用」と記載されているが、real CC の挙動は未確認。
|
|
47
|
+
const INITIAL_USER_MESSAGE_TEST_FLAG = join(homedir(), '.throughline', 'initial-user-message-test.flag');
|
|
48
|
+
|
|
49
|
+
function logInitialUserMessageTest(entry) {
|
|
50
|
+
const path = join(homedir(), '.throughline', 'logs', 'initial-user-message-test.log');
|
|
51
|
+
try {
|
|
52
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
53
|
+
appendFileSync(path, JSON.stringify(entry) + '\n', 'utf8');
|
|
54
|
+
} catch (err) {
|
|
55
|
+
const msg = err instanceof Error ? err.message : 'unknown';
|
|
56
|
+
process.stderr.write(`[session-start:initialUserMessage-test-log] ${msg}\n`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
38
60
|
const ENV_DISABLE_AUTO_HANDOFF = 'THROUGHLINE_DISABLE_AUTO_HANDOFF';
|
|
39
61
|
|
|
40
62
|
function isAutoHandoffDisabled(env) {
|
|
@@ -88,7 +110,7 @@ export async function run() {
|
|
|
88
110
|
});
|
|
89
111
|
|
|
90
112
|
const payload = JSON.parse(raw);
|
|
91
|
-
const { session_id, cwd, source } = payload;
|
|
113
|
+
const { session_id, cwd, source, transcript_path } = payload;
|
|
92
114
|
|
|
93
115
|
if (!session_id) throw new Error('Missing session_id in SessionStart payload');
|
|
94
116
|
|
|
@@ -146,11 +168,80 @@ export async function run() {
|
|
|
146
168
|
mergeResult = { merged: false, skipReason: 'auto_handoff_disabled' };
|
|
147
169
|
}
|
|
148
170
|
|
|
171
|
+
// 4. 合流成立なら curated memory を stdout 注入 (L1 + L2 + L3 refs)
|
|
172
|
+
// 順序厳守: stdout flush を先に完了させてから spike 分岐へ進む。
|
|
173
|
+
// spike が throw しても stdout は既に attachment に保存されている。
|
|
174
|
+
//
|
|
175
|
+
// Phase 0-6: initialUserMessage test flag 存在時は JSON 出力に切り替え、
|
|
176
|
+
// initialUserMessage が interactive モードで messages[] に乗るか実機検証する。
|
|
177
|
+
if (mergeResult.merged) {
|
|
178
|
+
const text = buildResumeContext(db, {
|
|
179
|
+
sessionId: session_id,
|
|
180
|
+
isInheritance: true,
|
|
181
|
+
});
|
|
182
|
+
if (existsSync(INITIAL_USER_MESSAGE_TEST_FLAG)) {
|
|
183
|
+
// TEST MODE: emit JSON with initialUserMessage tracer. Plain stdout は出さない
|
|
184
|
+
// (= 通常 Throughline 案内文無し)。テスト 1 回限定。flag を削除すれば即復帰。
|
|
185
|
+
const tracer = randomBytes(4).toString('hex');
|
|
186
|
+
const initialMessage =
|
|
187
|
+
`[initial-user-tracer: ${tracer}]\n\n` +
|
|
188
|
+
`This text is being delivered via the SessionStart hook's ` +
|
|
189
|
+
`hookSpecificOutput.initialUserMessage field. If you can quote the 8-hex ` +
|
|
190
|
+
`tracer above when asked, it means initialUserMessage IS consumed in ` +
|
|
191
|
+
`interactive mode (not headless-only as openclaude documents).`;
|
|
192
|
+
const jsonOutput = JSON.stringify({
|
|
193
|
+
hookSpecificOutput: {
|
|
194
|
+
hookEventName: 'SessionStart',
|
|
195
|
+
initialUserMessage: initialMessage,
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
process.stdout.write(jsonOutput + '\n');
|
|
199
|
+
logInitialUserMessageTest({
|
|
200
|
+
ts: new Date(now).toISOString(),
|
|
201
|
+
session_id,
|
|
202
|
+
tracer,
|
|
203
|
+
mode: 'json-initial-user-message-only',
|
|
204
|
+
had_resume_context: Boolean(text),
|
|
205
|
+
});
|
|
206
|
+
} else if (text) {
|
|
207
|
+
process.stdout.write(text + '\n');
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// 5. SPIKE: marker file あり + merge 成立 + transcript_path あり の 3 条件で
|
|
212
|
+
// L2 を user/assistant role 付きで transcript_path にも append する。
|
|
213
|
+
// 本実装ではない (docs/THROUGHLINE_TRANSCRIPT_INJECTION_PLAN.md Phase 0-2)。
|
|
214
|
+
//
|
|
215
|
+
// tracer: 末尾 assistant 行に stdout 注入には含まれない一意トークンを付与する。
|
|
216
|
+
// 次の /clear 後に Claude が tracer を再現できれば JSONL 経路はモデル可視。
|
|
217
|
+
let spikeResult = null;
|
|
218
|
+
const spikeMarkerExists = existsSync(SPIKE_MARKER_PATH);
|
|
219
|
+
if (spikeMarkerExists && mergeResult.merged && transcript_path) {
|
|
220
|
+
try {
|
|
221
|
+
const { spikeInject, generateSpikeTracer } = await import('./spike-transcript-writer.mjs');
|
|
222
|
+
const tracer = generateSpikeTracer();
|
|
223
|
+
spikeResult = spikeInject({
|
|
224
|
+
db,
|
|
225
|
+
targetJsonlPath: transcript_path,
|
|
226
|
+
newSessionId: session_id,
|
|
227
|
+
cwd: projectPath,
|
|
228
|
+
version: payload.version ?? '2.1.145',
|
|
229
|
+
gitBranch: payload.gitBranch ?? 'main',
|
|
230
|
+
tracer,
|
|
231
|
+
});
|
|
232
|
+
} catch (err) {
|
|
233
|
+
const msg = err instanceof Error ? err.message : 'unknown';
|
|
234
|
+
process.stderr.write(`[spike-inject] ${msg}\n`);
|
|
235
|
+
spikeResult = { error: msg };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
149
239
|
logDecision({
|
|
150
240
|
ts: new Date(now).toISOString(),
|
|
151
241
|
source: source ?? null,
|
|
152
242
|
session_id,
|
|
153
243
|
project_path: projectPath,
|
|
244
|
+
transcript_path: transcript_path ?? null,
|
|
154
245
|
triggered_path: triggeredPath,
|
|
155
246
|
auto_handoff_disabled: autoDisabled,
|
|
156
247
|
baton_session_id: baton.sessionId ?? null,
|
|
@@ -159,19 +250,10 @@ export async function run() {
|
|
|
159
250
|
merged: mergeResult.merged,
|
|
160
251
|
merge_skip_reason: mergeResult.skipReason ?? null,
|
|
161
252
|
predecessor_id: mergeResult.predecessorId ?? null,
|
|
253
|
+
spike_marker_exists: spikeMarkerExists,
|
|
254
|
+
spike_result: spikeResult,
|
|
162
255
|
});
|
|
163
256
|
|
|
164
|
-
// 4. 合流成立なら curated memory を stdout 注入 (L1 + L2 + L3 refs)
|
|
165
|
-
if (mergeResult.merged) {
|
|
166
|
-
const text = buildResumeContext(db, {
|
|
167
|
-
sessionId: session_id,
|
|
168
|
-
isInheritance: true,
|
|
169
|
-
});
|
|
170
|
-
if (text) {
|
|
171
|
-
process.stdout.write(text + '\n');
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
257
|
process.exit(0);
|
|
176
258
|
}
|
|
177
259
|
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SPIKE ONLY — Phase 0-2 / 0-4 検証用。本実装ではない。
|
|
3
|
+
*
|
|
4
|
+
* docs/THROUGHLINE_TRANSCRIPT_INJECTION_PLAN.md Phase 0-2 で
|
|
5
|
+
* 「`/clear` 直後の SessionStart hook 内で transcript_path に L2 を user/assistant
|
|
6
|
+
* role 付きで append すると、Claude が次の short prompt の文脈として読むか」を実機検証する。
|
|
7
|
+
*
|
|
8
|
+
* 本実装ではない理由:
|
|
9
|
+
* - text content のみ復元 (tool_use / tool_result / thinking は割愛)
|
|
10
|
+
* - idempotency 簡易チェックのみ
|
|
11
|
+
* - 本実装 (Phase 1-1) では src/transcript-writer.mjs を別途作る
|
|
12
|
+
*
|
|
13
|
+
* tracer 経路: 注入した JSONL 行が**モデルの message 履歴に乗ったか**を切り分けるため、
|
|
14
|
+
* 最終 assistant 行末尾に **stdout 注入には含まれない一意トークン** を付与する。
|
|
15
|
+
* 次の /clear 後にユーザーがその合言葉の再現を求め、Claude が答えられれば JSONL 経路は
|
|
16
|
+
* モデル可視。答えられなければ JSONL は保持されてもメッセージ履歴に乗らない (孤立 chain
|
|
17
|
+
* 等が原因)。
|
|
18
|
+
*
|
|
19
|
+
* marker file `~/.throughline/spike-inject.flag` 削除で spike は無効化される。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { readFileSync, existsSync, fsyncSync, openSync, closeSync, writeSync } from 'node:fs';
|
|
23
|
+
import { randomUUID, randomBytes } from 'node:crypto';
|
|
24
|
+
import { buildHandoffRecord } from './handoff-record.mjs';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* targetJsonl の末尾行の uuid を返す。chain 設計案 (b) の親決定用。
|
|
28
|
+
* file が無い / 空 / uuid 持ち行が無い場合は null を返す。
|
|
29
|
+
*/
|
|
30
|
+
function readLastUuid(path) {
|
|
31
|
+
if (!existsSync(path)) return null;
|
|
32
|
+
const text = readFileSync(path, 'utf8');
|
|
33
|
+
const lines = text.split('\n').filter((l) => l.trim());
|
|
34
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
35
|
+
try {
|
|
36
|
+
const o = JSON.parse(lines[i]);
|
|
37
|
+
if (typeof o.uuid === 'string') return o.uuid;
|
|
38
|
+
} catch {
|
|
39
|
+
// skip non-json line
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildUserLine({ b, parentUuid, newSessionId, cwd, version, gitBranch }) {
|
|
46
|
+
const uuid = randomUUID();
|
|
47
|
+
const ts = new Date(b.createdAt ?? Date.now()).toISOString();
|
|
48
|
+
const obj = {
|
|
49
|
+
parentUuid,
|
|
50
|
+
isSidechain: false,
|
|
51
|
+
promptId: randomUUID(),
|
|
52
|
+
type: 'user',
|
|
53
|
+
message: {
|
|
54
|
+
role: 'user',
|
|
55
|
+
content: [{ type: 'text', text: b.text ?? '' }],
|
|
56
|
+
},
|
|
57
|
+
uuid,
|
|
58
|
+
timestamp: ts,
|
|
59
|
+
permissionMode: 'auto',
|
|
60
|
+
userType: 'external',
|
|
61
|
+
entrypoint: 'claude-vscode',
|
|
62
|
+
cwd,
|
|
63
|
+
sessionId: newSessionId,
|
|
64
|
+
version,
|
|
65
|
+
gitBranch,
|
|
66
|
+
};
|
|
67
|
+
return { uuid, line: JSON.stringify(obj) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function buildAssistantLine({ b, parentUuid, newSessionId, cwd, version, gitBranch, tracer, assistantModel }) {
|
|
71
|
+
const uuid = randomUUID();
|
|
72
|
+
const ts = new Date(b.createdAt ?? Date.now()).toISOString();
|
|
73
|
+
const baseText = b.text ?? '';
|
|
74
|
+
const text = tracer ? `${baseText}\n\n[spike-tracer: ${tracer}]` : baseText;
|
|
75
|
+
const obj = {
|
|
76
|
+
parentUuid,
|
|
77
|
+
isSidechain: false,
|
|
78
|
+
message: {
|
|
79
|
+
model: assistantModel,
|
|
80
|
+
id: `msg_spike_${uuid.slice(0, 8)}`,
|
|
81
|
+
type: 'message',
|
|
82
|
+
role: 'assistant',
|
|
83
|
+
content: [{ type: 'text', text }],
|
|
84
|
+
stop_reason: 'end_turn',
|
|
85
|
+
stop_sequence: null,
|
|
86
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
87
|
+
},
|
|
88
|
+
requestId: `req_spike_${uuid.slice(0, 8)}`,
|
|
89
|
+
type: 'assistant',
|
|
90
|
+
uuid,
|
|
91
|
+
timestamp: ts,
|
|
92
|
+
userType: 'external',
|
|
93
|
+
entrypoint: 'claude-vscode',
|
|
94
|
+
cwd,
|
|
95
|
+
sessionId: newSessionId,
|
|
96
|
+
version,
|
|
97
|
+
gitBranch,
|
|
98
|
+
};
|
|
99
|
+
return { uuid, line: JSON.stringify(obj) };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 末尾 assistant 行に埋める tracer を生成する。8 hex (32 bit)。
|
|
104
|
+
* stdout 注入には含まれない値である必要があるため、DB body text とは無相関に乱数生成する。
|
|
105
|
+
*/
|
|
106
|
+
export function generateSpikeTracer() {
|
|
107
|
+
return randomBytes(4).toString('hex');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* spike append. fsync 付きで JSONL に user/assistant 行を append する。
|
|
112
|
+
*
|
|
113
|
+
* @param {object} opts
|
|
114
|
+
* @param {string|null} [opts.tracer] 末尾 assistant 行に付与する一意トークン。
|
|
115
|
+
* 未指定なら付与しない (back-compat: 既存呼び出し用)。
|
|
116
|
+
* @returns {{
|
|
117
|
+
* appended: number,
|
|
118
|
+
* parentUuidStart: string|null,
|
|
119
|
+
* tracer: string|null,
|
|
120
|
+
* tracerAppendedAt: number|null,
|
|
121
|
+
* skipReason?: string
|
|
122
|
+
* }}
|
|
123
|
+
*/
|
|
124
|
+
// Phase 0-5 retry: 偽モデル名 ('claude-throughline-spike') では Claude Code が messages[]
|
|
125
|
+
// 構築時にフィルタしている可能性があるため、デフォルトは実在 Claude モデル名にする。
|
|
126
|
+
const DEFAULT_SPIKE_ASSISTANT_MODEL = 'claude-opus-4-7';
|
|
127
|
+
|
|
128
|
+
export function spikeInject({
|
|
129
|
+
db,
|
|
130
|
+
targetJsonlPath,
|
|
131
|
+
newSessionId,
|
|
132
|
+
cwd,
|
|
133
|
+
version,
|
|
134
|
+
gitBranch,
|
|
135
|
+
tracer = null,
|
|
136
|
+
assistantModel = DEFAULT_SPIKE_ASSISTANT_MODEL,
|
|
137
|
+
}) {
|
|
138
|
+
const record = buildHandoffRecord(db, { sessionId: newSessionId, isInheritance: true });
|
|
139
|
+
if (!record || !record.memory?.recentBodies?.length) {
|
|
140
|
+
return {
|
|
141
|
+
appended: 0,
|
|
142
|
+
parentUuidStart: null,
|
|
143
|
+
tracer: null,
|
|
144
|
+
tracerAppendedAt: null,
|
|
145
|
+
skipReason: 'no_record_or_empty_l2',
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const bodies = record.memory.recentBodies; // 古い順
|
|
149
|
+
|
|
150
|
+
// 末尾 assistant 行を 1 件特定 (assistant が必ず末尾とは限らないため後ろから探す)。
|
|
151
|
+
let lastAssistantIdx = -1;
|
|
152
|
+
for (let i = bodies.length - 1; i >= 0; i -= 1) {
|
|
153
|
+
if (bodies[i].role === 'assistant') {
|
|
154
|
+
lastAssistantIdx = i;
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let parentUuid = readLastUuid(targetJsonlPath);
|
|
160
|
+
const parentUuidStart = parentUuid;
|
|
161
|
+
const lines = [];
|
|
162
|
+
let tracerAppendedAt = null;
|
|
163
|
+
for (let i = 0; i < bodies.length; i += 1) {
|
|
164
|
+
const b = bodies[i];
|
|
165
|
+
const isLastAssistant = Boolean(tracer) && i === lastAssistantIdx;
|
|
166
|
+
const built = b.role === 'user'
|
|
167
|
+
? buildUserLine({ b, parentUuid, newSessionId, cwd, version, gitBranch })
|
|
168
|
+
: buildAssistantLine({
|
|
169
|
+
b,
|
|
170
|
+
parentUuid,
|
|
171
|
+
newSessionId,
|
|
172
|
+
cwd,
|
|
173
|
+
version,
|
|
174
|
+
gitBranch,
|
|
175
|
+
tracer: isLastAssistant ? tracer : null,
|
|
176
|
+
assistantModel,
|
|
177
|
+
});
|
|
178
|
+
if (isLastAssistant) tracerAppendedAt = i;
|
|
179
|
+
lines.push(built.line);
|
|
180
|
+
parentUuid = built.uuid;
|
|
181
|
+
}
|
|
182
|
+
// sync write + fsync で hook 終了前に確実に flush
|
|
183
|
+
const fd = openSync(targetJsonlPath, 'a');
|
|
184
|
+
try {
|
|
185
|
+
writeSync(fd, lines.join('\n') + '\n');
|
|
186
|
+
fsyncSync(fd);
|
|
187
|
+
} finally {
|
|
188
|
+
closeSync(fd);
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
appended: lines.length,
|
|
192
|
+
parentUuidStart,
|
|
193
|
+
tracer: tracer ?? null,
|
|
194
|
+
tracerAppendedAt,
|
|
195
|
+
};
|
|
196
|
+
}
|